use deps_core::ConcreteVersion;
use deps_core::EcosystemId;
use deps_core::PackageName;
use deps_core::lsp_helpers::{
OsvNameAvailability, has_unqueryable_resolved_pin, resolve_in_use_versions,
};
use std::collections::HashMap;
pub fn build_scan_targets(
parse_result: &dyn deps_core::ParseResult,
resolved_versions: &HashMap<PackageName, ConcreteVersion>,
resolved_version_candidates: &HashMap<PackageName, Vec<ConcreteVersion>>,
formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
ecosystem: EcosystemId,
) -> (
Vec<deps_core::osv::ScanTarget>,
deps_core::osv::VulnerabilityMap,
) {
use deps_core::osv::{OsvQueryName, ScanOutcome, SkipReason};
use std::collections::hash_map::Entry;
let mut targets: Vec<deps_core::osv::ScanTarget> = Vec::new();
let mut target_index: HashMap<deps_core::osv::VulnKey, usize> = HashMap::new();
let mut skipped = deps_core::osv::VulnerabilityMap::new();
let keys = deps_core::osv::vulnerability_keys(
parse_result,
resolved_versions,
Some(resolved_version_candidates),
formatter,
ecosystem,
);
for dep in parse_result.dependencies() {
let normalized_name = formatter.normalize_package_name(dep.name());
let key = deps_core::osv::vuln_key_for(dep, Some(&keys), formatter);
if !formatter.source_is_public_registry_content(&dep.source()) {
skipped.insert(key, ScanOutcome::Skipped(SkipReason::NonRegistrySource));
continue;
}
let versions = resolve_in_use_versions(
dep,
&normalized_name,
resolved_versions,
Some(resolved_version_candidates),
formatter,
ecosystem,
);
let Some(versions) = versions else {
let reason = if has_unqueryable_resolved_pin(dep, formatter, ecosystem) {
SkipReason::ResolvedTagNotFullVersion
} else {
SkipReason::NoConcreteVersion
};
skipped.insert(key, ScanOutcome::Skipped(reason));
continue;
};
let osv_name = match formatter.osv_name_availability(dep) {
OsvNameAvailability::Ready => match formatter.osv_package_name(dep) {
Some(name) => OsvQueryName::Confirmed(name),
None => {
skipped.insert(key, ScanOutcome::Skipped(SkipReason::UnmappableName));
continue;
}
},
OsvNameAvailability::AwaitingRegistryData {
written_fallback: Some(name),
} => OsvQueryName::Provisional(name),
OsvNameAvailability::AwaitingRegistryData {
written_fallback: None,
} => {
skipped.insert(
key,
ScanOutcome::Skipped(SkipReason::CanonicalNameUnconfirmed),
);
continue;
}
};
let target = deps_core::osv::ScanTarget::from_native(
key,
osv_name,
versions.primary().clone(),
formatter,
)
.with_siblings(&versions, formatter);
match target_index.entry(target.key.clone()) {
Entry::Vacant(slot) => {
slot.insert(targets.len());
targets.push(target);
}
Entry::Occupied(slot) => {
if let Some(existing) = targets.get_mut(*slot.get())
&& matches!(existing.osv_name, OsvQueryName::Provisional(_))
&& matches!(target.osv_name, OsvQueryName::Confirmed(_))
{
*existing = target;
}
}
}
}
(targets, skipped)
}
enum DepCheckClassification<'a> {
NonRegistrySource,
NoCachedVersions,
AwaitingOsvName {
cached: &'a deps_core::lsp_helpers::PackageVersions,
},
UnmappableName {
cached: &'a deps_core::lsp_helpers::PackageVersions,
},
Target {
osv_name: deps_core::osv::OsvPackageName,
cached: &'a deps_core::lsp_helpers::PackageVersions,
},
}
fn classify_dep_for_check_targets<'a>(
dep: &dyn deps_core::Dependency,
cached_versions: &'a HashMap<PackageName, deps_core::lsp_helpers::PackageVersions>,
formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
) -> DepCheckClassification<'a> {
if !formatter.source_is_public_registry_content(&dep.source()) {
return DepCheckClassification::NonRegistrySource;
}
let normalized_name = formatter.normalize_package_name(dep.name());
let Some(cached) = cached_versions
.get(normalized_name.as_str())
.or_else(|| cached_versions.get(dep.name()))
else {
return DepCheckClassification::NoCachedVersions;
};
if matches!(
formatter.osv_name_availability(dep),
OsvNameAvailability::AwaitingRegistryData { .. }
) {
return DepCheckClassification::AwaitingOsvName { cached };
}
match formatter.osv_package_name(dep) {
Some(osv_name) => DepCheckClassification::Target { osv_name, cached },
None => DepCheckClassification::UnmappableName { cached },
}
}
pub fn build_latest_check_targets(
parse_result: &dyn deps_core::ParseResult,
cached_versions: &HashMap<PackageName, deps_core::lsp_helpers::PackageVersions>,
vuln_keys: &deps_core::osv::VulnKeys,
formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
) -> (
Vec<deps_core::osv::ScanTarget>,
deps_core::osv::LatestStatusMap,
) {
use deps_core::osv::{SkipReason, StructuralSkipReason, UpgradeStatus, vuln_key_for};
let mut targets = Vec::new();
let mut structural = deps_core::osv::LatestStatusMap::new();
let mut seen = std::collections::HashSet::new();
for dep in parse_result.dependencies() {
let key = vuln_key_for(dep, Some(vuln_keys), formatter);
match classify_dep_for_check_targets(dep, cached_versions, formatter) {
DepCheckClassification::NonRegistrySource => {
structural.insert(
key,
UpgradeStatus::StructurallyUnchecked(StructuralSkipReason::NonRegistrySource),
);
}
DepCheckClassification::NoCachedVersions => {}
DepCheckClassification::AwaitingOsvName { cached } => {
structural.insert(
key,
UpgradeStatus::CandidateUnverified {
version: cached.latest.clone(),
reason: SkipReason::CanonicalNameUnconfirmed,
},
);
}
DepCheckClassification::UnmappableName { cached } => {
structural.insert(
key,
UpgradeStatus::CandidateUnverified {
version: cached.latest.clone(),
reason: SkipReason::UnmappableName,
},
);
}
DepCheckClassification::Target { osv_name, cached } => {
if !seen.insert(key.clone()) {
continue;
}
targets.push(deps_core::osv::ScanTarget::from_native(
key,
deps_core::osv::OsvQueryName::Confirmed(osv_name),
cached.latest.clone(),
formatter,
));
}
}
}
(targets, structural)
}
const MAX_CANDIDATE_CHECK_VERSIONS: usize = 6;
pub fn build_candidate_check_targets(
parse_result: &dyn deps_core::ParseResult,
cached_versions: &HashMap<PackageName, deps_core::lsp_helpers::PackageVersions>,
vuln_keys: &deps_core::osv::VulnKeys,
formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
) -> (
Vec<Vec<deps_core::osv::ScanTarget>>,
deps_core::osv::CandidateStatusMap,
) {
use deps_core::osv::{CandidateStatuses, StructuralSkipReason, vuln_key_for};
let mut rounds: Vec<Vec<deps_core::osv::ScanTarget>> =
vec![Vec::new(); MAX_CANDIDATE_CHECK_VERSIONS];
let mut structural = deps_core::osv::CandidateStatusMap::new();
let mut seen = std::collections::HashSet::new();
for dep in parse_result.dependencies() {
let key = vuln_key_for(dep, Some(vuln_keys), formatter);
let (osv_name, package_versions) =
match classify_dep_for_check_targets(dep, cached_versions, formatter) {
DepCheckClassification::NonRegistrySource => {
structural.insert(
key,
CandidateStatuses::Structural(StructuralSkipReason::NonRegistrySource),
);
continue;
}
DepCheckClassification::NoCachedVersions => continue,
DepCheckClassification::AwaitingOsvName { .. } => continue,
DepCheckClassification::UnmappableName { .. } => {
structural.insert(
key,
CandidateStatuses::Structural(StructuralSkipReason::UnmappableName),
);
continue;
}
DepCheckClassification::Target { osv_name, cached } => (osv_name, cached),
};
if !seen.insert(key.clone()) {
continue;
}
let yanked: std::collections::HashSet<&ConcreteVersion> = package_versions
.yanked
.iter()
.map(|(version, _)| version)
.collect();
for (rank, version) in package_versions
.available
.iter()
.filter(|v| !yanked.contains(v))
.take(MAX_CANDIDATE_CHECK_VERSIONS)
.enumerate()
{
let Some(bucket) = rounds.get_mut(rank) else {
continue;
};
bucket.push(deps_core::osv::ScanTarget::from_native(
key.clone(),
deps_core::osv::OsvQueryName::Confirmed(osv_name.clone()),
version.clone(),
formatter,
));
}
}
(rounds, structural)
}
#[derive(Debug, PartialEq, Eq)]
enum FixTargetResolution {
Skip,
Resolved(deps_core::osv::UpgradeStatus),
NeedsLiveCheck(deps_core::osv::ScanTarget),
}
fn resolve_fix_target(
dv: &deps_core::osv::DependencyVulnerabilities,
key: &deps_core::osv::VulnKey,
latest_status: &deps_core::osv::LatestStatusMap,
osv_name_by_key: &HashMap<deps_core::osv::VulnKey, deps_core::osv::OsvQueryName>,
formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
) -> FixTargetResolution {
use deps_core::edit::{VulnFixSkip, resolve_recommended_fix};
use deps_core::osv::{OsvQueryName, ScanTarget, UpgradeStatus};
let latest = latest_status.get(key);
let (fix, version_native) = match resolve_recommended_fix(dv, latest, formatter) {
Ok(pair) => pair,
Err(VulnFixSkip::NoRecommendedFix) => return FixTargetResolution::Skip,
Err(VulnFixSkip::UnsafeVersion) => {
tracing::debug!(
key = %key,
"OSV #462: fix-target version failed validation, skipping verification"
);
return FixTargetResolution::Skip;
}
Err(
VulnFixSkip::UnverifiedTarget
| VulnFixSkip::RequirementAlreadyResolves
| VulnFixSkip::NoOpRewrite
| VulnFixSkip::UnresolvedPlaceholder
| VulnFixSkip::OversizedRequirement,
) => return FixTargetResolution::Skip,
};
let reused_latest_version: Option<&str> = match latest {
Some(
UpgradeStatus::CandidateClean { version }
| UpgradeStatus::CandidateVulnerable { version, .. },
) => Some(version.as_str()),
Some(_) | None => None,
};
if reused_latest_version == Some(version_native.as_str()) {
return FixTargetResolution::Resolved(latest.cloned().unwrap_or(UpgradeStatus::NotChecked));
}
let Some(osv_name) = osv_name_by_key.get(key) else {
tracing::debug!(
key = %key,
"OSV #462: no osv_name on record for fix-target verification, skipping"
);
return FixTargetResolution::Skip;
};
let osv_name = match osv_name {
OsvQueryName::Confirmed(name) => name.clone(),
OsvQueryName::Provisional(_) => {
tracing::debug!(
key = %key,
"OSV #1694: unconfirmed package name, fix target stays unverified"
);
return FixTargetResolution::Skip;
}
};
FixTargetResolution::NeedsLiveCheck(ScanTarget::new(
key.clone(),
OsvQueryName::Confirmed(osv_name),
fix.version,
ConcreteVersion::new(version_native),
))
}
pub fn collect_fix_target_resolutions(
vulnerabilities: &deps_core::osv::VulnerabilityMap,
vulnerable_keys: &[deps_core::osv::VulnKey],
osv_name_by_key: &HashMap<deps_core::osv::VulnKey, deps_core::osv::OsvQueryName>,
latest_status: &deps_core::osv::LatestStatusMap,
formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
) -> (
Vec<(deps_core::osv::VulnKey, deps_core::osv::UpgradeStatus)>,
Vec<deps_core::osv::ScanTarget>,
) {
use deps_core::osv::ScanOutcome;
let mut resolved = Vec::new();
let mut live_check_candidates = Vec::new();
for key in vulnerable_keys {
let Some(ScanOutcome::Vulnerable(dv)) = vulnerabilities.get(key) else {
continue;
};
match resolve_fix_target(dv, key, latest_status, osv_name_by_key, formatter) {
FixTargetResolution::Skip => {}
FixTargetResolution::Resolved(status) => resolved.push((key.clone(), status)),
FixTargetResolution::NeedsLiveCheck(target) => live_check_candidates.push(target),
}
}
(resolved, live_check_candidates)
}
pub fn apply_live_fix_target_statuses(
vulnerabilities: &mut deps_core::osv::VulnerabilityMap,
statuses: HashMap<deps_core::osv::VulnKey, deps_core::osv::UpgradeStatus>,
) {
use deps_core::osv::ScanOutcome;
for (key, status) in statuses {
if let Some(ScanOutcome::Vulnerable(dv)) = vulnerabilities.get_mut(&key) {
dv.fix_target_status = status;
}
}
}
#[must_use]
pub fn osv_name_by_key(
targets: &[deps_core::osv::ScanTarget],
) -> HashMap<deps_core::osv::VulnKey, deps_core::osv::OsvQueryName> {
targets
.iter()
.map(|t| (t.key.clone(), t.osv_name.clone()))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::classify::resolved::collect_in_use_versions;
use deps_core::VersionReq;
use std::assert_matches;
mod osv_scan_target_tests {
use super::*;
use deps_core::Dependency;
use deps_core::lsp_helpers::{
DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
RequirementResolution, SourcePolicy,
};
use deps_core::parser::DependencySource;
use deps_core::position::{Position, Range};
use deps_core::test_util::StubFormatter;
use std::any::Any;
struct MockDep {
name: PackageName,
version_req: Option<VersionReq>,
source: DependencySource,
}
impl Dependency for MockDep {
fn name(&self) -> &PackageName {
&self.name
}
fn name_range(&self) -> Range {
let addr = std::ptr::from_ref(self) as u32;
Range::new(Position::new(0, addr), Position::new(0, addr + 1))
}
fn version_requirement(&self) -> Option<&VersionReq> {
self.version_req.as_ref()
}
fn version_range(&self) -> Option<Range> {
None
}
fn source(&self) -> DependencySource {
self.source.clone()
}
fn as_any(&self) -> &dyn Any {
self
}
}
struct MockParseResult {
deps: Vec<MockDep>,
}
impl deps_core::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 {
static URI: std::sync::OnceLock<url::Url> = std::sync::OnceLock::new();
URI.get_or_init(|| deps_core::test_util::test_uri("/test/Cargo.toml"))
}
fn as_any(&self) -> &dyn Any {
self
}
}
use deps_core::osv::{ScanOutcome, SkipReason};
#[test]
fn build_scan_targets_step0_skips_non_registry_source_even_with_lockfile_version() {
let parse_result = MockParseResult {
deps: vec![MockDep {
name: PackageName::new("time"),
version_req: Some(VersionReq::new("0.1.43")),
source: DependencySource::Git {
url: "https://github.com/example/time".to_string(),
rev: None,
},
}],
};
let mut resolved = HashMap::new();
resolved.insert(PackageName::new("time"), "0.1.43".into());
let (targets, skipped) = build_scan_targets(
&parse_result,
&resolved,
&HashMap::new(),
&StubFormatter::DEFAULT,
EcosystemId::Cargo,
);
assert!(targets.is_empty());
assert_matches!(
skipped.get(&deps_core::test_util::vuln_key("time")),
Some(ScanOutcome::Skipped(SkipReason::NonRegistrySource))
);
}
#[test]
fn build_scan_targets_step1_prefers_lockfile_resolved_version() {
let parse_result = MockParseResult {
deps: vec![MockDep {
name: PackageName::new("serde"),
version_req: Some(VersionReq::new("^1.0")),
source: DependencySource::Registry,
}],
};
let mut resolved = HashMap::new();
resolved.insert(PackageName::new("serde"), "1.0.195".into());
let (targets, skipped) = build_scan_targets(
&parse_result,
&resolved,
&HashMap::new(),
&StubFormatter::DEFAULT,
EcosystemId::Cargo,
);
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].version, "1.0.195");
assert!(skipped.is_empty());
}
const MOCK_GO_FORMATTER: StubFormatter = StubFormatter::new()
.with_package_url_prefix("https://pkg.go.dev/")
.with_manifest_requirement_as_resolved_version();
struct MockVPrefixFormatter;
impl PackageNaming for MockVPrefixFormatter {}
impl PackageRendering for MockVPrefixFormatter {
fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
version.to_string()
}
fn package_url(&self, name: &PackageName) -> String {
format!("https://example.com/{}", name.as_str())
}
}
impl RequirementResolution for MockVPrefixFormatter {}
impl DiagnosticMessages for MockVPrefixFormatter {}
impl DiagnosticPolicy for MockVPrefixFormatter {}
impl SourcePolicy for MockVPrefixFormatter {}
impl OsvNaming for MockVPrefixFormatter {
fn osv_version(&self, version: &ConcreteVersion) -> deps_core::osv::OsvVersion {
let version = version.as_str();
deps_core::osv::OsvVersion::new(version.strip_prefix('v').unwrap_or(version))
}
}
#[test]
fn build_scan_targets_normalizes_version_via_formatter_osv_version_hook() {
let parse_result = MockParseResult {
deps: vec![MockDep {
name: PackageName::new("github.com/gin-gonic/gin"),
version_req: Some(VersionReq::new("v1.9.0")),
source: DependencySource::Registry,
}],
};
let mut resolved = HashMap::new();
resolved.insert(
PackageName::new("github.com/gin-gonic/gin"),
"v1.9.0".into(),
);
let (targets, skipped) = build_scan_targets(
&parse_result,
&resolved,
&HashMap::new(),
&MockVPrefixFormatter,
EcosystemId::Go,
);
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].version, "1.9.0");
assert_eq!(targets[0].display_version, "v1.9.0");
assert!(skipped.is_empty());
}
#[test]
fn build_scan_targets_leaves_version_unaffected_for_default_identity_formatter() {
let parse_result = MockParseResult {
deps: vec![MockDep {
name: PackageName::new("serde"),
version_req: Some(VersionReq::new("^1.0")),
source: DependencySource::Registry,
}],
};
let mut resolved = HashMap::new();
resolved.insert(PackageName::new("serde"), "1.0.195".into());
let (targets, skipped) = build_scan_targets(
&parse_result,
&resolved,
&HashMap::new(),
&StubFormatter::DEFAULT,
EcosystemId::Cargo,
);
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].version, "1.0.195");
assert_eq!(targets[0].display_version, "1.0.195");
assert!(skipped.is_empty());
}
#[test]
fn build_scan_targets_go_ignores_stale_lockfile_version_uses_go_mod_requirement() {
let parse_result = MockParseResult {
deps: vec![MockDep {
name: PackageName::new("github.com/pkg/errors"),
version_req: Some(VersionReq::new("v0.8.1")),
source: DependencySource::Registry,
}],
};
let mut resolved = HashMap::new();
resolved.insert(PackageName::new("github.com/pkg/errors"), "v0.9.1".into());
let (targets, skipped) = build_scan_targets(
&parse_result,
&resolved,
&HashMap::new(),
&MOCK_GO_FORMATTER,
EcosystemId::Go,
);
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].version, "0.8.1");
assert_eq!(targets[0].display_version, "v0.8.1");
assert!(skipped.is_empty());
}
#[cfg(feature = "deno")]
#[test]
fn build_scan_targets_deno_bare_pinned_jsr_dep_is_unmappable_name_skip() {
let parse_result = MockParseResult {
deps: vec![MockDep {
name: PackageName::new("jsr:@std/fs"),
version_req: Some(VersionReq::new("1.0.0")),
source: DependencySource::Registry,
}],
};
let (targets, skipped) = build_scan_targets(
&parse_result,
&HashMap::new(),
&HashMap::new(),
&deps_deno::DenoFormatter,
EcosystemId::Deno,
);
assert!(targets.is_empty(), "jsr: dep must never reach an OSV query");
assert_eq!(skipped.len(), 1);
assert_matches!(
skipped.get(&deps_core::test_util::vuln_key("jsr:@std/fs")),
Some(ScanOutcome::Skipped(SkipReason::UnmappableName))
);
}
#[cfg(feature = "composer")]
#[test]
fn build_scan_targets_composer_mixed_case_name_is_lowercased_for_osv() {
let parse_result = MockParseResult {
deps: vec![MockDep {
name: PackageName::new("Symfony/Http-Kernel"),
version_req: Some(VersionReq::new("4.4.0")),
source: DependencySource::Registry,
}],
};
let (targets, skipped) = build_scan_targets(
&parse_result,
&HashMap::new(),
&HashMap::new(),
&deps_composer::ComposerFormatter,
EcosystemId::Composer,
);
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].osv_name.name(), "symfony/http-kernel");
assert!(skipped.is_empty());
}
#[cfg(feature = "pypi")]
#[test]
fn build_scan_targets_pypi_separator_only_key_is_skipped_and_sibling_still_queried() {
let dep = |name: &str| MockDep {
name: PackageName::new(name),
version_req: Some(VersionReq::new("==1.0.0")),
source: DependencySource::Registry,
};
let parse_result = MockParseResult {
deps: vec![dep("Werkzeug"), dep("---")],
};
let (targets, skipped) = build_scan_targets(
&parse_result,
&HashMap::new(),
&HashMap::new(),
&deps_pypi::PypiFormatter,
EcosystemId::Pypi,
);
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].osv_name.name(), "werkzeug");
assert_eq!(skipped.len(), 1);
assert_matches!(
skipped.get(&deps_core::test_util::vuln_key("")),
Some(ScanOutcome::Skipped(SkipReason::UnmappableName))
);
}
#[cfg(feature = "dart")]
#[test]
fn build_scan_targets_dart_plus_build_number_pin_without_lockfile_is_queried() {
let dep = |name: &str, req: &str| MockDep {
name: PackageName::new(name),
version_req: Some(VersionReq::new(req)),
source: DependencySource::Registry,
};
let parse_result = MockParseResult {
deps: vec![
dep("image_picker_android", "0.8.13+1"),
dep("caret_pkg", "^0.8.13+1"),
],
};
let (targets, skipped) = build_scan_targets(
&parse_result,
&HashMap::new(),
&HashMap::new(),
&deps_dart::DartFormatter,
EcosystemId::Dart,
);
assert_eq!(targets.len(), 1, "{skipped:?}");
assert_eq!(targets[0].display_version, "0.8.13+1");
}
#[cfg(feature = "github-actions")]
#[test]
fn build_scan_targets_github_actions_sha_pin_with_moving_major_comment_stays_skipped() {
use deps_core::lsp_helpers::{CommitSha, TagIndex};
use deps_core::osv::{ScanOutcome, SkipReason};
use deps_github_actions::{GithubActionsFormatter, GithubActionsRegistry};
use std::sync::Arc;
let sha = "d".repeat(40);
let uri = deps_core::test_util::test_uri("/repo/.github/workflows/ci.yml");
let content = format!("steps:\n - uses: actions/checkout@{sha} # v1\n");
let parse_result =
deps_github_actions::parse_workflow_yaml(&content, &uri).expect("valid yaml");
let cache = Arc::new(deps_core::HttpCache::new());
let registry = GithubActionsRegistry::new(cache);
let tag_index = registry.tag_index();
let mut index = TagIndex::default();
index.insert_sha_pin(
CommitSha::parse(&sha).unwrap(),
deps_core::lsp_helpers::ResolvedPin::most_specific(
deps_core::ConcreteVersion::new("v1"),
),
);
tag_index.insert(PackageName::new("actions/checkout"), Arc::new(index));
let formatter = GithubActionsFormatter::new(tag_index);
let (targets, skipped) = build_scan_targets(
&parse_result,
&HashMap::new(),
&HashMap::new(),
&formatter,
EcosystemId::GithubActions,
);
assert!(
targets.is_empty(),
"a moving-major TagIndex-resolved tag must not reach OSV as a fabricated version: {targets:?}"
);
assert_matches!(
skipped.get(&deps_core::test_util::vuln_key("actions/checkout")),
Some(ScanOutcome::Skipped(SkipReason::ResolvedTagNotFullVersion))
);
}
#[cfg(feature = "github-actions")]
#[test]
fn build_scan_targets_github_actions_sha_pin_two_component_release_tag() {
use deps_core::lsp_helpers::{CommitSha, TagIndex};
use deps_core::osv::{ScanOutcome, SkipReason};
use deps_github_actions::{GithubActionsFormatter, GithubActionsRegistry};
use std::sync::Arc;
let sha = "f".repeat(40);
let uri = deps_core::test_util::test_uri("/repo/.github/workflows/ci.yml");
let content = format!("steps:\n - uses: actions/checkout@{sha} # v2.9\n");
let parse_result =
deps_github_actions::parse_workflow_yaml(&content, &uri).expect("valid yaml");
let commit = CommitSha::parse(&sha).unwrap();
for (tags, expected) in [
(vec!["v2", "v2.9"], Some("v2.9")),
(vec!["v2.9", "v2.9.1"], Some("v2.9.1")),
(vec!["v2.9"], Some("v2.9")),
(vec!["v2"], None),
] {
let cache = Arc::new(deps_core::HttpCache::new());
let registry = GithubActionsRegistry::new(cache);
let tag_index = registry.tag_index();
tag_index.insert(
PackageName::new("actions/checkout"),
Arc::new(
TagIndex::from_tags(tags.iter().map(|t| (*t, &commit)))
.with_canonical_repo_name(
deps_core::github::CanonicalRepoName::from_commit_url(
"https://api.github.com/repos/actions/checkout/commits/abc",
),
),
),
);
let formatter = GithubActionsFormatter::new(tag_index);
let (targets, skipped) = build_scan_targets(
&parse_result,
&HashMap::new(),
&HashMap::new(),
&formatter,
EcosystemId::GithubActions,
);
match expected {
Some(version) => {
assert_eq!(targets.len(), 1, "{tags:?}: {skipped:?}");
assert_eq!(targets[0].display_version, version);
assert!(skipped.is_empty());
}
None => {
assert!(targets.is_empty(), "{tags:?}: {targets:?}");
assert_matches!(
skipped.get(&deps_core::test_util::vuln_key("actions/checkout")),
Some(ScanOutcome::Skipped(SkipReason::ResolvedTagNotFullVersion))
);
}
}
}
}
#[cfg(feature = "github-actions")]
#[test]
fn build_scan_targets_github_actions_sha_pin_tag_index_resolves_to_full_semver_tag() {
use deps_core::lsp_helpers::{CommitSha, TagIndex};
use deps_github_actions::{GithubActionsFormatter, GithubActionsRegistry};
use std::sync::Arc;
let sha = "e".repeat(40);
let uri = deps_core::test_util::test_uri("/repo/.github/workflows/ci.yml");
let content = format!("steps:\n - uses: actions/checkout@{sha} # v1\n");
let parse_result =
deps_github_actions::parse_workflow_yaml(&content, &uri).expect("valid yaml");
let cache = Arc::new(deps_core::HttpCache::new());
let registry = GithubActionsRegistry::new(cache);
let tag_index = registry.tag_index();
let mut index = TagIndex::default();
index.insert_sha_pin(
CommitSha::parse(&sha).unwrap(),
deps_core::lsp_helpers::ResolvedPin::most_specific(
deps_core::ConcreteVersion::new("v1.3.0"),
),
);
let index = index.with_canonical_repo_name(
deps_core::github::CanonicalRepoName::from_commit_url(
"https://api.github.com/repos/actions/checkout/commits/abc",
),
);
tag_index.insert(PackageName::new("actions/checkout"), Arc::new(index));
let formatter = GithubActionsFormatter::new(tag_index);
let (targets, skipped) = build_scan_targets(
&parse_result,
&HashMap::new(),
&HashMap::new(),
&formatter,
EcosystemId::GithubActions,
);
assert_eq!(
targets.len(),
1,
"expected the SHA pin to reach a real OSV scan target: {skipped:?}"
);
assert!(skipped.is_empty());
assert_eq!(targets[0].display_version, "v1.3.0");
}
#[cfg(feature = "github-actions")]
#[test]
fn build_scan_targets_github_actions_attaches_sibling_release_tags() {
use deps_core::lsp_helpers::{CommitSha, TagIndex};
use deps_github_actions::{GithubActionsFormatter, GithubActionsRegistry};
use std::sync::Arc;
let sha = "e".repeat(40);
let commit = CommitSha::parse(&sha).unwrap();
let uri = deps_core::test_util::test_uri("/repo/.github/workflows/ci.yml");
let content = format!(
"steps:\n - uses: actions/checkout@{sha}\n - uses: other/action@v4.9.0\n"
);
let parse_result =
deps_github_actions::parse_workflow_yaml(&content, &uri).expect("valid yaml");
let registry = GithubActionsRegistry::new(Arc::new(deps_core::HttpCache::new()));
let tag_index = registry.tag_index();
let canonical = |repo: &str| {
deps_core::github::CanonicalRepoName::from_commit_url(&format!(
"https://api.github.com/repos/{repo}/commits/abc"
))
};
tag_index.insert(
PackageName::new("actions/checkout"),
Arc::new(
TagIndex::from_tags([("v4.8.0", &commit), ("v4.9.0", &commit)])
.with_canonical_repo_name(canonical("actions/checkout")),
),
);
tag_index.insert(
PackageName::new("other/action"),
Arc::new(
TagIndex::from_tags([("v4.9.0", &commit), ("v4.10.0", &commit)])
.with_canonical_repo_name(canonical("other/action")),
),
);
let formatter = GithubActionsFormatter::new(tag_index);
let (targets, skipped) = build_scan_targets(
&parse_result,
&HashMap::new(),
&HashMap::new(),
&formatter,
EcosystemId::GithubActions,
);
assert!(skipped.is_empty(), "{skipped:?}");
let versions = |name: &str| {
let target = targets
.iter()
.find(|t| t.key.as_str() == name)
.expect("target for name");
(
target.display_version.to_string(),
target
.siblings()
.iter()
.map(|s| s.display_version().to_string())
.collect::<Vec<_>>(),
)
};
assert_eq!(
versions("actions/checkout"),
("v4.8.0".to_string(), vec!["v4.9.0".to_string()])
);
assert_eq!(
versions("other/action"),
("v4.9.0".to_string(), vec!["v4.10.0".to_string()])
);
}
#[test]
fn build_scan_targets_without_a_tag_pin_has_no_siblings() {
let parse_result = MockParseResult {
deps: vec![MockDep {
name: PackageName::new("log4j-core"),
version_req: Some(VersionReq::new("2.14.1")),
source: DependencySource::Registry,
}],
};
let (targets, _) = build_scan_targets(
&parse_result,
&HashMap::new(),
&HashMap::new(),
&StubFormatter::DEFAULT,
EcosystemId::Maven,
);
assert!(targets.iter().all(|t| t.siblings().is_empty()));
}
#[test]
fn build_scan_targets_step2_uses_concrete_requirement_verbatim() {
let parse_result = MockParseResult {
deps: vec![MockDep {
name: PackageName::new("log4j-core"),
version_req: Some(VersionReq::new("2.14.1")),
source: DependencySource::Registry,
}],
};
let resolved = HashMap::new();
let (targets, skipped) = build_scan_targets(
&parse_result,
&resolved,
&HashMap::new(),
&StubFormatter::DEFAULT,
EcosystemId::Maven,
);
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].version, "2.14.1");
assert!(skipped.is_empty());
}
#[test]
fn build_scan_targets_step2_strips_pin_marker_for_operator_prefixed_requirements() {
let cargo_result = MockParseResult {
deps: vec![MockDep {
name: PackageName::new("time"),
version_req: Some(VersionReq::new("=1.2.3")),
source: DependencySource::Registry,
}],
};
let (targets, skipped) = build_scan_targets(
&cargo_result,
&HashMap::new(),
&HashMap::new(),
&StubFormatter::DEFAULT,
EcosystemId::Cargo,
);
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].version, "1.2.3");
assert!(skipped.is_empty());
let nuget_result = MockParseResult {
deps: vec![MockDep {
name: PackageName::new("Newtonsoft.Json"),
version_req: Some(VersionReq::new("[1.0.0]")),
source: DependencySource::Registry,
}],
};
let (targets, skipped) = build_scan_targets(
&nuget_result,
&HashMap::new(),
&HashMap::new(),
&StubFormatter::DEFAULT,
EcosystemId::NuGet,
);
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].version, "1.0.0");
assert!(skipped.is_empty());
}
#[test]
fn build_scan_targets_step3_skips_caret_range_with_no_lockfile_entry() {
let parse_result = MockParseResult {
deps: vec![MockDep {
name: PackageName::new("serde"),
version_req: Some(VersionReq::new("^1.0")),
source: DependencySource::Registry,
}],
};
let resolved = HashMap::new();
let (targets, skipped) = build_scan_targets(
&parse_result,
&resolved,
&HashMap::new(),
&StubFormatter::DEFAULT,
EcosystemId::Cargo,
);
assert!(targets.is_empty());
assert_matches!(
skipped.get(&deps_core::test_util::vuln_key("serde")),
Some(ScanOutcome::Skipped(SkipReason::NoConcreteVersion))
);
}
#[test]
fn build_scan_targets_step3_skips_wildcard_with_no_lockfile_entry() {
let parse_result = MockParseResult {
deps: vec![MockDep {
name: PackageName::new("serde"),
version_req: Some(VersionReq::new("*")),
source: DependencySource::Registry,
}],
};
let resolved = HashMap::new();
let (targets, skipped) = build_scan_targets(
&parse_result,
&resolved,
&HashMap::new(),
&StubFormatter::DEFAULT,
EcosystemId::Cargo,
);
assert!(targets.is_empty());
assert_matches!(
skipped.get(&deps_core::test_util::vuln_key("serde")),
Some(ScanOutcome::Skipped(SkipReason::NoConcreteVersion))
);
}
#[test]
fn build_scan_targets_all_non_registry_sources_are_skipped() {
let sources = vec![
DependencySource::Path {
path: "../local".to_string(),
},
DependencySource::Url {
url: "https://example.com/pkg.tgz".to_string(),
},
DependencySource::Sdk {
sdk: "flutter".to_string(),
},
DependencySource::Workspace,
DependencySource::CustomRegistry {
url: "https://private.example.com".to_string(),
},
];
for source in sources {
let parse_result = MockParseResult {
deps: vec![MockDep {
name: PackageName::new("pkg"),
version_req: Some(VersionReq::new("1.0.0")),
source: source.clone(),
}],
};
let mut resolved = HashMap::new();
resolved.insert(PackageName::new("pkg"), "1.0.0".into());
let (targets, skipped) = build_scan_targets(
&parse_result,
&resolved,
&HashMap::new(),
&StubFormatter::DEFAULT,
EcosystemId::Cargo,
);
assert!(targets.is_empty(), "{source:?} must be skipped (step 0)");
assert_matches!(
skipped.get(&deps_core::test_util::vuln_key("pkg")),
Some(ScanOutcome::Skipped(SkipReason::NonRegistrySource))
);
}
}
#[test]
fn build_scan_targets_never_drops_a_dependency_silently() {
let parse_result = MockParseResult {
deps: vec![
MockDep {
name: PackageName::new("concrete"),
version_req: Some(VersionReq::new("2.14.1")),
source: DependencySource::Registry,
},
MockDep {
name: PackageName::new("range-only"),
version_req: Some(VersionReq::new("^1.0")),
source: DependencySource::Registry,
},
MockDep {
name: PackageName::new("git-dep"),
version_req: Some(VersionReq::new("1.0.0")),
source: DependencySource::Git {
url: "https://example.com/git-dep".to_string(),
rev: None,
},
},
],
};
let resolved = HashMap::new();
let (targets, skipped) = build_scan_targets(
&parse_result,
&resolved,
&HashMap::new(),
&StubFormatter::DEFAULT,
EcosystemId::Maven,
);
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].key.as_str(), "concrete");
assert_eq!(skipped.len(), 2);
assert_matches!(
skipped.get(&deps_core::test_util::vuln_key("range-only")),
Some(ScanOutcome::Skipped(SkipReason::NoConcreteVersion))
);
assert_matches!(
skipped.get(&deps_core::test_util::vuln_key("git-dep")),
Some(ScanOutcome::Skipped(SkipReason::NonRegistrySource))
);
}
#[test]
fn collect_in_use_versions_prefers_lockfile_resolved_version() {
let parse_result = MockParseResult {
deps: vec![MockDep {
name: PackageName::new("serde"),
version_req: Some(VersionReq::new("^1.0")),
source: DependencySource::Registry,
}],
};
let mut resolved = HashMap::new();
resolved.insert(PackageName::new("serde"), "1.0.195".into());
let in_use = collect_in_use_versions(
&parse_result,
&resolved,
&HashMap::new(),
&StubFormatter::DEFAULT,
EcosystemId::Cargo,
);
assert_eq!(
in_use.get(&PackageName::new("serde")),
Some(&vec![ConcreteVersion::from("1.0.195")])
);
}
#[test]
fn collect_in_use_versions_concrete_pin_without_lockfile() {
let parse_result = MockParseResult {
deps: vec![MockDep {
name: PackageName::new("log4j-core"),
version_req: Some(VersionReq::new("2.14.1")),
source: DependencySource::Registry,
}],
};
let resolved = HashMap::new();
let in_use = collect_in_use_versions(
&parse_result,
&resolved,
&HashMap::new(),
&StubFormatter::DEFAULT,
EcosystemId::Maven,
);
assert_eq!(
in_use.get(&PackageName::new("log4j-core")),
Some(&vec![ConcreteVersion::from("2.14.1")])
);
}
#[test]
fn collect_in_use_versions_strips_pep440_double_equals_pin_for_pypi() {
let parse_result = MockParseResult {
deps: vec![MockDep {
name: PackageName::new("typing_extensions"),
version_req: Some(VersionReq::new("==4.9.0")),
source: DependencySource::Registry,
}],
};
let resolved = HashMap::new();
let in_use = collect_in_use_versions(
&parse_result,
&resolved,
&HashMap::new(),
&StubFormatter::DEFAULT,
EcosystemId::Pypi,
);
assert_eq!(
in_use.get(&PackageName::new("typing_extensions")),
Some(&vec![ConcreteVersion::from("4.9.0")]),
"pep440 '==' comparator must be stripped, not carried into the in-use version"
);
}
#[test]
fn collect_in_use_versions_skips_non_concrete_requirement_with_no_lockfile() {
let parse_result = MockParseResult {
deps: vec![MockDep {
name: PackageName::new("serde"),
version_req: Some(VersionReq::new("^1.0")),
source: DependencySource::Registry,
}],
};
let resolved = HashMap::new();
let in_use = collect_in_use_versions(
&parse_result,
&resolved,
&HashMap::new(),
&StubFormatter::DEFAULT,
EcosystemId::Cargo,
);
assert!(in_use.is_empty());
}
#[test]
fn collect_in_use_versions_excludes_non_registry_source_even_with_lockfile_version() {
let parse_result = MockParseResult {
deps: vec![MockDep {
name: PackageName::new("time"),
version_req: Some(VersionReq::new("0.1.43")),
source: DependencySource::Git {
url: "https://github.com/example/time".to_string(),
rev: None,
},
}],
};
let mut resolved = HashMap::new();
resolved.insert(PackageName::new("time"), "0.1.43".into());
let in_use = collect_in_use_versions(
&parse_result,
&resolved,
&HashMap::new(),
&StubFormatter::DEFAULT,
EcosystemId::Cargo,
);
assert!(in_use.is_empty());
}
#[test]
fn collect_in_use_versions_tracks_all_occurrences_of_duplicate_name() {
let parse_result = MockParseResult {
deps: vec![
MockDep {
name: PackageName::new("time"),
version_req: Some(VersionReq::new("=0.1.43")),
source: DependencySource::Registry,
},
MockDep {
name: PackageName::new("time"),
version_req: Some(VersionReq::new("=0.1.44")),
source: DependencySource::Registry,
},
],
};
let resolved = HashMap::new();
let in_use = collect_in_use_versions(
&parse_result,
&resolved,
&HashMap::new(),
&StubFormatter::DEFAULT,
EcosystemId::Cargo,
);
assert_eq!(
in_use.get(&PackageName::new("time")),
Some(&vec![
ConcreteVersion::from("0.1.43"),
ConcreteVersion::from("0.1.44")
]),
"both occurrences' in-use versions must be tracked, not just the last one"
);
}
}
mod build_check_targets_tests {
use super::*;
use deps_core::Dependency;
use deps_core::lsp_helpers::{
DiagnosticMessages, DiagnosticPolicy, OsvNaming, PackageNaming, PackageRendering,
PackageVersions, RequirementResolution, SourcePolicy,
};
use deps_core::osv::{CandidateStatuses, StructuralSkipReason, UpgradeStatus};
use deps_core::parser::DependencySource;
use deps_core::position::{Position, Range};
use deps_core::test_util::StubFormatter;
use std::any::Any;
use std::sync::Arc;
struct MockDep {
name: PackageName,
source: DependencySource,
}
impl Dependency for MockDep {
fn name(&self) -> &PackageName {
&self.name
}
fn name_range(&self) -> Range {
let addr = std::ptr::from_ref(self) as u32;
Range::new(Position::new(0, addr), Position::new(0, addr + 1))
}
fn version_requirement(&self) -> Option<&VersionReq> {
None
}
fn version_range(&self) -> Option<Range> {
None
}
fn source(&self) -> DependencySource {
self.source.clone()
}
fn as_any(&self) -> &dyn Any {
self
}
}
struct MockParseResult {
deps: Vec<MockDep>,
}
impl deps_core::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 {
static URI: std::sync::OnceLock<url::Url> = std::sync::OnceLock::new();
URI.get_or_init(|| deps_core::test_util::test_uri("/test/Cargo.toml"))
}
fn as_any(&self) -> &dyn Any {
self
}
}
struct UnmappableNameFormatter;
impl PackageNaming for UnmappableNameFormatter {}
impl PackageRendering for UnmappableNameFormatter {
fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
version.to_string()
}
fn package_url(&self, name: &PackageName) -> String {
name.as_str().to_string()
}
}
impl RequirementResolution for UnmappableNameFormatter {}
impl DiagnosticMessages for UnmappableNameFormatter {}
impl DiagnosticPolicy for UnmappableNameFormatter {}
impl SourcePolicy for UnmappableNameFormatter {}
impl OsvNaming for UnmappableNameFormatter {
fn osv_package_name(
&self,
_dep: &dyn Dependency,
) -> Option<deps_core::osv::OsvPackageName> {
None
}
}
#[derive(Default)]
struct AwaitingNameFormatter {
with_fallback: bool,
}
impl PackageNaming for AwaitingNameFormatter {}
impl PackageRendering for AwaitingNameFormatter {
fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
version.to_string()
}
fn package_url(&self, name: &PackageName) -> String {
name.as_str().to_string()
}
}
impl RequirementResolution for AwaitingNameFormatter {}
impl DiagnosticMessages for AwaitingNameFormatter {}
impl DiagnosticPolicy for AwaitingNameFormatter {}
impl SourcePolicy for AwaitingNameFormatter {}
impl OsvNaming for AwaitingNameFormatter {
fn osv_name_availability(
&self,
dep: &dyn Dependency,
) -> deps_core::lsp_helpers::OsvNameAvailability {
deps_core::lsp_helpers::OsvNameAvailability::AwaitingRegistryData {
written_fallback: self
.with_fallback
.then(|| deps_core::osv::OsvPackageName::new_or_skip(dep.name().as_str()))
.flatten(),
}
}
}
fn awaiting_name_fixture() -> (
MockParseResult,
deps_core::osv::VulnKeys,
HashMap<PackageName, PackageVersions>,
) {
let parse_result = MockParseResult {
deps: vec![MockDep {
name: PackageName::new("gha-action"),
source: DependencySource::Registry,
}],
};
let vuln_keys = vuln_keys_for(&parse_result, &AwaitingNameFormatter::default());
let mut cached_versions = HashMap::new();
cached_versions.insert(
PackageName::new("gha-action"),
PackageVersions::latest_only("1.0.0"),
);
(parse_result, vuln_keys, cached_versions)
}
#[test]
fn build_latest_check_targets_awaiting_osv_name_is_unverified_not_structural() {
let (parse_result, vuln_keys, cached_versions) = awaiting_name_fixture();
let (targets, statuses) = build_latest_check_targets(
&parse_result,
&cached_versions,
&vuln_keys,
&AwaitingNameFormatter::default(),
);
assert!(targets.is_empty());
assert_eq!(
statuses.get(&deps_core::test_util::vuln_key("gha-action")),
Some(&UpgradeStatus::CandidateUnverified {
version: ConcreteVersion::new("1.0.0"),
reason: deps_core::osv::SkipReason::CanonicalNameUnconfirmed,
})
);
}
#[test]
fn build_candidate_check_targets_awaiting_osv_name_records_no_entry() {
let (parse_result, vuln_keys, cached_versions) = awaiting_name_fixture();
let (rounds, statuses) = build_candidate_check_targets(
&parse_result,
&cached_versions,
&vuln_keys,
&AwaitingNameFormatter::default(),
);
assert!(rounds.iter().all(Vec::is_empty));
assert!(
statuses.is_empty(),
"a transient name gap must never be stored as structural: {statuses:?}"
);
}
#[cfg(feature = "github-actions")]
#[test]
fn build_scan_targets_github_actions_unconfirmed_name_is_provisional() {
use deps_core::lsp_helpers::TagIndex;
use deps_core::osv::OsvQueryName;
use deps_github_actions::{GithubActionsFormatter, GithubActionsRegistry};
use std::sync::Arc;
let uri = deps_core::test_util::test_uri("/repo/.github/workflows/ci.yml");
let parse_result = deps_github_actions::parse_workflow_yaml(
"steps:\n - uses: azure/setup-kubectl@v4.1.2\n",
&uri,
)
.expect("valid yaml");
let registry = GithubActionsRegistry::new(Arc::new(deps_core::HttpCache::new()));
let written = deps_core::osv::OsvPackageName::new("azure/setup-kubectl").unwrap();
let canonical = deps_core::osv::OsvPackageName::new("Azure/setup-kubectl").unwrap();
for warm_without_canonical in [false, true] {
let tag_index = registry.tag_index();
tag_index.clear();
if warm_without_canonical {
tag_index.insert(
PackageName::new("azure/setup-kubectl"),
Arc::new(TagIndex::default()),
);
}
let formatter = GithubActionsFormatter::new(tag_index);
let (targets, skipped) = build_scan_targets(
&parse_result,
&HashMap::new(),
&HashMap::new(),
&formatter,
EcosystemId::GithubActions,
);
assert!(skipped.is_empty(), "{skipped:?}");
assert_eq!(targets.len(), 1);
assert_eq!(
targets[0].osv_name,
OsvQueryName::Provisional(written.clone()),
"warm_without_canonical={warm_without_canonical}"
);
}
let tag_index = registry.tag_index();
tag_index.insert(
PackageName::new("azure/setup-kubectl"),
Arc::new(TagIndex::default().with_canonical_repo_name(
deps_core::github::CanonicalRepoName::from_commit_url(
"https://api.github.com/repos/Azure/setup-kubectl/commits/abc",
),
)),
);
let formatter = GithubActionsFormatter::new(tag_index);
let (targets, _) = build_scan_targets(
&parse_result,
&HashMap::new(),
&HashMap::new(),
&formatter,
EcosystemId::GithubActions,
);
assert_eq!(targets[0].osv_name, OsvQueryName::Confirmed(canonical));
}
#[cfg(feature = "github-actions")]
#[test]
fn build_scan_targets_github_actions_floating_tag_resolves_via_commit() {
use deps_core::lsp_helpers::{CommitSha, TagIndex};
use deps_core::osv::{OsvQueryName, ScanOutcome, SkipReason};
use deps_github_actions::{GithubActionsFormatter, GithubActionsRegistry};
use std::sync::Arc;
let uri = deps_core::test_util::test_uri("/repo/.github/workflows/ci.yml");
let parse_result = deps_github_actions::parse_workflow_yaml(
"steps:\n - uses: actions/checkout@v4\n",
&uri,
)
.expect("valid yaml");
let registry = GithubActionsRegistry::new(Arc::new(deps_core::HttpCache::new()));
let key = deps_core::test_util::vuln_key("actions/checkout");
let commit = CommitSha::parse(&"a".repeat(40)).unwrap();
let scan = |tags: &[&str]| {
let tag_index = registry.tag_index();
tag_index.clear();
if !tags.is_empty() {
let index = TagIndex::from_tags(tags.iter().map(|t| (*t, &commit)))
.with_canonical_repo_name(
deps_core::github::CanonicalRepoName::from_commit_url(
"https://api.github.com/repos/actions/checkout/commits/abc",
),
);
tag_index.insert(PackageName::new("actions/checkout"), Arc::new(index));
}
build_scan_targets(
&parse_result,
&HashMap::new(),
&HashMap::new(),
&GithubActionsFormatter::new(tag_index),
EcosystemId::GithubActions,
)
};
let (targets, skipped) = scan(&["v4", "v4.2.2"]);
assert!(skipped.is_empty(), "{skipped:?}");
assert_eq!(targets.len(), 1);
assert_eq!(targets[0].version, "4.2.2");
assert_matches!(targets[0].osv_name, OsvQueryName::Confirmed(_));
let (targets, skipped) = scan(&[]);
assert!(targets.is_empty());
assert_matches!(
skipped.get(&key),
Some(ScanOutcome::Skipped(SkipReason::NoConcreteVersion))
);
let (targets, skipped) = scan(&["v4"]);
assert!(targets.is_empty());
assert_matches!(
skipped.get(&key),
Some(ScanOutcome::Skipped(SkipReason::ResolvedTagNotFullVersion))
);
}
#[test]
fn build_scan_targets_awaiting_name_without_fallback_is_unconfirmed_skip() {
let parse_result = MockParseResult {
deps: vec![MockDep {
name: PackageName::new("gha-action"),
source: DependencySource::Registry,
}],
};
let mut resolved = HashMap::new();
resolved.insert(
PackageName::new("gha-action"),
ConcreteVersion::new("1.0.0"),
);
let (targets, skipped) = build_scan_targets(
&parse_result,
&resolved,
&HashMap::new(),
&AwaitingNameFormatter::default(),
EcosystemId::Cargo,
);
assert!(targets.is_empty());
assert_matches!(
skipped.get(&deps_core::test_util::vuln_key("gha-action")),
Some(deps_core::osv::ScanOutcome::Skipped(
deps_core::osv::SkipReason::CanonicalNameUnconfirmed
))
);
}
struct SequencedNameFormatter {
ready: [bool; 2],
calls: std::sync::atomic::AtomicUsize,
}
impl PackageNaming for SequencedNameFormatter {}
impl PackageRendering for SequencedNameFormatter {
fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
version.to_string()
}
fn package_url(&self, name: &PackageName) -> String {
name.as_str().to_string()
}
}
impl RequirementResolution for SequencedNameFormatter {}
impl DiagnosticMessages for SequencedNameFormatter {}
impl DiagnosticPolicy for SequencedNameFormatter {}
impl SourcePolicy for SequencedNameFormatter {}
impl OsvNaming for SequencedNameFormatter {
fn osv_name_availability(
&self,
dep: &dyn Dependency,
) -> deps_core::lsp_helpers::OsvNameAvailability {
let idx = self.calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
if self.ready[idx] {
deps_core::lsp_helpers::OsvNameAvailability::Ready
} else {
deps_core::lsp_helpers::OsvNameAvailability::AwaitingRegistryData {
written_fallback: deps_core::osv::OsvPackageName::new_or_skip(
dep.name().as_str(),
),
}
}
}
}
#[test]
fn build_scan_targets_dedups_per_key_preferring_confirmed_name() {
use deps_core::osv::OsvQueryName;
let mut resolved = HashMap::new();
resolved.insert(PackageName::new("dup"), ConcreteVersion::new("1.0.0"));
let name = deps_core::osv::OsvPackageName::new("dup").unwrap();
for (ready, expected) in [
([false, true], OsvQueryName::Confirmed(name.clone())),
([true, false], OsvQueryName::Confirmed(name.clone())),
([false, false], OsvQueryName::Provisional(name)),
] {
let parse_result = MockParseResult {
deps: ["dup", "dup"]
.into_iter()
.map(|n| MockDep {
name: PackageName::new(n),
source: DependencySource::Registry,
})
.collect(),
};
let formatter = SequencedNameFormatter {
ready,
calls: std::sync::atomic::AtomicUsize::new(0),
};
let (targets, _) = build_scan_targets(
&parse_result,
&resolved,
&HashMap::new(),
&formatter,
EcosystemId::Cargo,
);
assert_eq!(targets.len(), 1, "ready={ready:?}");
assert_eq!(targets[0].osv_name, expected, "ready={ready:?}");
}
}
fn vuln_keys_for(
parse_result: &dyn deps_core::ParseResult,
formatter: &dyn deps_core::lsp_helpers::EcosystemFormatter,
) -> deps_core::osv::VulnKeys {
deps_core::osv::vulnerability_keys(
parse_result,
&HashMap::new(),
None,
formatter,
EcosystemId::Cargo,
)
}
#[test]
fn build_latest_check_targets_non_registry_source_is_structurally_unchecked() {
let parse_result = MockParseResult {
deps: vec![MockDep {
name: PackageName::new("vendored"),
source: DependencySource::Path {
path: "../vendored".to_string(),
},
}],
};
let vuln_keys = vuln_keys_for(&parse_result, &StubFormatter::DEFAULT);
let (targets, structural) = build_latest_check_targets(
&parse_result,
&HashMap::new(),
&vuln_keys,
&StubFormatter::DEFAULT,
);
assert!(targets.is_empty());
assert_eq!(
structural.get(&deps_core::test_util::vuln_key("vendored")),
Some(&UpgradeStatus::StructurallyUnchecked(
StructuralSkipReason::NonRegistrySource
))
);
}
#[test]
fn build_candidate_check_targets_non_registry_source_is_structural() {
let parse_result = MockParseResult {
deps: vec![MockDep {
name: PackageName::new("vendored"),
source: DependencySource::Path {
path: "../vendored".to_string(),
},
}],
};
let vuln_keys = vuln_keys_for(&parse_result, &StubFormatter::DEFAULT);
let (rounds, structural) = build_candidate_check_targets(
&parse_result,
&HashMap::new(),
&vuln_keys,
&StubFormatter::DEFAULT,
);
assert!(
rounds.iter().all(Vec::is_empty),
"no round may hold a non-registry dependency"
);
assert_eq!(
structural.get(&deps_core::test_util::vuln_key("vendored")),
Some(&CandidateStatuses::Structural(
StructuralSkipReason::NonRegistrySource
))
);
}
#[test]
fn build_candidate_check_targets_unmappable_name_is_structural() {
let parse_result = MockParseResult {
deps: vec![MockDep {
name: PackageName::new("jsr-pinned"),
source: DependencySource::Registry,
}],
};
let vuln_keys = vuln_keys_for(&parse_result, &UnmappableNameFormatter);
let mut cached_versions = HashMap::new();
cached_versions.insert(
PackageName::new("jsr-pinned"),
PackageVersions::latest_only("1.0.0"),
);
let (rounds, structural) = build_candidate_check_targets(
&parse_result,
&cached_versions,
&vuln_keys,
&UnmappableNameFormatter,
);
assert!(
rounds.iter().all(Vec::is_empty),
"an unmappable name has no OSV-checkable round targets"
);
assert_eq!(
structural.get(&deps_core::test_util::vuln_key("jsr-pinned")),
Some(&CandidateStatuses::Structural(
StructuralSkipReason::UnmappableName
))
);
}
fn duplicated_registry_dep() -> MockParseResult {
MockParseResult {
deps: vec![
MockDep {
name: PackageName::new("lodash"),
source: DependencySource::Registry,
},
MockDep {
name: PackageName::new("lodash"),
source: DependencySource::Registry,
},
],
}
}
#[test]
fn build_latest_check_targets_dedups_duplicate_key_occurrences() {
let parse_result = duplicated_registry_dep();
let vuln_keys = vuln_keys_for(&parse_result, &StubFormatter::DEFAULT);
let mut cached_versions = HashMap::new();
cached_versions.insert(
PackageName::new("lodash"),
PackageVersions::latest_only("4.17.21"),
);
let (targets, structural) = build_latest_check_targets(
&parse_result,
&cached_versions,
&vuln_keys,
&StubFormatter::DEFAULT,
);
assert_eq!(
targets.len(),
1,
"two same-key occurrences are checked once"
);
assert!(structural.is_empty());
}
#[test]
fn build_candidate_check_targets_dedups_duplicate_key_occurrences() {
let parse_result = duplicated_registry_dep();
let vuln_keys = vuln_keys_for(&parse_result, &StubFormatter::DEFAULT);
let mut cached_versions = HashMap::new();
let available: Arc<[ConcreteVersion]> = Arc::from(vec![
ConcreteVersion::new("2.0.0"),
ConcreteVersion::new("1.0.0"),
]);
cached_versions.insert(
PackageName::new("lodash"),
PackageVersions::new(ConcreteVersion::new("2.0.0"), available),
);
let (rounds, _) = build_candidate_check_targets(
&parse_result,
&cached_versions,
&vuln_keys,
&StubFormatter::DEFAULT,
);
assert_eq!(rounds[0].len(), 1);
assert_eq!(rounds[1].len(), 1);
}
fn dup_other_dup() -> MockParseResult {
let dep = |name: &str| MockDep {
name: PackageName::new(name),
source: DependencySource::Registry,
};
MockParseResult {
deps: vec![dep("dup"), dep("other"), dep("dup")],
}
}
#[test]
fn build_latest_check_targets_keeps_first_occurrence_order() {
let parse_result = dup_other_dup();
let vuln_keys = vuln_keys_for(&parse_result, &StubFormatter::DEFAULT);
let mut cached_versions = HashMap::new();
cached_versions.insert(
PackageName::new("dup"),
PackageVersions::latest_only("2.0.0"),
);
cached_versions.insert(
PackageName::new("other"),
PackageVersions::latest_only("3.0.0"),
);
let (targets, _) = build_latest_check_targets(
&parse_result,
&cached_versions,
&vuln_keys,
&StubFormatter::DEFAULT,
);
let keys: Vec<_> = targets.iter().map(|t| t.key.clone()).collect();
assert_eq!(
keys,
[
deps_core::test_util::vuln_key("dup"),
deps_core::test_util::vuln_key("other")
]
);
assert_eq!(targets[0].display_version, "2.0.0");
assert_eq!(targets[1].display_version, "3.0.0");
}
#[test]
fn build_candidate_check_targets_keeps_first_occurrence_order() {
let parse_result = dup_other_dup();
let vuln_keys = vuln_keys_for(&parse_result, &StubFormatter::DEFAULT);
let mut cached_versions = HashMap::new();
for (name, newest) in [("dup", "2.0.0"), ("other", "3.0.0")] {
let available: Arc<[ConcreteVersion]> =
Arc::from(vec![ConcreteVersion::new(newest)]);
cached_versions.insert(
PackageName::new(name),
PackageVersions::new(ConcreteVersion::new(newest), available),
);
}
let (rounds, _) = build_candidate_check_targets(
&parse_result,
&cached_versions,
&vuln_keys,
&StubFormatter::DEFAULT,
);
let keys: Vec<_> = rounds[0].iter().map(|t| t.key.clone()).collect();
assert_eq!(
keys,
[
deps_core::test_util::vuln_key("dup"),
deps_core::test_util::vuln_key("other")
]
);
assert_eq!(rounds[0][0].display_version, "2.0.0");
assert_eq!(rounds[0][1].display_version, "3.0.0");
assert!(rounds[1..].iter().all(Vec::is_empty));
}
#[test]
fn build_candidate_check_targets_buckets_newest_first_by_round() {
let parse_result = MockParseResult {
deps: vec![MockDep {
name: PackageName::new("pkg"),
source: DependencySource::Registry,
}],
};
let vuln_keys = vuln_keys_for(&parse_result, &StubFormatter::DEFAULT);
let mut cached_versions = HashMap::new();
let available: Arc<[ConcreteVersion]> = Arc::from(vec![
ConcreteVersion::new("3.0.0"),
ConcreteVersion::new("2.0.0"),
ConcreteVersion::new("1.0.0"),
]);
cached_versions.insert(
PackageName::new("pkg"),
PackageVersions::new(ConcreteVersion::new("3.0.0"), available),
);
let (rounds, structural) = build_candidate_check_targets(
&parse_result,
&cached_versions,
&vuln_keys,
&StubFormatter::DEFAULT,
);
assert!(structural.is_empty());
assert_eq!(
rounds[0].len(),
1,
"round 0 must hold this dependency's newest candidate"
);
assert_eq!(rounds[0][0].display_version, "3.0.0");
assert_eq!(rounds[1].len(), 1);
assert_eq!(rounds[1][0].display_version, "2.0.0");
assert_eq!(rounds[2].len(), 1);
assert_eq!(rounds[2][0].display_version, "1.0.0");
assert!(
rounds[3..].iter().all(Vec::is_empty),
"only 3 candidate versions were available, so later rounds must stay empty"
);
}
}
mod fix_target_verification_tests {
use super::*;
use deps_core::osv::{
Advisory, Capped, DependencyVulnerabilities, OsvVersion, ScanOutcome, UpgradeStatus,
VulnSeverity, VulnerabilityMap,
};
use deps_core::test_util::StubFormatter;
use std::sync::Arc;
fn advisory(id: &str, fixed_versions: &[&str]) -> Arc<Advisory> {
Arc::new(
Advisory::new(
id.to_string(),
"2023-01-01T00:00:00Z".to_string(),
VulnSeverity::High,
)
.expect("valid osv id")
.with_fixed_versions(
fixed_versions
.iter()
.copied()
.map(OsvVersion::new)
.collect(),
),
)
}
fn dv(advisories: Vec<Arc<Advisory>>) -> DependencyVulnerabilities {
let total = advisories.len();
DependencyVulnerabilities::new(Capped::new(advisories, total))
}
fn latest_status_map(status: UpgradeStatus) -> deps_core::osv::LatestStatusMap {
let mut map = deps_core::osv::LatestStatusMap::new();
map.insert(deps_core::test_util::vuln_key("pkg"), status);
map
}
#[test]
fn resolve_fix_target_skips_when_no_fix_is_recommended() {
let dv = dv(vec![advisory("A1", &[])]);
let resolution = resolve_fix_target(
&dv,
&deps_core::test_util::vuln_key("pkg"),
&HashMap::new(),
&HashMap::new(),
&StubFormatter::DEFAULT,
);
assert_eq!(resolution, FixTargetResolution::Skip);
}
#[test]
fn resolve_fix_target_reuses_latest_when_f_equals_latest() {
let latest_status = UpgradeStatus::CandidateClean {
version: ConcreteVersion::new("1.2.0"),
};
let dv = dv(vec![advisory("A1", &["1.2.0"])]);
let latest_status_map = latest_status_map(latest_status.clone());
let resolution = resolve_fix_target(
&dv,
&deps_core::test_util::vuln_key("pkg"),
&latest_status_map,
&HashMap::new(),
&StubFormatter::DEFAULT,
);
assert_eq!(resolution, FixTargetResolution::Resolved(latest_status));
}
#[test]
fn resolve_fix_target_always_needs_live_check_when_f_differs_from_latest() {
let dv = dv(vec![advisory("A1", &["1.2.0"])]);
let latest_status_map = latest_status_map(UpgradeStatus::CandidateClean {
version: ConcreteVersion::new("3.0.0"),
});
let mut osv_name_by_key = HashMap::new();
osv_name_by_key.insert(
deps_core::test_util::vuln_key("pkg"),
deps_core::osv::OsvQueryName::Confirmed(
deps_core::osv::OsvPackageName::new("pkg").unwrap(),
),
);
let resolution = resolve_fix_target(
&dv,
&deps_core::test_util::vuln_key("pkg"),
&latest_status_map,
&osv_name_by_key,
&StubFormatter::DEFAULT,
);
assert_eq!(
resolution,
FixTargetResolution::NeedsLiveCheck(deps_core::osv::ScanTarget::new(
deps_core::test_util::vuln_key("pkg"),
deps_core::osv::OsvQueryName::Confirmed(
deps_core::osv::OsvPackageName::new("pkg").unwrap()
),
OsvVersion::new("1.2.0"),
ConcreteVersion::new("1.2.0"),
))
);
}
#[test]
fn resolve_fix_target_never_reuses_a_candidate_unverified_latest() {
let dv = dv(vec![advisory("A1", &["1.2.0"])]);
let latest_status_map = latest_status_map(UpgradeStatus::CandidateUnverified {
version: ConcreteVersion::new("1.2.0"),
reason: deps_core::osv::SkipReason::QueryFailed,
});
let mut osv_name_by_key = HashMap::new();
osv_name_by_key.insert(
deps_core::test_util::vuln_key("pkg"),
deps_core::osv::OsvQueryName::Confirmed(
deps_core::osv::OsvPackageName::new("pkg").unwrap(),
),
);
let resolution = resolve_fix_target(
&dv,
&deps_core::test_util::vuln_key("pkg"),
&latest_status_map,
&osv_name_by_key,
&StubFormatter::DEFAULT,
);
assert_eq!(
resolution,
FixTargetResolution::NeedsLiveCheck(deps_core::osv::ScanTarget::new(
deps_core::test_util::vuln_key("pkg"),
deps_core::osv::OsvQueryName::Confirmed(
deps_core::osv::OsvPackageName::new("pkg").unwrap()
),
OsvVersion::new("1.2.0"),
ConcreteVersion::new("1.2.0"),
))
);
}
#[test]
fn resolve_fix_target_skips_provisional_name_and_queues_no_live_check() {
let dv = dv(vec![advisory("A1", &["1.2.0"])]);
let key = deps_core::test_util::vuln_key("pkg");
let latest_status_map = latest_status_map(UpgradeStatus::CandidateClean {
version: ConcreteVersion::new("3.0.0"),
});
let mut osv_name_by_key = HashMap::new();
osv_name_by_key.insert(
key.clone(),
deps_core::osv::OsvQueryName::Provisional(
deps_core::osv::OsvPackageName::new("pkg").unwrap(),
),
);
assert_eq!(
resolve_fix_target(
&dv,
&key,
&latest_status_map,
&osv_name_by_key,
&StubFormatter::DEFAULT,
),
FixTargetResolution::Skip
);
let mut vulnerabilities = VulnerabilityMap::new();
vulnerabilities.insert(key.clone(), ScanOutcome::Vulnerable(dv));
let (resolved, live_check_candidates) = collect_fix_target_resolutions(
&vulnerabilities,
&[key],
&osv_name_by_key,
&latest_status_map,
&StubFormatter::DEFAULT,
);
assert!(resolved.is_empty(), "{resolved:?}");
assert!(
live_check_candidates.is_empty(),
"{live_check_candidates:?}"
);
}
#[test]
fn resolve_fix_target_skips_when_osv_name_is_unavailable() {
let dv = dv(vec![advisory("A1", &["1.0.0"])]);
let resolution = resolve_fix_target(
&dv,
&deps_core::test_util::vuln_key("pkg"),
&HashMap::new(),
&HashMap::new(),
&StubFormatter::DEFAULT,
);
assert_eq!(resolution, FixTargetResolution::Skip);
}
#[test]
fn resolve_fix_target_skips_when_f_is_not_a_safe_version_string() {
let dv = dv(vec![advisory("A1", &["1.2.0\", \"evil\": \"true"])]);
let resolution = resolve_fix_target(
&dv,
&deps_core::test_util::vuln_key("pkg"),
&HashMap::new(),
&HashMap::new(),
&StubFormatter::DEFAULT,
);
assert_eq!(resolution, FixTargetResolution::Skip);
}
#[test]
fn collect_fix_target_resolutions_batches_multiple_dependencies_needing_live_check_into_one_vec()
{
let mut vulnerabilities = VulnerabilityMap::new();
vulnerabilities.insert(
deps_core::test_util::vuln_key("reused"),
ScanOutcome::Vulnerable(dv(vec![advisory("A1", &["1.0.0"])])),
);
vulnerabilities.insert(
deps_core::test_util::vuln_key("live-a"),
ScanOutcome::Vulnerable(dv(vec![advisory("A2", &["1.2.0"])])),
);
vulnerabilities.insert(
deps_core::test_util::vuln_key("live-b"),
ScanOutcome::Vulnerable(dv(vec![advisory("A3", &["2.2.0"])])),
);
let vulnerable_keys = vec![
deps_core::test_util::vuln_key("reused"),
deps_core::test_util::vuln_key("live-a"),
deps_core::test_util::vuln_key("live-b"),
];
let mut latest_status: deps_core::osv::LatestStatusMap = HashMap::new();
latest_status.insert(
deps_core::test_util::vuln_key("reused"),
UpgradeStatus::CandidateClean {
version: ConcreteVersion::new("1.0.0"),
},
);
latest_status.insert(
deps_core::test_util::vuln_key("live-a"),
UpgradeStatus::CandidateClean {
version: ConcreteVersion::new("9.0.0"),
},
);
latest_status.insert(
deps_core::test_util::vuln_key("live-b"),
UpgradeStatus::CandidateClean {
version: ConcreteVersion::new("9.0.0"),
},
);
let mut osv_name_by_key = HashMap::new();
osv_name_by_key.insert(
deps_core::test_util::vuln_key("reused"),
deps_core::osv::OsvQueryName::Confirmed(
deps_core::osv::OsvPackageName::new("reused").unwrap(),
),
);
osv_name_by_key.insert(
deps_core::test_util::vuln_key("live-a"),
deps_core::osv::OsvQueryName::Confirmed(
deps_core::osv::OsvPackageName::new("live-a").unwrap(),
),
);
osv_name_by_key.insert(
deps_core::test_util::vuln_key("live-b"),
deps_core::osv::OsvQueryName::Confirmed(
deps_core::osv::OsvPackageName::new("live-b").unwrap(),
),
);
let (resolved, live_check_candidates) = collect_fix_target_resolutions(
&vulnerabilities,
&vulnerable_keys,
&osv_name_by_key,
&latest_status,
&StubFormatter::DEFAULT,
);
assert_eq!(resolved.len(), 1, "{resolved:?}");
assert_eq!(resolved[0].0.as_str(), "reused");
assert_eq!(live_check_candidates.len(), 2, "{live_check_candidates:?}");
let keys: std::collections::HashSet<&str> = live_check_candidates
.iter()
.map(|t| t.key.as_str())
.collect();
assert!(keys.contains("live-a"));
assert!(keys.contains("live-b"));
}
#[test]
fn apply_live_fix_target_statuses_sets_only_matching_keys_leaving_others_untouched() {
let mut vulnerabilities = VulnerabilityMap::new();
vulnerabilities.insert(
deps_core::test_util::vuln_key("checked"),
ScanOutcome::Vulnerable(dv(vec![advisory("A1", &["1.0.0"])])),
);
vulnerabilities.insert(
deps_core::test_util::vuln_key("timed-out"),
ScanOutcome::Vulnerable(dv(vec![advisory("A2", &["1.0.0"])])),
);
let mut statuses = HashMap::new();
statuses.insert(
deps_core::test_util::vuln_key("checked"),
UpgradeStatus::CandidateClean {
version: ConcreteVersion::new("1.0.0"),
},
);
apply_live_fix_target_statuses(&mut vulnerabilities, statuses);
let ScanOutcome::Vulnerable(checked) = vulnerabilities
.get(&deps_core::test_util::vuln_key("checked"))
.unwrap()
else {
panic!("expected Vulnerable");
};
assert_eq!(
checked.fix_target_status,
UpgradeStatus::CandidateClean {
version: ConcreteVersion::new("1.0.0")
}
);
let ScanOutcome::Vulnerable(timed_out) = vulnerabilities
.get(&deps_core::test_util::vuln_key("timed-out"))
.unwrap()
else {
panic!("expected Vulnerable");
};
assert_eq!(timed_out.fix_target_status, UpgradeStatus::NotChecked);
}
}
}