use std::collections::{HashMap, HashSet};
use std::sync::Arc;
use futures::stream::{self, StreamExt};
use crate::deps_dev::deps_dev_system;
use crate::diagnostic::{CodeDescription, Diagnostic, RelatedInformation, Severity};
use crate::licenses::{
ViolationReason, evaluate as evaluate_license_policy, resolve_license_entries,
};
use crate::osv::{ScanOutcome, SkipReason, UpgradeStatus, diagnostic_severity_for};
use crate::position::{Position, Range};
use crate::redact::{RedactedUrl, redact_declaration_key, sanitize_invisible};
use crate::{
BlockedRegistryOccurrence, ConcreteVersion, Dependency, Deprecation, DepsDevClient,
EcosystemId, FetchCompleteness, FetchFailure, PackageName, ParseResult, PublishTime,
RegistryOccurrence, RejectedRegistryOccurrence, RemovalStatus, TyposquatSignal, VersionReq,
format_relative_age,
};
use super::{
CooldownBlocker, CooldownDisposition, EcosystemFormatter, LatestVerdict, PackageVersions,
RequirementGate, RequirementMatcher, RequirementStatus, VersionData, cooldown_disposition,
resolve_scan_outcome, version_range_is_synthetic_empty,
};
pub const UNSATISFIABLE_DIAGNOSTIC_CODE: &str = "unsatisfiable-requirement";
pub const LICENSE_POLICY_VIOLATION_DIAGNOSTIC_CODE: &str = "license-policy-violation";
pub const TYPOSQUAT_DIAGNOSTIC_CODE: &str = "typosquat-suspect";
pub const MAX_DIAGNOSTIC_VALUE_CHARS: usize = 128;
const MAX_BLOCKED_REGISTRY_RELATED_INFO: usize = 9;
pub(crate) const MAX_DIAGNOSTIC_PROSE_CHARS: usize = MAX_DIAGNOSTIC_VALUE_CHARS;
pub fn truncate_for_diagnostic(value: &str, max_chars: usize) -> std::borrow::Cow<'_, str> {
if value.chars().count() <= max_chars {
return std::borrow::Cow::Borrowed(value);
}
let mut truncated: String = value.chars().take(max_chars).collect();
truncated.push('…');
std::borrow::Cow::Owned(truncated)
}
pub(crate) const MAX_DIAGNOSTIC_NAME_CHARS: usize = MAX_DIAGNOSTIC_VALUE_CHARS;
pub(crate) const MAX_VERSION_DIAGNOSTIC_CHARS: usize = MAX_DIAGNOSTIC_VALUE_CHARS;
#[must_use]
pub fn redact_name_for_diagnostic(name: &PackageName) -> String {
sanitize_and_truncate_for_diagnostic(
&redact_declaration_key(name.as_str()),
MAX_DIAGNOSTIC_NAME_CHARS,
)
}
#[must_use]
pub fn redact_requirement_for_diagnostic(req: &VersionReq) -> String {
sanitize_and_truncate_for_diagnostic(
&redact_declaration_key(req.as_str()),
MAX_VERSION_DIAGNOSTIC_CHARS,
)
}
#[must_use]
pub fn sanitize_and_truncate_for_diagnostic(value: &str, max_chars: usize) -> String {
truncate_for_diagnostic(&sanitize_invisible(value), max_chars).into_owned()
}
#[must_use]
pub fn sanitize_advisory_text_for_diagnostic(value: &str, max_chars: usize) -> String {
truncate_for_diagnostic(&super::replace_markdown_unsafe_chars(value), max_chars).into_owned()
}
const MAX_SIBLING_TAGS_RENDERED: usize = 3;
pub(crate) fn format_matched_tags(tags: &crate::osv::MatchedTags) -> String {
let sanitized: Vec<String> = tags
.iter()
.map(|tag| sanitize_and_truncate_for_diagnostic(tag.as_str(), MAX_VERSION_DIAGNOSTIC_CHARS))
.collect();
crate::licenses::join_capped(&sanitized, MAX_SIBLING_TAGS_RENDERED)
}
pub const DEPRECATED_DIAGNOSTIC_CODE: &str = "deprecated-package";
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DiagnosticSeverities {
pub outdated: Severity,
pub unknown: Severity,
pub yanked: Severity,
pub unsatisfiable: Severity,
pub deprecated: Severity,
pub mutable_ref_pin: Severity,
pub mutable_ref_pin_enabled: bool,
pub sha_comment_mismatch: Severity,
pub vulnerabilities_enabled: bool,
}
impl Default for DiagnosticSeverities {
fn default() -> Self {
Self::new()
}
}
impl DiagnosticSeverities {
#[must_use]
pub const fn new() -> Self {
Self {
outdated: Severity::Hint,
unknown: Severity::Warning,
yanked: Severity::Warning,
unsatisfiable: Severity::Warning,
deprecated: Severity::Warning,
mutable_ref_pin: Severity::Hint,
mutable_ref_pin_enabled: true,
sha_comment_mismatch: Severity::Warning,
vulnerabilities_enabled: true,
}
}
#[must_use]
pub const fn with_outdated(mut self, outdated: Severity) -> Self {
self.outdated = outdated;
self
}
#[must_use]
pub const fn with_unknown(mut self, unknown: Severity) -> Self {
self.unknown = unknown;
self
}
#[must_use]
pub const fn with_yanked(mut self, yanked: Severity) -> Self {
self.yanked = yanked;
self
}
#[must_use]
pub const fn with_unsatisfiable(mut self, unsatisfiable: Severity) -> Self {
self.unsatisfiable = unsatisfiable;
self
}
#[must_use]
pub const fn with_deprecated(mut self, deprecated: Severity) -> Self {
self.deprecated = deprecated;
self
}
#[must_use]
pub const fn with_mutable_ref_pin(mut self, mutable_ref_pin: Severity) -> Self {
self.mutable_ref_pin = mutable_ref_pin;
self
}
#[must_use]
pub const fn with_mutable_ref_pin_enabled(mut self, mutable_ref_pin_enabled: bool) -> Self {
self.mutable_ref_pin_enabled = mutable_ref_pin_enabled;
self
}
#[must_use]
pub const fn with_sha_comment_mismatch(mut self, sha_comment_mismatch: Severity) -> Self {
self.sha_comment_mismatch = sha_comment_mismatch;
self
}
#[must_use]
pub const fn with_vulnerabilities_enabled(mut self, vulnerabilities_enabled: bool) -> Self {
self.vulnerabilities_enabled = vulnerabilities_enabled;
self
}
}
pub fn compile_requirement_unless<M>(
requirement: &str,
is_undecidable: impl FnOnce(&str) -> bool,
matcher: impl FnOnce(String) -> M,
) -> Option<Box<dyn RequirementMatcher>>
where
M: RequirementMatcher + 'static,
{
if is_undecidable(requirement) {
return None;
}
Some(Box::new(matcher(requirement.to_string())))
}
pub const MAX_REQUIREMENT_LEN: usize = 256;
pub fn requirement_len_exceeds_cap(requirement: &str) -> bool {
requirement.len() > MAX_REQUIREMENT_LEN
}
#[must_use]
pub fn requirement_is_oversized(requirement: &VersionReq) -> bool {
requirement_len_exceeds_cap(requirement.as_str())
}
#[derive(Clone, Copy, Debug)]
pub struct BoundedVersionReq<'a>(&'a VersionReq);
impl<'a> BoundedVersionReq<'a> {
#[must_use]
pub fn new(requirement: &'a VersionReq) -> Option<Self> {
(!requirement_is_oversized(requirement)).then_some(Self(requirement))
}
#[must_use]
pub const fn get(self) -> &'a VersionReq {
self.0
}
#[must_use]
pub fn as_str(self) -> &'a str {
self.get().as_str()
}
}
pub fn requirement_is_unsatisfiable(
formatter: &dyn EcosystemFormatter,
requirement: &VersionReq,
available: &[ConcreteVersion],
) -> bool {
unsatisfiable_matcher(formatter, requirement, available).is_some()
}
fn unsatisfiable_matcher(
formatter: &dyn EcosystemFormatter,
requirement: &VersionReq,
available: &[ConcreteVersion],
) -> Option<Box<dyn RequirementMatcher>> {
if available.is_empty() || requirement.as_str().trim().is_empty() {
return None;
}
let bounded = BoundedVersionReq::new(requirement)?;
if formatter.bounded_requirement_is_unresolved(bounded)
|| formatter.bounded_requirement_is_placeholder(bounded)
{
return None;
}
if formatter.bounded_requirement_is_undecidable_given_available(bounded, available) {
return None;
}
let matcher = formatter.compile_bounded_requirement(bounded)?;
let mut saw_decided_false = false;
for candidate in available {
match matcher.matches(candidate) {
Some(true) => return None,
Some(false) => saw_decided_false = true,
None => {}
}
}
saw_decided_false.then_some(matcher)
}
#[expect(
clippy::string_slice,
reason = "dash comes from str::find('-'), an ASCII byte, so it is always a char boundary"
)]
fn semver_prerelease_base(version: &str) -> Option<&str> {
let core = version.split('+').next().unwrap_or(version);
core.find('-').map(|dash| &core[..dash])
}
#[expect(
clippy::indexing_slicing,
reason = "i > 0 and i + 1 < bytes.len() guard bytes[i - 1]/bytes[i + 1] respectively"
)]
fn requirement_names_prerelease(requirement: &str) -> bool {
let bytes = requirement.as_bytes();
bytes.iter().enumerate().any(|(i, &b)| {
b == b'-'
&& i > 0
&& i + 1 < bytes.len()
&& !bytes[i - 1].is_ascii_whitespace()
&& !bytes[i + 1].is_ascii_whitespace()
})
}
fn matching_prerelease_would_satisfy(
matcher: &dyn RequirementMatcher,
requirement: &VersionReq,
available: &[ConcreteVersion],
yanked: &[(ConcreteVersion, RemovalStatus)],
) -> Option<String> {
if requirement_names_prerelease(requirement.as_str()) {
return None;
}
if !matcher.strict_prerelease_exclusion() {
return None;
}
available.iter().find_map(|candidate| {
let base = semver_prerelease_base(candidate.as_str())?;
(!yanked.iter().any(|(y, _)| y == candidate)
&& matcher.matches(&ConcreteVersion::new(base)) == Some(true))
.then(|| candidate.to_string())
})
}
fn status_for_version(
yanked: &[(ConcreteVersion, RemovalStatus)],
candidate: &ConcreteVersion,
) -> Option<RemovalStatus> {
let mut found: Option<RemovalStatus> = None;
for (y, status) in yanked {
if y == candidate {
if *status == RemovalStatus::Yanked {
return Some(RemovalStatus::Yanked);
}
found = Some(*status);
}
}
found
}
fn requirement_matches_only_yanked(
formatter: &dyn EcosystemFormatter,
requirement: &VersionReq,
available: &[ConcreteVersion],
yanked: &[(ConcreteVersion, RemovalStatus)],
) -> Option<RemovalStatus> {
if available.is_empty() || yanked.is_empty() || requirement.as_str().trim().is_empty() {
return None;
}
let bounded = BoundedVersionReq::new(requirement)?;
if formatter.bounded_requirement_is_unresolved(bounded) {
return None;
}
let matcher = formatter.compile_bounded_requirement(bounded)?;
let mut saw_match = false;
let mut saw_undecided = false;
let mut aggregate: Option<RemovalStatus> = None;
for candidate in available {
match matcher.matches(candidate) {
Some(true) => {
saw_match = true;
let status = status_for_version(yanked, candidate)?;
if aggregate != Some(RemovalStatus::Yanked) {
aggregate = Some(status);
}
}
Some(false) => {}
None => saw_undecided = true,
}
}
if saw_match && !saw_undecided {
aggregate
} else {
None
}
}
pub fn generate_diagnostics_from_cache(
parse_result: &dyn ParseResult,
versions: VersionData<'_>,
formatter: &dyn EcosystemFormatter,
uri: &url::Url,
freshness: crate::freshness::FreshnessSettings,
severities: DiagnosticSeverities,
now: PublishTime,
) -> Vec<Diagnostic> {
let deps = parse_result.dependencies();
let mut diagnostics = Vec::with_capacity(deps.len());
let mut fetch_failed: Vec<FetchFailureEntry> = Vec::new();
let vuln_keys = versions.ecosystem.map(|ecosystem| {
crate::osv::vulnerability_keys(
parse_result,
versions.resolved,
versions.resolved_version_candidates,
formatter,
ecosystem,
)
});
let normalized_names: HashMap<Range, String> = deps
.iter()
.filter(|dep| !dep.name_range_is_synthetic())
.map(|dep| {
(
dep.name_range(),
formatter.normalize_package_name(dep.name()),
)
})
.collect();
dependency_ceiling_notice(&mut diagnostics, parse_result);
invalid_minimum_stability_notice(&mut diagnostics, parse_result);
offline_notice(&mut diagnostics, versions, &deps);
skip_reason_notice(
&mut diagnostics,
&deps,
versions,
&normalized_names,
uri,
vuln_keys.as_ref(),
);
blocked_and_rejected_registry_diagnostics(&mut diagnostics, parse_result, &deps);
for dep in deps {
if dep.name_range_is_synthetic() {
continue;
}
let normalized_name = normalized_names
.get(&dep.name_range())
.map_or("", String::as_str);
let ctx = RuleContext {
dep,
normalized_name,
versions,
formatter,
severities,
freshness,
now,
};
let vulnerable_in_use =
apply_vulnerability_rule(&mut diagnostics, &ctx, vuln_keys.as_ref());
apply_license_policy_rule(&mut diagnostics, &ctx);
apply_typosquat_rule(&mut diagnostics, &ctx);
let deprecation_found = apply_deprecation_rule(&mut diagnostics, &ctx);
let in_use_yanked_emitted =
apply_in_use_yanked_rule(&mut diagnostics, &ctx, deprecation_found);
let Some(package_versions) = ctx.cached_versions() else {
apply_unknown_package_rule(&mut diagnostics, &mut fetch_failed, &ctx);
continue;
};
let Some(version_range) = dep.version_range() else {
continue;
};
let version_range: Range = version_range;
let resolved = ResolvedData {
package_versions,
version_range,
};
if apply_unsatisfiable_rule(&mut diagnostics, &ctx, &resolved) == RuleFlow::Stop {
continue;
}
if apply_yanked_only_rule(
&mut diagnostics,
&ctx,
&resolved,
YankedOnlyPrior {
deprecation_found,
in_use_yanked_emitted,
},
) == RuleFlow::Stop
{
continue;
}
apply_outdated_rule(
&mut diagnostics,
&ctx,
&resolved,
vuln_keys.as_ref(),
vulnerable_in_use,
);
}
push_collapsed_fetch_failures(&mut diagnostics, fetch_failed, uri);
diagnostics
}
struct RuleContext<'a> {
dep: &'a dyn Dependency,
normalized_name: &'a str,
versions: VersionData<'a>,
formatter: &'a dyn EcosystemFormatter,
severities: DiagnosticSeverities,
freshness: crate::freshness::FreshnessSettings,
now: PublishTime,
}
impl<'a> RuleContext<'a> {
fn cached_versions(&self) -> Option<&'a PackageVersions> {
self.versions
.cached
.get(self.normalized_name)
.or_else(|| self.versions.cached.get(self.dep.name()))
}
}
struct ResolvedData<'a> {
package_versions: &'a PackageVersions,
version_range: Range,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum RuleFlow {
Continue,
Stop,
}
struct FetchFailureEntry {
name: String,
diagnostic: Diagnostic,
failure: Option<FetchFailure>,
}
struct YankedOnlyPrior {
deprecation_found: bool,
in_use_yanked_emitted: bool,
}
fn dependency_ceiling_notice(diagnostics: &mut Vec<Diagnostic>, parse_result: &dyn ParseResult) {
if let Some((kept, total)) = parse_result.dependency_truncation() {
diagnostics.push(
Diagnostic::new(
Range {
start: Position::new(0, 0),
end: Position::new(0, 0),
},
format!(
"manifest declares {total} dependencies, exceeding deps-lsp's per-document \
limit of {kept}; only the first {kept} are tracked, fetched, and checked \
against the registry"
),
)
.with_severity(Severity::Information),
);
}
}
fn invalid_minimum_stability_notice(
diagnostics: &mut Vec<Diagnostic>,
parse_result: &dyn ParseResult,
) {
if let Some(occurrence) = parse_result.invalid_minimum_stability() {
let raw = sanitize_and_truncate_for_diagnostic(&occurrence.raw, MAX_DIAGNOSTIC_VALUE_CHARS);
diagnostics.push(
Diagnostic::new(
occurrence.range,
format!(
"minimum-stability \"{raw}\" is not one of dev, alpha, beta, RC, stable; \
\"stable\" is assumed when choosing the latest version"
),
)
.with_severity(Severity::Warning),
);
}
}
fn offline_notice(
diagnostics: &mut Vec<Diagnostic>,
versions: VersionData<'_>,
deps: &[&dyn Dependency],
) {
if versions.network.is_offline() && !deps.is_empty() {
diagnostics.push(
Diagnostic::new(
Range {
start: Position::new(0, 0),
end: Position::new(0, 0),
},
"deps-lsp is offline (network.offline): dependency and vulnerability data \
reflects only what was already cached, not the current registry state",
)
.with_severity(Severity::Information),
);
}
}
const fn should_notify_in_diagnostics(reason: SkipReason) -> bool {
match reason {
SkipReason::NoConcreteVersion
| SkipReason::ResolvedTagNotFullVersion
| SkipReason::CanonicalNameUnconfirmed
| SkipReason::QueryFailed
| SkipReason::Truncated
| SkipReason::UnevaluableAdvisoryRange => true,
SkipReason::NonRegistrySource
| SkipReason::UnmappableName
| SkipReason::UnmappableEcosystem
| SkipReason::UnmatchableVersion => false,
}
}
fn skip_reason_notice(
diagnostics: &mut Vec<Diagnostic>,
deps: &[&dyn Dependency],
versions: VersionData<'_>,
normalized_names: &HashMap<Range, String>,
uri: &url::Url,
vuln_keys: Option<&crate::osv::VulnKeys>,
) {
if versions.network.is_offline() {
return;
}
let Some(vulnerabilities) = versions.vulnerabilities else {
return;
};
struct SkippedEntry {
name: String,
range: Range,
reason_text: &'static str,
}
let mut entries: Vec<SkippedEntry> = Vec::new();
for dep in deps {
if dep.name_range_is_synthetic() {
continue;
}
let normalized_name = normalized_names
.get(&dep.name_range())
.map_or("", String::as_str);
let outcome = resolve_scan_outcome(vulnerabilities, *dep, vuln_keys, normalized_name);
if let Some(ScanOutcome::Skipped(reason)) = outcome
&& should_notify_in_diagnostics(*reason)
&& let Some(reason_text) = reason.unchecked_reason()
{
entries.push(SkippedEntry {
name: redact_name_for_diagnostic(dep.name()),
range: version_anchor_range(*dep),
reason_text,
});
}
}
if entries.is_empty() {
return;
}
let count = entries.len();
let dependency_word = if count == 1 {
"dependency"
} else {
"dependencies"
};
let mut distinct_reasons: Vec<&'static str> =
entries.iter().map(|entry| entry.reason_text).collect();
distinct_reasons.sort_unstable();
distinct_reasons.dedup();
let joined_reasons = distinct_reasons.join("; ");
let shown = entries.len().min(MAX_BLOCKED_REGISTRY_RELATED_INFO);
#[expect(
clippy::indexing_slicing,
reason = "shown is entries.len().min(...), so entries[..shown] is always in bounds"
)]
let mut related_information: Vec<RelatedInformation> = entries[..shown]
.iter()
.map(|entry| {
RelatedInformation::new(
uri.clone(),
entry.range,
format!("'{}': {}", entry.name, entry.reason_text),
)
})
.collect();
let remaining = entries.len() - shown;
if remaining > 0 {
related_information.push(RelatedInformation::new(
uri.clone(),
Range {
start: Position::new(0, 0),
end: Position::new(0, 0),
},
format!("and {remaining} more dependencies not checked for vulnerabilities"),
));
}
diagnostics.push(
Diagnostic::new(
Range {
start: Position::new(0, 0),
end: Position::new(0, 0),
},
format!(
"vulnerability data was not checked for {count} {dependency_word}: \
{joined_reasons}"
),
)
.with_severity(Severity::Information)
.with_related_information(related_information),
);
}
fn blocked_and_rejected_registry_diagnostics(
diagnostics: &mut Vec<Diagnostic>,
parse_result: &dyn ParseResult,
deps: &[&dyn Dependency],
) {
let blocked = parse_result.blocked_registries();
let rejected = parse_result.rejected_registries();
if blocked.is_empty() && rejected.is_empty() {
return;
}
let dependency_names: HashMap<Range, String> = deps
.iter()
.map(|dep| (dep.name_range(), redact_name_for_diagnostic(dep.name())))
.collect();
let uri = parse_result.uri();
if !blocked.is_empty() {
declaration_grouped_registry_diagnostics(
diagnostics,
blocked,
uri,
&dependency_names,
build_blocked_registry_diagnostic,
|name| match name {
Some(name) => format!("'{name}' also blocked by the same registry policy"),
None => "also blocked by the same registry policy".to_string(),
},
|remaining| {
format!(
"and {remaining} more dependencies also blocked by the same registry policy"
)
},
);
}
if !rejected.is_empty() {
declaration_grouped_registry_diagnostics(
diagnostics,
rejected,
uri,
&dependency_names,
build_rejected_registry_diagnostic,
|name| match name {
Some(name) => format!("'{name}' also rejected by the same registry entry"),
None => "also rejected by the same registry entry".to_string(),
},
|remaining| {
format!(
"and {remaining} more dependencies also rejected by the same registry entry"
)
},
);
}
}
fn declaration_grouped_registry_diagnostics<O: RegistryOccurrence>(
diagnostics: &mut Vec<Diagnostic>,
occurrences: Vec<O>,
uri: &url::Url,
dependency_names: &HashMap<Range, String>,
build_diagnostic: impl Fn(&O) -> Diagnostic,
sibling_message: impl Fn(Option<&str>) -> String,
remaining_message: impl Fn(usize) -> String,
) {
let mut order: Vec<String> = Vec::new();
let mut groups: HashMap<String, Vec<O>> = HashMap::new();
for occurrence in occurrences {
let key = occurrence.declaration_key().to_string();
groups
.entry(key.clone())
.or_insert_with(|| {
order.push(key.clone());
Vec::new()
})
.push(occurrence);
}
for key in order {
if let Some(entries) = groups.remove(&key) {
push_collapsed_registry_diagnostics(
diagnostics,
entries,
uri,
dependency_names,
&build_diagnostic,
&sibling_message,
&remaining_message,
);
}
}
}
fn build_blocked_registry_diagnostic(occurrence: &BlockedRegistryOccurrence) -> Diagnostic {
let redacted_value = RedactedUrl::new(&occurrence.raw_value).into_inner();
let redacted_key = redact_declaration_key(&occurrence.declaration_key);
Diagnostic::new(
occurrence.range,
format!(
"registry index \"{}\" blocked by registries.workspace_registries policy \
(host class: {}; declaration: {})",
sanitize_and_truncate_for_diagnostic(&redacted_value, MAX_DIAGNOSTIC_VALUE_CHARS),
occurrence.class,
sanitize_and_truncate_for_diagnostic(&redacted_key, MAX_DIAGNOSTIC_VALUE_CHARS),
),
)
.with_severity(Severity::Information)
}
fn build_rejected_registry_diagnostic(occurrence: &RejectedRegistryOccurrence) -> Diagnostic {
let redacted_value = RedactedUrl::new(&occurrence.raw_value).into_inner();
let redacted_key = redact_declaration_key(&occurrence.declaration_key);
Diagnostic::new(
occurrence.range,
format!(
"registry entry \"{}\" rejected: {} (declaration: {})",
sanitize_and_truncate_for_diagnostic(&redacted_value, MAX_DIAGNOSTIC_VALUE_CHARS),
occurrence.reason,
sanitize_and_truncate_for_diagnostic(&redacted_key, MAX_DIAGNOSTIC_VALUE_CHARS),
),
)
.with_severity(Severity::Warning)
}
fn push_collapsed_registry_diagnostics<O: RegistryOccurrence>(
diagnostics: &mut Vec<Diagnostic>,
entries: Vec<O>,
uri: &url::Url,
dependency_names: &HashMap<Range, String>,
build_diagnostic: &impl Fn(&O) -> Diagnostic,
sibling_message: &impl Fn(Option<&str>) -> String,
remaining_message: &impl Fn(usize) -> String,
) {
#[expect(
clippy::indexing_slicing,
reason = "the 0/1 arms are matched separately, so this arm only runs with len() >= 2, \
making both entries[0] and the entries[1..] slice below valid"
)]
match entries.len() {
0 => {}
1 => diagnostics.extend(entries.iter().map(build_diagnostic)),
_ => {
let diagnostic = build_diagnostic(&entries[0]);
let siblings = &entries[1..];
let shown = siblings.len().min(MAX_BLOCKED_REGISTRY_RELATED_INFO);
#[expect(
clippy::indexing_slicing,
reason = "shown is siblings.len().min(...), so siblings[..shown] is always \
in bounds"
)]
let mut related_information: Vec<RelatedInformation> = siblings[..shown]
.iter()
.map(|occurrence| {
let range = occurrence.range();
let name = dependency_names.get(&range).map(String::as_str);
RelatedInformation::new(uri.clone(), range, sibling_message(name))
})
.collect();
let remaining = siblings.len() - shown;
if remaining > 0 {
related_information.push(RelatedInformation::new(
uri.clone(),
diagnostic.range,
remaining_message(remaining),
));
}
diagnostics.push(diagnostic.with_related_information(related_information));
}
}
}
fn apply_vulnerability_rule<'a>(
diagnostics: &mut Vec<Diagnostic>,
ctx: &RuleContext<'a>,
vuln_keys: Option<&crate::osv::VulnKeys>,
) -> Option<&'a crate::osv::DependencyVulnerabilities> {
let vulnerabilities = ctx.versions.vulnerabilities?;
let Some(ScanOutcome::Vulnerable(dv)) =
resolve_scan_outcome(vulnerabilities, ctx.dep, vuln_keys, ctx.normalized_name)
else {
return None;
};
push_vulnerability_diagnostics(diagnostics, ctx.dep, dv, ctx.formatter);
Some(dv)
}
fn apply_license_policy_rule(diagnostics: &mut Vec<Diagnostic>, ctx: &RuleContext<'_>) {
let Some(policy) = ctx.versions.license_policy else {
return;
};
let Some(license) = ctx
.versions
.license_prefetch
.and_then(|prefetch| prefetch.get(ctx.dep.name()))
else {
return;
};
let license_source = ctx.versions.license_source.unwrap_or_default();
let (normalized_license, all_matched) = resolve_license_entries(license_source, license);
let suppress_not_allowed = !all_matched;
let Some(violation) = evaluate_license_policy(&normalized_license, policy) else {
return;
};
if suppress_not_allowed && violation.reason == ViolationReason::NotAllowed {
return;
}
let severity = match violation.reason {
ViolationReason::Denied => Severity::Error,
ViolationReason::NotAllowed => Severity::Warning,
};
diagnostics.push(
Diagnostic::new(
ctx.dep.name_range(),
format!(
"{}: {} {}",
redact_name_for_diagnostic(ctx.dep.name()),
sanitize_and_truncate_for_diagnostic(
&violation.license,
MAX_DIAGNOSTIC_VALUE_CHARS
),
violation.reason
),
)
.with_severity(severity)
.with_code(LICENSE_POLICY_VIOLATION_DIAGNOSTIC_CODE),
);
}
fn apply_typosquat_rule(diagnostics: &mut Vec<Diagnostic>, ctx: &RuleContext<'_>) {
let Some(signal) = ctx
.versions
.typosquat_prefetch
.and_then(|signals| signals.get(ctx.dep.name()))
else {
return;
};
if !ctx
.formatter
.source_is_public_registry_content(&ctx.dep.source())
{
return;
}
diagnostics.push(
Diagnostic::new(
ctx.dep.name_range(),
format!(
"{} has far fewer dependents ({}) than the similarly named {} ({}) — double-\
check this isn't a typo or a typosquat",
redact_name_for_diagnostic(ctx.dep.name()),
signal.declared_dependent_count,
redact_name_for_diagnostic(&PackageName::new(signal.suspected_name.as_str())),
signal.suspected_dependent_count,
),
)
.with_severity(Severity::Hint)
.with_code(TYPOSQUAT_DIAGNOSTIC_CODE),
);
}
const TYPOSQUAT_FETCH_CONCURRENCY: usize = 8;
#[derive(Debug, Clone, PartialEq)]
pub struct TyposquatFetchOutcome {
pub signals: HashMap<PackageName, TyposquatSignal>,
pub completeness: FetchCompleteness,
}
pub async fn fetch_typosquat_signals(
ecosystem_id: EcosystemId,
parse_result: &dyn ParseResult,
formatter: &dyn EcosystemFormatter,
network: crate::NetworkMode,
client: Option<&Arc<DepsDevClient>>,
) -> TyposquatFetchOutcome {
let complete = TyposquatFetchOutcome {
signals: HashMap::new(),
completeness: FetchCompleteness::Complete,
};
let Some(client) = client else {
return complete;
};
if network.is_offline() {
return complete;
}
let Some(system) = deps_dev_system(ecosystem_id) else {
return complete;
};
let mut seen = HashSet::new();
let names: Vec<PackageName> = parse_result
.dependencies()
.into_iter()
.filter(|dep| formatter.source_is_public_registry_content(&dep.source()))
.map(|dep| dep.name().clone())
.filter(|name| seen.insert(name.clone()))
.collect();
if names.is_empty() {
return complete;
}
let resolved = stream::iter(names)
.map(|name| {
let client = Arc::clone(client);
async move {
let (signal, completeness) = client.typosquat_signal(system, name.as_str()).await;
(name, signal, completeness)
}
})
.buffer_unordered(TYPOSQUAT_FETCH_CONCURRENCY)
.collect::<Vec<_>>()
.await;
let mut signals = HashMap::new();
let mut completeness = FetchCompleteness::Complete;
for (name, signal, name_completeness) in resolved {
completeness = completeness.combine(name_completeness);
if let Some(signal) = signal {
signals.insert(name, signal);
}
}
TyposquatFetchOutcome {
signals,
completeness,
}
}
pub async fn fetch_gossip_findings_batch(
ecosystem_id: EcosystemId,
parse_result: &dyn ParseResult,
formatter: &dyn EcosystemFormatter,
network: crate::NetworkMode,
client: Option<&Arc<DepsDevClient>>,
) -> HashMap<PackageName, crate::GossipFindings> {
let mut result = HashMap::new();
let Some(client) = client else {
return result;
};
if network.is_offline() {
return result;
}
let Some(system) = deps_dev_system(ecosystem_id) else {
return result;
};
let mut seen = HashSet::new();
let names: Vec<String> = parse_result
.dependencies()
.into_iter()
.filter(|dep| formatter.source_is_public_registry_content(&dep.source()))
.map(|dep| dep.name().as_str().to_string())
.filter(|name| seen.insert(name.clone()))
.collect();
if names.is_empty() {
return result;
}
let batch = client.gossip_findings_batch(system, &names).await;
for (name, findings) in batch {
result.insert(PackageName::new(&name), findings);
}
result
}
pub async fn force_refresh_gossip_findings(
ecosystem_id: EcosystemId,
names: &[String],
client: &Arc<DepsDevClient>,
) -> HashMap<PackageName, crate::GossipFindings> {
let mut result = HashMap::new();
let Some(system) = deps_dev_system(ecosystem_id) else {
return result;
};
let refreshed = client.force_refresh_gossip_findings(system, names).await;
for (name, findings) in refreshed {
result.insert(PackageName::new(&name), findings);
}
result
}
fn apply_deprecation_rule(diagnostics: &mut Vec<Diagnostic>, ctx: &RuleContext<'_>) -> bool {
let deprecation = ctx
.formatter
.can_resolve_source(&ctx.dep.source())
.then(|| {
ctx.versions
.outcomes
.and_then(|o| o.deprecation(ctx.normalized_name))
})
.flatten();
if let Some(dep_info) = deprecation {
push_deprecation_diagnostic(
diagnostics,
ctx.dep,
ctx.formatter,
dep_info,
ctx.severities,
);
}
deprecation.is_some()
}
fn apply_in_use_yanked_rule(
diagnostics: &mut Vec<Diagnostic>,
ctx: &RuleContext<'_>,
deprecation_found: bool,
) -> bool {
let deprecation_suppresses_yanked = deprecation_found
&& ctx
.versions
.outcomes
.and_then(|o| o.yanked(ctx.normalized_name))
.is_some_and(|(_, status)| *status != RemovalStatus::Yanked);
if deprecation_suppresses_yanked {
false
} else if let Some((yanked_version, _status)) = ctx
.versions
.outcomes
.and_then(|o| o.yanked(ctx.normalized_name))
&& ctx.versions.ecosystem.is_none_or(|ecosystem| {
super::resolve_in_use_version(
ctx.dep,
ctx.normalized_name,
ctx.versions.resolved,
ctx.versions.resolved_version_candidates,
ctx.formatter,
ecosystem,
)
.as_ref()
== Some(yanked_version)
})
{
let yanked_version = sanitize_and_truncate_for_diagnostic(
yanked_version.as_str(),
MAX_VERSION_DIAGNOSTIC_CHARS,
);
diagnostics.push(
Diagnostic::new(
version_anchor_range(ctx.dep),
format!("{} ({})", ctx.formatter.yanked_message(), yanked_version),
)
.with_severity(ctx.severities.yanked),
);
true
} else {
false
}
}
fn apply_unknown_package_rule(
diagnostics: &mut Vec<Diagnostic>,
fetch_failed: &mut Vec<FetchFailureEntry>,
ctx: &RuleContext<'_>,
) {
let dep = ctx.dep;
let in_lockfile = ctx.versions.resolved.contains_key(ctx.normalized_name)
|| ctx.versions.resolved.contains_key(dep.name());
if in_lockfile {
return;
}
let can_resolve_source = ctx.formatter.can_resolve_source(&dep.source());
let fetch_failure: Option<&FetchFailure> = can_resolve_source
.then(|| {
ctx.versions
.outcomes
.and_then(|o| o.fetch_failure(ctx.normalized_name))
})
.flatten();
let no_comparable_versions = can_resolve_source
&& ctx
.versions
.outcomes
.is_some_and(|o| o.no_comparable_versions(ctx.normalized_name));
match ctx.formatter.validate_package_name(dep.name().as_str()) {
Err(reason) => {
diagnostics.push(
Diagnostic::new(
dep.name_range(),
format!(
"Invalid package name '{}': {reason}",
redact_name_for_diagnostic(dep.name())
),
)
.with_severity(ctx.severities.unknown),
);
}
Ok(()) if fetch_failure.is_some() && ctx.versions.network.is_offline() => {}
Ok(()) if fetch_failure.is_some() => {
let redacted_name = redact_name_for_diagnostic(dep.name());
let message = match fetch_failure {
Some(FetchFailure::Actionable(hint)) => {
format!("Registry lookup failed for '{redacted_name}': {hint}")
}
Some(FetchFailure::Transient | FetchFailure::NotAttempted) | None => {
format!(
"Registry lookup failed for '{redacted_name}'; package status could not \
be determined"
)
}
};
fetch_failed.push(FetchFailureEntry {
name: redacted_name,
diagnostic: Diagnostic::new(dep.name_range(), message)
.with_severity(ctx.severities.unknown),
failure: fetch_failure.cloned(),
});
}
Ok(()) if no_comparable_versions => {}
Ok(()) if can_resolve_source => {
diagnostics.push(
Diagnostic::new(
dep.name_range(),
format!(
"Unknown package '{}'",
redact_name_for_diagnostic(dep.name())
),
)
.with_severity(ctx.severities.unknown),
);
}
Ok(()) => {}
}
}
fn apply_unsatisfiable_rule(
diagnostics: &mut Vec<Diagnostic>,
ctx: &RuleContext<'_>,
resolved: &ResolvedData<'_>,
) -> RuleFlow {
let dep = ctx.dep;
let package_versions = resolved.package_versions;
let latest = &package_versions.latest;
let version_req = dep.version_requirement();
if !ctx.formatter.can_resolve_source(&dep.source()) {
return RuleFlow::Continue;
}
let Some(matcher) = version_req.and_then(|version_req| {
unsatisfiable_matcher(ctx.formatter, version_req, &package_versions.available)
}) else {
return RuleFlow::Continue;
};
let req_str = sanitize_and_truncate_for_diagnostic(
version_req.map_or("", |r| r.as_str()),
MAX_VERSION_DIAGNOSTIC_CHARS,
);
let latest =
sanitize_and_truncate_for_diagnostic(latest.as_str(), MAX_VERSION_DIAGNOSTIC_CHARS);
let mut message =
format!("No published version satisfies requirement '{req_str}'; latest is {latest}");
if let Some(prerelease) = version_req.and_then(|version_req| {
matching_prerelease_would_satisfy(
matcher.as_ref(),
version_req,
&package_versions.available,
&package_versions.yanked,
)
}) {
use std::fmt::Write as _;
let prerelease =
sanitize_and_truncate_for_diagnostic(prerelease.as_str(), MAX_VERSION_DIAGNOSTIC_CHARS);
let _ = write!(
message,
" (a pre-release, {prerelease}, is excluded by SemVer's default \
pre-release-matching rules; require it explicitly to use it)"
);
}
diagnostics.push(
Diagnostic::new(resolved.version_range, message)
.with_severity(ctx.severities.unsatisfiable)
.with_code(UNSATISFIABLE_DIAGNOSTIC_CODE),
);
RuleFlow::Stop
}
fn apply_yanked_only_rule(
diagnostics: &mut Vec<Diagnostic>,
ctx: &RuleContext<'_>,
resolved: &ResolvedData<'_>,
prior: YankedOnlyPrior,
) -> RuleFlow {
let YankedOnlyPrior {
deprecation_found,
in_use_yanked_emitted,
} = prior;
let dep = ctx.dep;
let package_versions = resolved.package_versions;
let latest = &package_versions.latest;
let yanked_only_status = (!in_use_yanked_emitted
&& ctx.formatter.can_resolve_source(&dep.source()))
.then(|| dep.version_requirement())
.flatten()
.filter(|version_req| ctx.formatter.yanked_diagnostic_applies_to(dep, version_req))
.and_then(|version_req| {
requirement_matches_only_yanked(
ctx.formatter,
version_req,
&package_versions.available,
&package_versions.yanked,
)
});
let yanked_only = yanked_only_status
.is_some_and(|status| status != RemovalStatus::AdvisoryDeprecated || !deprecation_found);
if !yanked_only {
return RuleFlow::Continue;
}
let latest =
sanitize_and_truncate_for_diagnostic(latest.as_str(), MAX_VERSION_DIAGNOSTIC_CHARS);
diagnostics.push(
Diagnostic::new(
resolved.version_range,
format!("{}; latest is {latest}", ctx.formatter.yanked_message()),
)
.with_severity(ctx.severities.yanked),
);
RuleFlow::Stop
}
fn push_flagged_latest_admitted_by_requirement(
diagnostics: &mut Vec<Diagnostic>,
ctx: &RuleContext<'_>,
resolved: &ResolvedData<'_>,
vuln_keys: Option<&crate::osv::VulnKeys>,
latest: &str,
vulnerable_in_use: Option<&crate::osv::DependencyVulnerabilities>,
) {
let (verdict, raw_status) = match ctx.versions.latest_status {
None => (LatestVerdict::NotApplicable, None),
Some(map) => {
let raw_status =
super::resolve_latest_status(map, ctx.dep, vuln_keys, ctx.normalized_name);
(
super::upgrade_status_to_verdict(raw_status, latest, ctx.dep, ctx.formatter),
raw_status,
)
}
};
match verdict {
LatestVerdict::Flagged {
advisory_ids,
malicious,
} => {
let severity = if malicious {
Severity::Error
} else {
Severity::Warning
};
if flagged_latest_duplicates_vulnerability_finding(
ctx,
latest,
severity,
vulnerable_in_use,
) {
return;
}
let ids = if advisory_ids.is_empty() {
String::new()
} else {
format!(" ({})", advisory_ids.join(", "))
};
let latest = sanitize_and_truncate_for_diagnostic(latest, MAX_VERSION_DIAGNOSTIC_CHARS);
diagnostics.push(
Diagnostic::new(
resolved.version_range,
format!(
"This requirement already admits {latest}{ids}, which OSV.dev flags as \
vulnerable/malicious"
),
)
.with_severity(severity),
);
}
LatestVerdict::Unverified => {
push_unverified_latest_check_failed(diagnostics, resolved, raw_status, latest);
}
LatestVerdict::Verified | LatestVerdict::NotApplicable => {}
}
}
fn flagged_latest_duplicates_vulnerability_finding(
ctx: &RuleContext<'_>,
latest: &str,
would_be_severity: Severity,
vulnerable_in_use: Option<&crate::osv::DependencyVulnerabilities>,
) -> bool {
let Some(dv) = vulnerable_in_use else {
return false;
};
let Some(ecosystem) = ctx.versions.ecosystem else {
return false;
};
let in_use_version = super::resolve_in_use_version(
ctx.dep,
ctx.normalized_name,
ctx.versions.resolved,
ctx.versions.resolved_version_candidates,
ctx.formatter,
ecosystem,
);
if in_use_version.as_ref().map(ConcreteVersion::as_str) != Some(latest) {
return false;
}
let Some(worst_advisory) = dv.advisories_for_display().items().first().map(Arc::clone) else {
return false;
};
diagnostic_severity_for(worst_advisory.severity) <= would_be_severity
}
fn push_unverified_latest_check_failed(
diagnostics: &mut Vec<Diagnostic>,
resolved: &ResolvedData<'_>,
raw_status: Option<&UpgradeStatus>,
latest: &str,
) {
let Some(UpgradeStatus::CandidateUnverified { version, reason }) = raw_status else {
return;
};
if version != latest {
return;
}
let Some(clause) = reason.unchecked_reason() else {
return;
};
let latest = sanitize_and_truncate_for_diagnostic(latest, MAX_VERSION_DIAGNOSTIC_CHARS);
diagnostics.push(
Diagnostic::new(
resolved.version_range,
format!(
"This requirement already admits {latest}; whether it is safe could not be \
confirmed because {clause}"
),
)
.with_severity(Severity::Hint),
);
}
fn apply_outdated_rule(
diagnostics: &mut Vec<Diagnostic>,
ctx: &RuleContext<'_>,
resolved: &ResolvedData<'_>,
vuln_keys: Option<&crate::osv::VulnKeys>,
vulnerable_in_use: Option<&crate::osv::DependencyVulnerabilities>,
) {
let dep = ctx.dep;
let package_versions = resolved.package_versions;
let latest = &package_versions.latest;
let status = match dep.version_requirement() {
Some(version_req) if ctx.formatter.can_resolve_source(&dep.source()) => ctx
.formatter
.requirement_status_for(dep, version_req, latest),
_ => RequirementStatus::Unresolved,
};
if status != RequirementStatus::Outdated {
if status == RequirementStatus::UpToDate {
push_flagged_latest_admitted_by_requirement(
diagnostics,
ctx,
resolved,
vuln_keys,
latest.as_str(),
vulnerable_in_use,
);
}
return;
}
let verdict = crate::lsp_helpers::latest_verdict(
ctx.versions.latest_status,
dep,
vuln_keys,
ctx.normalized_name,
latest.as_str(),
ctx.formatter,
);
let latest =
sanitize_and_truncate_for_diagnostic(latest.as_str(), MAX_VERSION_DIAGNOSTIC_CHARS);
let (message, severity) = match &verdict {
LatestVerdict::Flagged {
advisory_ids,
malicious,
} => {
let ids = if advisory_ids.is_empty() {
String::new()
} else {
format!(" ({})", advisory_ids.join(", "))
};
let severity = if *malicious {
Severity::Error
} else {
Severity::Warning
};
(
format!("Latest version {latest} is flagged by OSV{ids} — do not upgrade"),
severity,
)
}
LatestVerdict::Unverified => (
format!("Newer version available: {latest} (not yet verified against OSV)"),
ctx.severities.outdated,
),
LatestVerdict::Verified | LatestVerdict::NotApplicable => {
let disposition = cooldown_disposition(
package_versions,
dep.name(),
ctx.freshness,
ctx.versions.gossip_prefetch,
ctx.now,
);
let message = match disposition {
CooldownDisposition::Blocked {
by: CooldownBlocker::Gossip,
..
} => format!(
"Newer version available: {latest} (deps.dev/GOSSIP reports this release is \
still within its cooldown window)"
),
CooldownDisposition::Blocked {
by: CooldownBlocker::Local { published_at },
..
} => format!(
"Newer version available: {latest} (published {} — still within the release cooldown window)",
format_relative_age(published_at.age_secs_from(ctx.now))
),
CooldownDisposition::Cleared | CooldownDisposition::NotEvaluated => {
format!("Newer version available: {latest}")
}
};
(message, ctx.severities.outdated)
}
};
diagnostics.push(Diagnostic::new(resolved.version_range, message).with_severity(severity));
}
fn push_collapsed_fetch_failures(
diagnostics: &mut Vec<Diagnostic>,
fetch_failed: Vec<FetchFailureEntry>,
uri: &url::Url,
) {
#[expect(
clippy::indexing_slicing,
reason = "the 0/1 arms are matched separately, so this arm only runs with len() >= 2, \
making both fetch_failed[0] and the fetch_failed[1..] slice below valid"
)]
match fetch_failed.len() {
0 => {}
1 => diagnostics.extend(fetch_failed.into_iter().map(|entry| entry.diagnostic)),
n => {
let first = &fetch_failed[0].diagnostic;
let range = first.range;
let severity = first.severity;
let shared_hint = fetch_failed.iter().find_map(|entry| match &entry.failure {
Some(FetchFailure::Actionable(hint)) => Some(hint.clone()),
_ => None,
});
let message = match shared_hint {
Some(hint) => format!("Registry lookup failed for {n} packages: {hint}"),
None => format!(
"Registry lookup failed for {n} packages; package status could not be determined"
),
};
let related_information = fetch_failed[1..]
.iter()
.map(|entry| {
RelatedInformation::new(
uri.clone(),
entry.diagnostic.range,
format!("'{}' also failed", entry.name),
)
})
.collect();
let mut diagnostic = Diagnostic::new(range, message);
diagnostic.severity = severity;
diagnostics.push(diagnostic.with_related_information(related_information));
}
}
}
fn version_anchor_range(dep: &dyn Dependency) -> Range {
if version_range_is_synthetic_empty(dep) {
dep.name_range()
} else {
dep.version_range().unwrap_or_else(|| dep.name_range())
}
}
fn push_deprecation_diagnostic(
diagnostics: &mut Vec<Diagnostic>,
dep: &dyn Dependency,
formatter: &dyn EcosystemFormatter,
deprecation: &Deprecation,
severities: DiagnosticSeverities,
) {
use std::fmt::Write as _;
let range: Range = version_anchor_range(dep);
let mut message = formatter.deprecated_message().to_string();
if let Some(reason) = deprecation.reason.as_deref().filter(|r| !r.is_empty()) {
let reason = sanitize_advisory_text_for_diagnostic(reason, MAX_DIAGNOSTIC_PROSE_CHARS);
let _ = write!(message, ": {reason}");
}
if let Some(replacement) = deprecation.replacement.as_deref().filter(|r| !r.is_empty()) {
let replacement =
sanitize_and_truncate_for_diagnostic(replacement, MAX_DIAGNOSTIC_NAME_CHARS);
let _ = write!(message, " (replacement: {replacement})");
}
diagnostics.push(
Diagnostic::new(range, message)
.with_severity(severities.deprecated)
.with_code(DEPRECATED_DIAGNOSTIC_CODE),
);
}
fn push_vulnerability_diagnostics(
diagnostics: &mut Vec<Diagnostic>,
dep: &dyn Dependency,
dv: &crate::osv::DependencyVulnerabilities,
formatter: &dyn EcosystemFormatter,
) {
let range: Range = version_anchor_range(dep);
let display_advisories = dv.advisories_for_display();
for advisory in display_advisories.items() {
let code_description = advisory
.url()
.parse::<url::Url>()
.ok()
.map(CodeDescription::new);
let advisory_id =
sanitize_advisory_text_for_diagnostic(advisory.id.as_str(), MAX_DIAGNOSTIC_PROSE_CHARS);
let summary = sanitize_advisory_text_for_diagnostic(
advisory
.summary
.as_deref()
.unwrap_or("(no summary provided)"),
MAX_DIAGNOSTIC_PROSE_CHARS,
);
let message = match advisory.severity {
crate::osv::VulnSeverity::Malicious => {
format!("{advisory_id}: [MALWARE] {summary}")
}
crate::osv::VulnSeverity::Informational => {
format!("{advisory_id}: [INFORMATIONAL] {summary}")
}
crate::osv::VulnSeverity::Critical
| crate::osv::VulnSeverity::High
| crate::osv::VulnSeverity::Medium
| crate::osv::VulnSeverity::Low
| crate::osv::VulnSeverity::Unknown => format!("{advisory_id}: {summary}"),
};
let message = match dv.sibling_match(&advisory.id) {
Some(tags) => format!(
"{message} ({} {})",
formatter.sibling_match_label(),
format_matched_tags(tags)
),
None => message,
};
let mut diagnostic = Diagnostic::new(range, message)
.with_severity(diagnostic_severity_for(advisory.severity))
.with_code(advisory.id.clone());
if let Some(code_description) = code_description {
diagnostic = diagnostic.with_code_description(code_description);
}
diagnostics.push(diagnostic);
}
let remaining = display_advisories.remaining();
if remaining > 0 {
diagnostics.push(
Diagnostic::new(range, format!("+{remaining} more advisories"))
.with_severity(Severity::Information),
);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lsp_helpers::test_support::*;
use crate::lsp_helpers::*;
use crate::position::{Position, Range};
use crate::{PackageName, VersionReq};
use std::collections::HashMap;
use std::sync::Arc;
fn matched_tags(tags: &[&str]) -> crate::osv::MatchedTags {
crate::osv::MatchedTags::from_tags(
tags.iter()
.map(|t| crate::ConcreteVersion::new(*t))
.collect(),
)
.unwrap()
}
#[test]
fn test_format_matched_tags_caps_the_count() {
let rendered = format_matched_tags(&matched_tags(&[
"v1.0.0", "v1.0.1", "v1.0.2", "v1.0.3", "v1.0.4",
]));
assert_eq!(rendered, "v1.0.0, v1.0.1, v1.0.2 (+2 more)");
}
#[test]
fn test_format_matched_tags_truncates_and_strips_invisible_characters() {
let long = format!("v{}", "9".repeat(10_000));
let rendered =
format_matched_tags(&matched_tags(&[long.as_str(), "v1\u{202E}.0.0\u{200D}"]));
let (first, second) = rendered.split_once(", ").unwrap();
assert!(
first.chars().count() <= MAX_VERSION_DIAGNOSTIC_CHARS + 1,
"{first}"
);
assert_eq!(second, "v1 .0.0 ");
}
#[test]
fn test_version_anchor_range_falls_back_to_name_range_with_no_requirement() {
let name_range = Range::new(Position::new(4, 18), Position::new(4, 21));
let dep = MockNoRequirementDep {
name: PackageName::new("com.example:foo"),
name_range,
version_range: Range::new(Position::new(5, 15), Position::new(5, 15)),
};
assert_eq!(version_anchor_range(&dep), name_range);
}
#[test]
fn test_version_anchor_range_uses_version_range_when_requirement_present() {
let version_range = Range::new(Position::new(0, 9), Position::new(0, 14));
let dep = MockDep {
name: PackageName::new("serde"),
version_req: VersionReq::new("1.0.0"),
version_range,
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
};
assert_eq!(version_anchor_range(&dep), version_range);
}
#[test]
fn test_version_anchor_range_uses_non_empty_version_range_with_no_requirement() {
let version_range = Range::new(Position::new(4, 40), Position::new(4, 45));
let dep = MockNoRequirementDep {
name: PackageName::new("com.example:guava"),
name_range: Range::new(Position::new(4, 18), Position::new(4, 21)),
version_range,
};
assert_eq!(version_anchor_range(&dep), version_range);
}
#[test]
fn test_generate_diagnostics_from_cache_unknown_package() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "unknown-pkg".into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 11)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].severity, Some(Severity::Warning));
assert!(diagnostics[0].message().contains("Unknown package"));
assert!(diagnostics[0].message().contains("unknown-pkg"));
}
const CREDENTIAL_SHAPED_NAME: &str = "https://svcacct:glpat-AAAABBBBCCCCDDDD@gitlab.corp/g/p";
#[test]
fn test_generate_diagnostics_from_cache_redacts_credential_in_invalid_name() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = RejectingFormatter;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: CREDENTIAL_SHAPED_NAME.into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 11)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert!(diagnostics[0].message().starts_with("Invalid package name"));
assert!(diagnostics[0].message().contains("***@"));
assert!(!diagnostics[0].message().contains("glpat-AAAABBBBCCCCDDDD"));
}
#[test]
fn test_generate_diagnostics_from_cache_redacts_credential_in_fetch_failure_hint() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: CREDENTIAL_SHAPED_NAME.into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 11)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let outcomes = DependencyOutcomes::new().with_fetch_failure(
CREDENTIAL_SHAPED_NAME,
FetchFailure::Actionable("set GITHUB_TOKEN to increase the rate limit".to_string()),
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert!(diagnostics[0].message().contains("Registry lookup failed"));
assert!(diagnostics[0].message().contains("***@"));
assert!(!diagnostics[0].message().contains("glpat-AAAABBBBCCCCDDDD"));
}
#[test]
fn test_generate_diagnostics_from_cache_redacts_credential_in_unknown_package() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: CREDENTIAL_SHAPED_NAME.into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 11)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert!(diagnostics[0].message().contains("Unknown package"));
assert!(diagnostics[0].message().contains("***@"));
assert!(!diagnostics[0].message().contains("glpat-AAAABBBBCCCCDDDD"));
}
#[test]
fn test_generate_diagnostics_from_cache_sanitizes_newlines_in_name() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let injected_name =
"ok\n [error] 1:1 totally-real-pkg (vulnerable) — CRITICAL RCE, upgrade now";
let parse_result = MockParseResult {
deps: vec![MockDep {
name: injected_name.into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 11)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert!(!diagnostics[0].message().contains('\n'));
assert!(!diagnostics[0].message().contains('\r'));
}
#[test]
fn test_generate_diagnostics_from_cache_sanitizes_bidi_override_in_name() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let injected_name = "bidi\u{202E}gnp.exe\u{200B}";
let parse_result = MockParseResult {
deps: vec![MockDep {
name: injected_name.into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 11)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert!(!diagnostics[0].message().contains('\u{202E}'));
assert!(!diagnostics[0].message().contains('\u{200B}'));
}
#[test]
fn test_generate_diagnostics_from_cache_sanitizes_line_and_paragraph_separators_in_name() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let injected_name = "evil\u{2028}pkg\u{2029}name";
let parse_result = MockParseResult {
deps: vec![MockDep {
name: injected_name.into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 11)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert!(!diagnostics[0].message().contains('\u{2028}'));
assert!(!diagnostics[0].message().contains('\u{2029}'));
}
#[test]
fn test_generate_diagnostics_from_cache_truncates_oversized_name() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let oversized_name = "a".repeat(400_000);
let parse_result = MockParseResult {
deps: vec![MockDep {
name: oversized_name.as_str().into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 11)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert!(
diagnostics[0].message().chars().count() < 200,
"expected a bounded message, got {} chars",
diagnostics[0].message().chars().count()
);
}
#[test]
fn test_generate_diagnostics_from_cache_skips_synthetic_range_dependency() {
use crate::position::{Position, Range};
let formatter = MOCK_FORMATTER;
let parse_result = MockMixedParseResult {
deps: vec![
Box::new(MockSyntheticRangeDep {
name: "synthetic-pkg".into(),
}),
Box::new(MockDep {
name: "unknown-pkg".into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 11)),
}),
],
uri: crate::test_util::test_uri("/test/pubspec.yaml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(
diagnostics.len(),
1,
"the synthetic-range dependency must contribute no diagnostic at all, not one \
stacked on Range::default()"
);
assert!(diagnostics[0].message().contains("unknown-pkg"));
assert!(!diagnostics[0].message().contains("synthetic-pkg"));
}
#[test]
fn test_generate_diagnostics_from_cache_reports_dependency_ceiling_truncation() {
let formatter = MOCK_FORMATTER;
let inner = crate::test_util::stub_parse_result_with_dependencies(12);
let parse_result = crate::dependency_cap::cap_dependencies(inner, 10);
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
parse_result.as_ref(),
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(
parse_result.dependencies().len(),
10,
"generate_diagnostics_from_cache must only ever see the capped 10 dependencies"
);
let ceiling_diagnostics: Vec<_> = diagnostics
.iter()
.filter(|d| d.severity == Some(Severity::Information))
.filter(|d| {
d.message()
.contains("exceeding deps-lsp's per-document limit")
})
.collect();
assert_eq!(
ceiling_diagnostics.len(),
1,
"expected exactly one dependency-ceiling diagnostic, got: {diagnostics:?}"
);
assert!(ceiling_diagnostics[0].message().contains("12"));
assert!(ceiling_diagnostics[0].message().contains("10"));
}
#[test]
fn test_generate_diagnostics_from_cache_no_ceiling_notice_under_the_limit() {
let formatter = MOCK_FORMATTER;
let inner = crate::test_util::stub_parse_result_with_dependencies(3);
let parse_result = crate::dependency_cap::cap_dependencies(inner, 10);
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
parse_result.as_ref(),
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
!diagnostics.iter().any(|d| d
.message()
.contains("exceeding deps-lsp's per-document limit")),
"a document under the ceiling must get no ceiling notice, got: {diagnostics:?}"
);
}
#[test]
fn test_generate_diagnostics_from_cache_reports_invalid_minimum_stability() {
use crate::position::{Position, Range};
use crate::selection::InvalidStabilityOccurrence;
struct InvalidStabilityParseResult {
deps: Vec<MockDep>,
uri: url::Url,
invalid: InvalidStabilityOccurrence,
}
impl ParseResult for InvalidStabilityParseResult {
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 invalid_minimum_stability(&self) -> Option<InvalidStabilityOccurrence> {
Some(self.invalid.clone())
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
let formatter = MOCK_FORMATTER;
let occurrence_range = Range::new(Position::new(1, 24), Position::new(1, 29));
let parse_result = InvalidStabilityParseResult {
deps: vec![MockDep {
name: "vendor/pkg".into(),
version_req: "^1.0".into(),
version_range: Range::new(Position::new(2, 20), Position::new(2, 24)),
name_range: Range::new(Position::new(2, 4), Position::new(2, 14)),
}],
uri: crate::test_util::test_uri("/test/composer.json"),
invalid: InvalidStabilityOccurrence {
range: occurrence_range,
raw: "betta".to_string(),
},
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let notices: Vec<_> = diagnostics
.iter()
.filter(|d| d.message().contains("minimum-stability"))
.collect();
assert_eq!(
notices.len(),
1,
"expected exactly one invalid-minimum-stability diagnostic, got: {diagnostics:?}"
);
assert_eq!(notices[0].severity, Some(Severity::Warning));
assert_eq!(notices[0].range, occurrence_range);
assert!(notices[0].message().contains("\"betta\""));
assert!(
notices[0]
.message()
.contains("dev, alpha, beta, RC, stable")
);
assert!(notices[0].message().contains("stable"));
}
#[test]
fn test_generate_diagnostics_from_cache_invalid_minimum_stability_sanitizes_raw() {
use crate::position::{Position, Range};
use crate::selection::InvalidStabilityOccurrence;
struct InvalidStabilityParseResult {
deps: Vec<MockDep>,
uri: url::Url,
invalid: InvalidStabilityOccurrence,
}
impl ParseResult for InvalidStabilityParseResult {
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 invalid_minimum_stability(&self) -> Option<InvalidStabilityOccurrence> {
Some(self.invalid.clone())
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
let formatter = MOCK_FORMATTER;
let parse_result = InvalidStabilityParseResult {
deps: vec![],
uri: crate::test_util::test_uri("/test/composer.json"),
invalid: InvalidStabilityOccurrence {
range: Range::new(Position::new(0, 0), Position::new(0, 1)),
raw: "bid\u{202E}i".to_string(),
},
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let notice = diagnostics
.iter()
.find(|d| d.message().contains("minimum-stability"))
.expect("expected an invalid-minimum-stability diagnostic");
assert!(!notice.message().contains('\u{202E}'));
}
#[test]
fn test_generate_diagnostics_from_cache_no_comparable_versions_is_not_unknown_package() {
use crate::position::{Position, Range};
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "dtolnay/rust-toolchain".into(),
version_req: "stable".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 23)),
}],
uri: crate::test_util::test_uri("/repo/.github/workflows/ci.yml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let outcomes =
DependencyOutcomes::new().with_no_comparable_versions("dtolnay/rust-toolchain");
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics.is_empty(),
"expected no diagnostic for a package with no comparable versions, got: {diagnostics:?}"
);
}
fn blocked_diagnostic_for(declaration_key: &str, raw_value: &str) -> Diagnostic {
use crate::net_policy::HostClass;
struct BlockedRegistryParseResult {
deps: Vec<MockDep>,
uri: url::Url,
blocked: Vec<BlockedRegistryOccurrence>,
}
impl ParseResult for BlockedRegistryParseResult {
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 blocked_registries(&self) -> Vec<BlockedRegistryOccurrence> {
self.blocked.clone()
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
let name_range = Range::new(Position::new(0, 0), Position::new(0, 14));
let formatter = MOCK_FORMATTER;
let parse_result = BlockedRegistryParseResult {
deps: vec![MockDep {
name: "internal-crate".into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 20), Position::new(0, 25)),
name_range,
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
blocked: vec![BlockedRegistryOccurrence {
range: name_range,
class: HostClass::CloudMetadata,
raw_value: raw_value.to_string(),
declaration_key: declaration_key.to_string(),
}],
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
diagnostics
.into_iter()
.find(|d| d.message().contains("blocked"))
.expect("expected a blocked-registry diagnostic")
}
#[test]
fn test_generate_diagnostics_from_cache_emits_blocked_registry_diagnostic() {
let blocked_diagnostic = blocked_diagnostic_for(
"https://169.254.169.254/index",
"https://169.254.169.254/index",
);
assert_eq!(
blocked_diagnostic.range,
Range::new(Position::new(0, 0), Position::new(0, 14))
);
assert_eq!(blocked_diagnostic.severity, Some(Severity::Information));
assert!(blocked_diagnostic.message().contains("169.254.169.254"));
assert!(blocked_diagnostic.message().contains("cloud metadata"));
assert!(
!blocked_diagnostic.message().contains("CloudMetadata"),
"message must use the Display form, not the Debug identifier"
);
}
#[test]
fn test_generate_diagnostics_from_cache_blocked_registry_message_redacts_query_string_credential()
{
let blocked_diagnostic = blocked_diagnostic_for(
"https://index.mycorp.dev/api?api_key=SECRET",
"https://index.mycorp.dev/api?api_key=SECRET",
);
assert!(
!blocked_diagnostic.message().contains("SECRET"),
"{blocked_diagnostic:?}"
);
assert!(blocked_diagnostic.message().contains("index.mycorp.dev"));
assert!(blocked_diagnostic.message().contains("/api"));
}
#[test]
fn test_generate_diagnostics_from_cache_blocked_registry_message_redacts_query_string_credential_in_declaration_key_without_scheme_slashes()
{
let slash_less = "https:169.254.169.254/v3/index.json?api_key=SECRET";
let blocked_diagnostic = blocked_diagnostic_for(slash_less, slash_less);
assert!(
!blocked_diagnostic.message().contains("SECRET"),
"declaration_key's query-string credential must be stripped even without \"://\", \
got: {blocked_diagnostic:?}"
);
assert!(blocked_diagnostic.message().contains("169.254.169.254"));
}
#[test]
fn test_generate_diagnostics_from_cache_blocked_registry_message_redacts_userinfo_in_declaration_key_without_scheme_slashes()
{
let slash_less_credential = "https:user:hunter2@10.0.0.1/index";
let blocked_diagnostic =
blocked_diagnostic_for(slash_less_credential, slash_less_credential);
assert!(
blocked_diagnostic
.message()
.contains("declaration: https://***@10.0.0.1/index"),
"declaration_key's userinfo must be redacted even without \"://\", \
got: {blocked_diagnostic:?}"
);
}
#[test]
fn test_generate_diagnostics_from_cache_blocked_registry_message_does_not_mangle_opaque_declaration_keys()
{
for opaque_key in [
"scope:@myorg",
"source:Blocked",
"top-level",
"primary",
"uv-tail",
"named:internal",
"goproxy",
"component-host:10.0.0.1",
] {
let blocked_diagnostic =
blocked_diagnostic_for(opaque_key, "https://169.254.169.254/index");
assert!(
blocked_diagnostic
.message()
.contains(&format!("declaration: {opaque_key}")),
"opaque declaration_key {opaque_key:?} must survive unmangled, \
got: {blocked_diagnostic:?}"
);
}
}
#[test]
fn test_generate_diagnostics_from_cache_blocked_registry_message_still_redacts_opaque_label_prefixed_url_in_declaration_key()
{
let blocked_diagnostic = blocked_diagnostic_for(
"source:https://user:hunter2@10.0.0.1/v3/index.json",
"https://10.0.0.1/v3/index.json",
);
assert!(
blocked_diagnostic
.message()
.contains("declaration: source:https://***@10.0.0.1/v3/index.json"),
"an opaque-label-prefixed URL's userinfo must still be redacted, \
got: {blocked_diagnostic:?}"
);
}
#[test]
fn test_generate_diagnostics_from_cache_blocked_registry_message_redacts_declaration_key_credential_regardless_of_separator()
{
for (declaration_key, expected_declaration) in [
("source:feed//user:hunter2@h", "***@h"),
(
"source:feed/user:hunter2@nuget.internal",
"***@nuget.internal",
),
(
"source:user:ghp_SECRET@nuget.pkg.github.com/OWNER/index.json",
"***@nuget.pkg.github.com/OWNER/index.json",
),
("source:feed%2F%2Fuser:hunter2@h", "***@h"),
("named:user:hunter2@pypi.internal", "***@pypi.internal"),
(
"component-host:user:hunter2@gitlab.internal",
"***@gitlab.internal",
),
] {
let blocked_diagnostic =
blocked_diagnostic_for(declaration_key, "https://169.254.169.254/index");
assert!(
blocked_diagnostic
.message()
.contains(&format!("declaration: {expected_declaration}")),
"declaration_key {declaration_key:?} must redact to {expected_declaration:?}, \
got: {blocked_diagnostic:?}"
);
}
}
#[test]
fn test_generate_diagnostics_from_cache_blocked_registry_message_does_not_mangle_double_slash_labels_without_credential()
{
for opaque_key in [
"source:feed//mirror",
"component-host:gitlab.example.com//group",
"named:my//index",
] {
let blocked_diagnostic =
blocked_diagnostic_for(opaque_key, "https://169.254.169.254/index");
assert!(
blocked_diagnostic
.message()
.contains(&format!("declaration: {opaque_key}")),
"opaque declaration_key {opaque_key:?} must survive unmangled, \
got: {blocked_diagnostic:?}"
);
}
}
#[test]
fn test_generate_diagnostics_from_cache_dedups_by_declaration_key_not_by_value() {
use crate::net_policy::HostClass;
use crate::position::{Position, Range};
struct BlockedRegistryParseResult {
deps: Vec<MockDep>,
uri: url::Url,
blocked: Vec<BlockedRegistryOccurrence>,
}
impl ParseResult for BlockedRegistryParseResult {
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 blocked_registries(&self) -> Vec<BlockedRegistryOccurrence> {
self.blocked.clone()
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
let first_range = Range::new(Position::new(0, 0), Position::new(0, 14));
let second_range = Range::new(Position::new(1, 0), Position::new(1, 14));
let third_range = Range::new(Position::new(2, 0), Position::new(2, 14));
let formatter = MOCK_FORMATTER;
let parse_result = BlockedRegistryParseResult {
deps: vec![
MockDep {
name: "first-crate".into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 20), Position::new(0, 25)),
name_range: first_range,
},
MockDep {
name: "second-crate".into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(1, 20), Position::new(1, 25)),
name_range: second_range,
},
MockDep {
name: "third-crate".into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(2, 20), Position::new(2, 25)),
name_range: third_range,
},
],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
blocked: vec![
BlockedRegistryOccurrence {
range: first_range,
class: HostClass::CloudMetadata,
raw_value: "https://169.254.169.254/index".to_string(),
declaration_key: "top-level".to_string(),
},
BlockedRegistryOccurrence {
range: second_range,
class: HostClass::CloudMetadata,
raw_value: "https://169.254.169.254/index".to_string(),
declaration_key: "top-level".to_string(),
},
BlockedRegistryOccurrence {
range: third_range,
class: HostClass::CloudMetadata,
raw_value: "https://169.254.169.254/index".to_string(),
declaration_key: "scope:@myorg".to_string(),
},
],
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let blocked_diagnostics: Vec<_> = diagnostics
.iter()
.filter(|d| d.message().contains("blocked"))
.collect();
assert_eq!(
blocked_diagnostics.len(),
2,
"two entries sharing a declaration key must collapse to one diagnostic, but a \
third entry with a different declaration key must still get its own, got: \
{blocked_diagnostics:?}"
);
let ranges: Vec<Range> = blocked_diagnostics.iter().map(|d| d.range).collect();
assert!(ranges.contains(&first_range));
assert!(ranges.contains(&third_range));
assert!(
!ranges.contains(&second_range),
"the deduped-away entry must be the second occurrence of the same declaration key, \
not the third (different-declaration) entry"
);
let anchor = blocked_diagnostics
.iter()
.find(|d| d.range == first_range)
.expect("anchor diagnostic for the shared declaration key must exist");
let related = anchor
.related_information
.as_ref()
.expect("anchor diagnostic must carry related_information for the collapsed sibling");
assert_eq!(related.len(), 1);
assert_eq!(related[0].range, second_range);
assert!(
related[0].message().contains("second-crate"),
"related_information message must name the collapsed sibling dependency, got: {:?}",
related[0].message()
);
let third = blocked_diagnostics
.iter()
.find(|d| d.range == third_range)
.expect("diagnostic for the differently-declared third occurrence must exist");
assert_ne!(
anchor.message(),
third.message(),
"two independently-declared blocked sources sharing the same raw_value must not \
render byte-identical diagnostic messages"
);
assert!(anchor.message().contains("top-level"));
assert!(third.message().contains("scope:@myorg"));
}
#[test]
fn test_generate_diagnostics_from_cache_redacts_credential_in_blocked_registry_related_info() {
use crate::net_policy::HostClass;
use crate::position::{Position, Range};
struct BlockedRegistryParseResult {
deps: Vec<MockDep>,
uri: url::Url,
blocked: Vec<BlockedRegistryOccurrence>,
}
impl ParseResult for BlockedRegistryParseResult {
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 blocked_registries(&self) -> Vec<BlockedRegistryOccurrence> {
self.blocked.clone()
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
let first_range = Range::new(Position::new(0, 0), Position::new(0, 14));
let second_range = Range::new(Position::new(1, 0), Position::new(1, 14));
let credential_name = "https://svcacct:glpat-AAAABBBBCCCCDDDD@gitlab.corp/g/p";
let formatter = MOCK_FORMATTER;
let parse_result = BlockedRegistryParseResult {
deps: vec![
MockDep {
name: "first-crate".into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 20), Position::new(0, 25)),
name_range: first_range,
},
MockDep {
name: credential_name.into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(1, 20), Position::new(1, 25)),
name_range: second_range,
},
],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
blocked: vec![
BlockedRegistryOccurrence {
range: first_range,
class: HostClass::CloudMetadata,
raw_value: "https://169.254.169.254/index".to_string(),
declaration_key: "top-level".to_string(),
},
BlockedRegistryOccurrence {
range: second_range,
class: HostClass::CloudMetadata,
raw_value: "https://169.254.169.254/index".to_string(),
declaration_key: "top-level".to_string(),
},
],
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let anchor = diagnostics
.iter()
.find(|d| d.range == first_range)
.expect("anchor diagnostic for the shared declaration key must exist");
let related = anchor
.related_information
.as_ref()
.expect("anchor diagnostic must carry related_information for the collapsed sibling");
assert_eq!(related.len(), 1);
assert!(related[0].message().contains("***@"));
assert!(!related[0].message().contains("glpat-AAAABBBBCCCCDDDD"));
}
#[test]
fn test_generate_diagnostics_from_cache_blocked_registry_related_info_capped_and_folded() {
use crate::net_policy::HostClass;
use crate::position::{Position, Range};
struct BlockedRegistryParseResult {
deps: Vec<MockDep>,
uri: url::Url,
blocked: Vec<BlockedRegistryOccurrence>,
}
impl ParseResult for BlockedRegistryParseResult {
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 blocked_registries(&self) -> Vec<BlockedRegistryOccurrence> {
self.blocked.clone()
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
const TOTAL: u32 = 12;
let ranges: Vec<Range> = (0..TOTAL)
.map(|i| Range::new(Position::new(i, 0), Position::new(i, 14)))
.collect();
let deps: Vec<MockDep> = ranges
.iter()
.enumerate()
.map(|(i, &range)| MockDep {
name: format!("crate-{i}").into(),
version_req: "1.0.0".into(),
version_range: Range::new(
Position::new(range.start.line, 20),
Position::new(range.start.line, 25),
),
name_range: range,
})
.collect();
let blocked: Vec<BlockedRegistryOccurrence> = ranges
.iter()
.map(|&range| BlockedRegistryOccurrence {
range,
class: HostClass::CloudMetadata,
raw_value: "https://169.254.169.254/index".to_string(),
declaration_key: "top-level".to_string(),
})
.collect();
let formatter = MOCK_FORMATTER;
let parse_result = BlockedRegistryParseResult {
deps,
uri: crate::test_util::test_uri("/test/Cargo.toml"),
blocked,
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let blocked_diagnostics: Vec<_> = diagnostics
.iter()
.filter(|d| d.message().contains("blocked"))
.collect();
assert_eq!(
blocked_diagnostics.len(),
1,
"all 12 occurrences share one declaration key and must collapse to one diagnostic"
);
let related = blocked_diagnostics[0]
.related_information
.as_ref()
.expect("anchor diagnostic must carry related_information for the siblings");
assert_eq!(
related.len(),
10,
"9 individually-named siblings plus 1 folded '+N more' entry, got: {related:?}"
);
let named_count = related
.iter()
.filter(|r| r.message().contains('\''))
.count();
assert_eq!(
named_count, 9,
"exactly 9 siblings must be individually named, got: {related:?}"
);
let fold_entry = &related[9];
assert!(
fold_entry.message().contains("and 2 more"),
"trailing fold entry must report the 2 siblings beyond the 9-entry cap, got: {:?}",
fold_entry.message()
);
assert_eq!(
fold_entry.range, blocked_diagnostics[0].range,
"the fold entry must be anchored at the anchor diagnostic's own range"
);
}
#[test]
fn test_truncate_for_diagnostic_leaves_short_value_untouched() {
assert_eq!(truncate_for_diagnostic("my-corp", 128), "my-corp");
}
#[test]
fn test_truncate_for_diagnostic_truncates_and_appends_ellipsis() {
let long_value = "a".repeat(200);
let truncated = truncate_for_diagnostic(&long_value, 128);
assert_eq!(truncated.chars().count(), 129); assert!(truncated.ends_with('…'));
assert!(truncated.starts_with(&"a".repeat(128)));
}
#[test]
fn test_truncate_for_diagnostic_never_splits_a_multibyte_character() {
let long_value = "日".repeat(200);
let truncated = truncate_for_diagnostic(&long_value, 128);
assert_eq!(truncated.chars().count(), 129);
}
#[test]
fn test_generate_diagnostics_from_cache_blocked_registry_message_caps_long_raw_value() {
use crate::net_policy::HostClass;
use crate::position::{Position, Range};
struct BlockedRegistryParseResult {
deps: Vec<MockDep>,
uri: url::Url,
blocked: Vec<BlockedRegistryOccurrence>,
}
impl ParseResult for BlockedRegistryParseResult {
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 blocked_registries(&self) -> Vec<BlockedRegistryOccurrence> {
self.blocked.clone()
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
let name_range = Range::new(Position::new(0, 0), Position::new(0, 14));
let formatter = MOCK_FORMATTER;
let long_alias = "x".repeat(10_000);
let parse_result = BlockedRegistryParseResult {
deps: vec![MockDep {
name: "internal-crate".into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 20), Position::new(0, 25)),
name_range,
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
blocked: vec![BlockedRegistryOccurrence {
range: name_range,
class: HostClass::InternalName,
raw_value: long_alias.clone(),
declaration_key: long_alias.clone(),
}],
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let blocked_diagnostic = diagnostics
.iter()
.find(|d| d.message().contains("blocked"))
.expect("expected a blocked-registry diagnostic");
assert!(
blocked_diagnostic.message().len() < long_alias.len(),
"a 10,000-char alias must not render in full inside the diagnostic message"
);
assert!(blocked_diagnostic.message().contains('…'));
}
#[test]
fn test_generate_diagnostics_from_cache_blocked_registry_message_sanitizes_bidi_override() {
let raw_value = "https://index.mycorp.dev/api\u{202E}evil";
let declaration_key = "source\u{202E}evil";
let blocked_diagnostic = blocked_diagnostic_for(declaration_key, raw_value);
assert!(!blocked_diagnostic.message().contains('\u{202E}'));
assert!(blocked_diagnostic.message().contains("index.mycorp.dev"));
}
fn rejected_diagnostic_for(
declaration_key: &str,
raw_value: &str,
reason: crate::net_policy::RegistryRejectionReason,
) -> Diagnostic {
struct RejectedRegistryParseResult {
deps: Vec<MockDep>,
uri: url::Url,
rejected: Vec<RejectedRegistryOccurrence>,
}
impl ParseResult for RejectedRegistryParseResult {
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 rejected_registries(&self) -> Vec<RejectedRegistryOccurrence> {
self.rejected.clone()
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
let name_range = Range::new(Position::new(0, 0), Position::new(0, 14));
let formatter = MOCK_FORMATTER;
let parse_result = RejectedRegistryParseResult {
deps: vec![MockDep {
name: "internal-crate".into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 20), Position::new(0, 25)),
name_range,
}],
uri: crate::test_util::test_uri("/test/package.json"),
rejected: vec![RejectedRegistryOccurrence {
range: name_range,
reason,
raw_value: raw_value.to_string(),
declaration_key: declaration_key.to_string(),
}],
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
diagnostics
.into_iter()
.find(|d| d.message().contains("rejected:"))
.expect("expected a rejected-registry diagnostic")
}
#[test]
fn test_generate_diagnostics_from_cache_emits_rejected_registry_diagnostic() {
let rejected_diagnostic = rejected_diagnostic_for(
"top-level",
"https://evil.example.com/${GITHUB_TOKEN}",
crate::net_policy::RegistryRejectionReason::EnvVarExpansionNotPermitted,
);
assert_eq!(
rejected_diagnostic.range,
Range::new(Position::new(0, 0), Position::new(0, 14))
);
assert_eq!(rejected_diagnostic.severity, Some(Severity::Warning));
assert!(rejected_diagnostic.message().contains("evil.example.com"));
assert!(
rejected_diagnostic
.message()
.contains("environment-variable expansion")
);
assert!(
rejected_diagnostic
.message()
.contains("declaration: top-level")
);
}
#[test]
fn test_generate_diagnostics_from_cache_rejected_registry_message_names_every_reason() {
use crate::net_policy::RegistryRejectionReason;
for (reason, expected_fragment) in [
(RegistryRejectionReason::InvalidUrl, "not a valid URL"),
(RegistryRejectionReason::NotHttps, "does not use https"),
(
RegistryRejectionReason::UserInfoPresent,
"embedded credentials",
),
(
RegistryRejectionReason::UndefinedEnvVar,
"undefined environment variable",
),
(
RegistryRejectionReason::EnvVarExpansionNotPermitted,
"environment-variable expansion",
),
(RegistryRejectionReason::HasCredentials, "cannot be used"),
(
RegistryRejectionReason::EncryptedCredentialUnsupported,
"encrypted credential",
),
(
RegistryRejectionReason::UnsupportedFeedKind,
"feed kind that is not supported",
),
] {
let rejected_diagnostic =
rejected_diagnostic_for("top-level", "https://example.com", reason);
assert!(
rejected_diagnostic.message().contains(expected_fragment),
"reason {reason:?} must produce a message containing {expected_fragment:?}, \
got: {rejected_diagnostic:?}"
);
}
}
#[test]
fn test_generate_diagnostics_from_cache_rejected_registry_invalid_url_masks_bare_token_shaped_value()
{
let rejected_diagnostic = rejected_diagnostic_for(
"top-level",
"AbCdEfGh12345678",
crate::net_policy::RegistryRejectionReason::InvalidUrl,
);
assert!(
!rejected_diagnostic.message().contains("AbCdEfGh12345678"),
"a bare token-shaped .npmrc value must never reach the diagnostic unmasked, \
got: {rejected_diagnostic:?}"
);
assert!(
rejected_diagnostic.message().contains("***"),
"expected the token-shaped value to be masked, got: {rejected_diagnostic:?}"
);
}
#[test]
fn test_generate_diagnostics_from_cache_rejected_registry_dedups_by_declaration_key() {
use crate::net_policy::RegistryRejectionReason;
struct RejectedRegistryParseResult {
deps: Vec<MockDep>,
uri: url::Url,
rejected: Vec<RejectedRegistryOccurrence>,
}
impl ParseResult for RejectedRegistryParseResult {
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 rejected_registries(&self) -> Vec<RejectedRegistryOccurrence> {
self.rejected.clone()
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
let range_a = Range::new(Position::new(0, 0), Position::new(0, 4));
let range_b = Range::new(Position::new(1, 0), Position::new(1, 4));
let formatter = MOCK_FORMATTER;
let parse_result = RejectedRegistryParseResult {
deps: vec![
MockDep {
name: "dep-a".into(),
version_req: "1.0.0".into(),
version_range: range_a,
name_range: range_a,
},
MockDep {
name: "dep-b".into(),
version_req: "1.0.0".into(),
version_range: range_b,
name_range: range_b,
},
],
uri: crate::test_util::test_uri("/test/package.json"),
rejected: vec![
RejectedRegistryOccurrence {
range: range_a,
reason: RegistryRejectionReason::InvalidUrl,
raw_value: "not-a-url".to_string(),
declaration_key: "top-level".to_string(),
},
RejectedRegistryOccurrence {
range: range_b,
reason: RegistryRejectionReason::InvalidUrl,
raw_value: "not-a-url".to_string(),
declaration_key: "top-level".to_string(),
},
],
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let rejected: Vec<&Diagnostic> = diagnostics
.iter()
.filter(|d| d.message().contains("rejected:"))
.collect();
assert_eq!(
rejected.len(),
1,
"two entries sharing one declaration key must collapse to one diagnostic, \
got: {diagnostics:?}"
);
assert_eq!(rejected[0].range, range_a);
let related = rejected[0]
.related_information
.as_ref()
.expect("second dependency must survive via related_information");
assert_eq!(related.len(), 1);
}
#[test]
fn test_generate_diagnostics_from_cache_unsatisfiable_sanitizes_bidi_in_requirement_and_latest()
{
struct AlwaysUnsatisfiable;
struct NeverMatches;
impl RequirementMatcher for NeverMatches {
fn matches(&self, _version: &ConcreteVersion) -> Option<bool> {
Some(false)
}
fn strict_prerelease_exclusion(&self) -> bool {
false
}
}
impl PackageNaming for AlwaysUnsatisfiable {}
impl PackageRendering for AlwaysUnsatisfiable {
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 AlwaysUnsatisfiable {
fn compile_bounded_requirement(
&self,
_requirement: BoundedVersionReq<'_>,
) -> Option<Box<dyn RequirementMatcher>> {
Some(Box::new(NeverMatches))
}
}
impl DiagnosticMessages for AlwaysUnsatisfiable {}
impl DiagnosticPolicy for AlwaysUnsatisfiable {}
impl SourcePolicy for AlwaysUnsatisfiable {}
impl OsvNaming for AlwaysUnsatisfiable {}
let mut cached_versions = HashMap::new();
cached_versions.insert(
"dep".into(),
PackageVersions {
latest: "1.0.0\u{202E}evil".into(),
available: Arc::from(vec!["1.0.0\u{202E}evil".into()]),
yanked: Arc::from(Vec::new()),
published_at: None,
gossip_excluded_version: None,
cooldown_fallback: None,
},
);
let resolved_versions = HashMap::new();
let mut dependency = dep_at("dep");
dependency.version_req = VersionReq::new("^2.0.0\u{202E}evil");
let parse_result = SingleDepParseResult {
dep: dependency,
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&AlwaysUnsatisfiable,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let message = diagnostics
.iter()
.find(|d| d.message().contains("No published version satisfies"))
.map(|d| d.message())
.expect("unsatisfiable diagnostic must fire");
assert!(!message.contains('\u{202E}'));
assert!(message.contains("2.0.0"));
assert!(message.contains("1.0.0"));
}
#[test]
fn test_generate_diagnostics_from_cache_in_use_yanked_sanitizes_bidi_in_version() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "1.0.5".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let outcomes = DependencyOutcomes::new()
.with_yanked("serde", ("1.0.5\u{202E}evil".into(), RemovalStatus::Yanked));
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let yanked_diag = diagnostics
.iter()
.find(|d| d.message().starts_with(formatter.yanked_message()))
.expect("expected a yanked diagnostic");
assert!(!yanked_diag.message().contains('\u{202E}'));
assert!(yanked_diag.message().contains("1.0.5"));
}
#[test]
fn test_generate_diagnostics_from_cache_outdated_sanitizes_bidi_in_latest() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "1.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions::latest_only("2.0.0\u{202E}evil"),
);
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert!(!diagnostics[0].message().contains('\u{202E}'));
assert!(diagnostics[0].message().contains("2.0.0"));
}
#[test]
fn test_generate_diagnostics_from_cache_outdated_truncates_oversized_latest() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "1.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let overlong_latest = format!("2.0.0-{}", "X".repeat(MAX_VERSION_DIAGNOSTIC_CHARS + 50));
let mut cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions::latest_only(overlong_latest.clone()),
);
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert!(diagnostics[0].message().contains('…'));
assert!(
!diagnostics[0].message().contains(&overlong_latest),
"expected `latest` to be truncated rather than interpolated verbatim, got: {:?}",
diagnostics[0].message()
);
}
#[test]
fn test_generate_diagnostics_from_cache_deprecation_sanitizes_bidi_and_caps_reason() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "left-pad".into(),
version_req: "1.3.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/package.json"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert("left-pad".into(), PackageVersions::latest_only("1.3.0"));
let resolved_versions = HashMap::new();
let overlong_reason = "X".repeat(MAX_DIAGNOSTIC_PROSE_CHARS + 50);
let outcomes = DependencyOutcomes::new().with_deprecation(
"left-pad",
Deprecation {
reason: Some(format!("bidi\u{202E}{overlong_reason}")),
replacement: Some("left-pad\u{202E}evil".to_string()),
},
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let deprecation_diag = diagnostics
.iter()
.find(|d| d.code() == Some(DEPRECATED_DIAGNOSTIC_CODE))
.expect("expected a deprecation diagnostic");
assert!(!deprecation_diag.message().contains('\u{202E}'));
assert!(deprecation_diag.message().contains('…'));
assert!(
!deprecation_diag.message().contains(&overlong_reason),
"expected the reason to be truncated rather than interpolated verbatim, got: {:?}",
deprecation_diag.message()
);
}
#[test]
fn test_generate_diagnostics_from_cache_fetch_failed_not_reported_as_unknown() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "flaky-pkg".into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 11)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let outcomes =
DependencyOutcomes::new().with_fetch_failure("flaky-pkg", FetchFailure::Transient);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert!(!diagnostics[0].message().contains("Unknown package"));
assert!(diagnostics[0].message().contains("Registry lookup failed"));
assert!(diagnostics[0].message().contains("flaky-pkg"));
}
#[test]
fn test_generate_diagnostics_from_cache_offline_suppresses_per_dependency_warning() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![
MockDep {
name: "flaky-pkg-a".into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 11)),
},
MockDep {
name: "flaky-pkg-b".into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(1, 10), Position::new(1, 20)),
name_range: Range::new(Position::new(1, 0), Position::new(1, 11)),
},
],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let outcomes = DependencyOutcomes::new()
.with_fetch_failure("flaky-pkg-a", FetchFailure::Transient)
.with_fetch_failure("flaky-pkg-b", FetchFailure::Transient);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_outcomes(&outcomes)
.with_network(crate::NetworkMode::Offline),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
!diagnostics
.iter()
.any(|d| d.message().contains("Registry lookup failed")),
"the per-dependency WARNING must not fire while offline, got: {diagnostics:?}"
);
let offline_diagnostics: Vec<_> = diagnostics
.iter()
.filter(|d| d.severity == Some(Severity::Information))
.filter(|d| d.message().to_lowercase().contains("offline"))
.collect();
assert_eq!(
offline_diagnostics.len(),
1,
"expected exactly one file-level offline diagnostic, not one per dependency; \
got: {diagnostics:?}"
);
}
#[test]
fn test_generate_diagnostics_from_cache_offline_signal_present_even_with_no_fetch_failures() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "=1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert("serde".into(), PackageVersions::latest_only("1.0.0"));
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_network(crate::NetworkMode::Offline),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics
.iter()
.any(|d| d.severity == Some(Severity::Information)
&& d.message().to_lowercase().contains("offline")),
"expected a file-level offline diagnostic even with zero fetch failures; \
got: {diagnostics:?}"
);
}
#[test]
fn test_generate_diagnostics_from_cache_skip_reason_notice_present_for_no_concrete_version() {
use crate::osv::{ScanOutcome, SkipReason, VulnerabilityMap};
use crate::position::{Position, Range};
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "^1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert("serde".into(), PackageVersions::latest_only("1.0.0"));
let resolved_versions = HashMap::new();
let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
vulns.insert(
crate::test_util::vuln_key("serde"),
ScanOutcome::Skipped(SkipReason::NoConcreteVersion),
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics
.iter()
.any(|d| d.severity == Some(Severity::Information)
&& d.message()
.contains("vulnerability data was not checked for 1 dependency")),
"expected a file-level not-checked diagnostic; got: {diagnostics:?}"
);
}
#[test]
fn test_generate_diagnostics_from_cache_skip_reason_notice_redacts_credential_in_name() {
use crate::osv::{ScanOutcome, SkipReason, VulnerabilityMap};
use crate::position::{Position, Range};
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: CREDENTIAL_SHAPED_NAME.into(),
version_req: "^1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
vulns.insert(
crate::test_util::vuln_key(CREDENTIAL_SHAPED_NAME),
ScanOutcome::Skipped(SkipReason::NoConcreteVersion),
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let notice = diagnostics
.iter()
.find(|d| {
d.message()
.contains("vulnerability data was not checked for")
})
.unwrap_or_else(|| panic!("expected the notice; got: {diagnostics:?}"));
let related = notice.related_information.as_deref().unwrap_or_default();
assert!(
related.iter().any(|r| r.message().contains("***@")),
"the redacted form must survive via related_information; got: {related:?}"
);
assert!(
related
.iter()
.all(|r| !r.message().contains("glpat-AAAABBBBCCCCDDDD")),
"the raw credential must never reach related_information; got: {related:?}"
);
}
#[test]
fn test_generate_diagnostics_from_cache_skip_reason_notice_omitted_while_offline() {
use crate::osv::{ScanOutcome, SkipReason, VulnerabilityMap};
use crate::position::{Position, Range};
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "^1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
vulns.insert(
crate::test_util::vuln_key("serde"),
ScanOutcome::Skipped(SkipReason::NoConcreteVersion),
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_vulnerabilities(&vulns)
.with_network(crate::NetworkMode::Offline),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
!diagnostics.iter().any(|d| d
.message()
.contains("vulnerability data was not checked for")),
"the reason-specific notice must not also render while offline; got: {diagnostics:?}"
);
}
#[test]
fn test_generate_diagnostics_from_cache_skip_reason_notice_ignores_non_registry_source() {
use crate::osv::{ScanOutcome, SkipReason, VulnerabilityMap};
use crate::position::{Position, Range};
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "path-pkg".into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 8)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
vulns.insert(
crate::test_util::vuln_key("path-pkg"),
ScanOutcome::Skipped(SkipReason::NonRegistrySource),
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
!diagnostics.iter().any(|d| d
.message()
.contains("vulnerability data was not checked for")),
"a NonRegistrySource skip must never contribute to the notice; got: {diagnostics:?}"
);
}
#[test]
fn test_generate_diagnostics_from_cache_skip_reason_notice_ignores_unmappable_reasons() {
use crate::osv::{ScanOutcome, SkipReason, VulnerabilityMap};
use crate::position::{Position, Range};
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![
MockDep {
name: "jsr-pkg".into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 7)),
},
MockDep {
name: "unmapped-eco-pkg".into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(1, 10), Position::new(1, 20)),
name_range: Range::new(Position::new(1, 0), Position::new(1, 17)),
},
],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
vulns.insert(
crate::test_util::vuln_key("jsr-pkg"),
ScanOutcome::Skipped(SkipReason::UnmappableName),
);
vulns.insert(
crate::test_util::vuln_key("unmapped-eco-pkg"),
ScanOutcome::Skipped(SkipReason::UnmappableEcosystem),
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
!diagnostics.iter().any(|d| d
.message()
.contains("vulnerability data was not checked for")),
"UnmappableName/UnmappableEcosystem must never contribute to the notice; \
got: {diagnostics:?}"
);
}
fn skip_notice_present(reason: crate::osv::SkipReason) -> bool {
use crate::osv::{ScanOutcome, VulnerabilityMap};
use crate::position::{Position, Range};
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "pkg".into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 3)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
vulns.insert(
crate::test_util::vuln_key("pkg"),
ScanOutcome::Skipped(reason),
);
generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
&MOCK_FORMATTER,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
)
.iter()
.any(|d| {
d.message()
.contains("vulnerability data was not checked for")
})
}
#[test]
fn test_skip_reason_notice_for_local_matching_reasons() {
use crate::osv::SkipReason;
assert!(skip_notice_present(SkipReason::UnevaluableAdvisoryRange));
assert!(!skip_notice_present(SkipReason::UnmatchableVersion));
}
#[test]
fn test_generate_diagnostics_from_cache_skip_reason_notice_counts_two_same_reason() {
use crate::osv::{ScanOutcome, SkipReason, VulnerabilityMap};
use crate::position::{Position, Range};
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![
MockDep {
name: "pkg-a".into(),
version_req: "^1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
},
MockDep {
name: "pkg-b".into(),
version_req: "^1.0.0".into(),
version_range: Range::new(Position::new(1, 10), Position::new(1, 20)),
name_range: Range::new(Position::new(1, 0), Position::new(1, 5)),
},
],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
vulns.insert(
crate::test_util::vuln_key("pkg-a"),
ScanOutcome::Skipped(SkipReason::NoConcreteVersion),
);
vulns.insert(
crate::test_util::vuln_key("pkg-b"),
ScanOutcome::Skipped(SkipReason::NoConcreteVersion),
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let notice = diagnostics
.iter()
.find(|d| {
d.message()
.contains("vulnerability data was not checked for")
})
.unwrap_or_else(|| panic!("expected the notice; got: {diagnostics:?}"));
assert!(
notice.message().contains("2 dependencies"),
"two distinct dependencies with the same reason must both count; got: {}",
notice.message()
);
let related = notice.related_information.as_deref().unwrap_or_default();
assert!(
related.iter().any(|r| r.message().contains("pkg-a"))
&& related.iter().any(|r| r.message().contains("pkg-b")),
"both dependency names must survive via related_information; got: {related:?}"
);
}
#[test]
fn test_generate_diagnostics_from_cache_skip_reason_notice_resolved_tag_not_full_version() {
use crate::osv::{ScanOutcome, SkipReason, VulnerabilityMap};
use crate::position::{Position, Range};
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "pkg-a".into(),
version_req: "^1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
vulns.insert(
crate::test_util::vuln_key("pkg-a"),
ScanOutcome::Skipped(SkipReason::ResolvedTagNotFullVersion),
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let notice = diagnostics
.iter()
.find(|d| {
d.message()
.contains("vulnerability data was not checked for")
})
.unwrap_or_else(|| panic!("expected the notice; got: {diagnostics:?}"));
assert!(
notice
.message()
.contains("the resolved tag is not a full version")
&& !notice.message().contains("no resolved or exact version"),
"got: {}",
notice.message()
);
}
#[test]
fn test_generate_diagnostics_from_cache_skip_reason_notice_canonical_name_unconfirmed() {
use crate::osv::{ScanOutcome, SkipReason, VulnerabilityMap};
use crate::position::{Position, Range};
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "pkg-a".into(),
version_req: "^1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
vulns.insert(
crate::test_util::vuln_key("pkg-a"),
ScanOutcome::Skipped(SkipReason::CanonicalNameUnconfirmed),
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let notice = diagnostics
.iter()
.find(|d| {
d.message()
.contains("vulnerability data was not checked for")
})
.unwrap_or_else(|| panic!("expected the notice; got: {diagnostics:?}"));
assert!(
notice
.message()
.contains("canonical name has not been confirmed")
&& !notice.message().contains("no resolved or exact version"),
"got: {}",
notice.message()
);
}
#[test]
fn test_generate_diagnostics_from_cache_skip_reason_notice_joins_distinct_reasons() {
use crate::osv::{ScanOutcome, SkipReason, VulnerabilityMap};
use crate::position::{Position, Range};
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![
MockDep {
name: "pkg-a".into(),
version_req: "^1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
},
MockDep {
name: "pkg-b".into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(1, 10), Position::new(1, 20)),
name_range: Range::new(Position::new(1, 0), Position::new(1, 5)),
},
],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
vulns.insert(
crate::test_util::vuln_key("pkg-a"),
ScanOutcome::Skipped(SkipReason::NoConcreteVersion),
);
vulns.insert(
crate::test_util::vuln_key("pkg-b"),
ScanOutcome::Skipped(SkipReason::QueryFailed),
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let notice = diagnostics
.iter()
.find(|d| {
d.message()
.contains("vulnerability data was not checked for")
})
.unwrap_or_else(|| panic!("expected the notice; got: {diagnostics:?}"));
assert!(
notice.message().contains("2 dependencies")
&& notice
.message()
.contains("no resolved or exact version was available to query")
&& notice.message().contains("the OSV.dev query failed"),
"both distinct reasons must be present in the message; got: {}",
notice.message()
);
}
#[test]
fn test_generate_diagnostics_from_cache_skip_reason_notice_ignores_stale_map_entry() {
use crate::osv::{ScanOutcome, SkipReason, VulnerabilityMap};
use crate::position::{Position, Range};
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "current-pkg".into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 11)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
vulns.insert(
crate::test_util::vuln_key("removed-pkg"),
ScanOutcome::Skipped(SkipReason::NoConcreteVersion),
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
!diagnostics.iter().any(|d| d
.message()
.contains("vulnerability data was not checked for")),
"a stale map entry for a removed dependency must never surface the notice; \
got: {diagnostics:?}"
);
}
#[test]
fn test_generate_diagnostics_from_cache_skip_reason_notice_caps_related_information() {
use crate::osv::{ScanOutcome, SkipReason, VulnerabilityMap};
use crate::position::{Position, Range};
let formatter = MOCK_FORMATTER;
let mut deps = Vec::new();
let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
for i in 0..10u32 {
let name = format!("pkg-{i}");
deps.push(MockDep {
name: name.as_str().into(),
version_req: "^1.0.0".into(),
version_range: Range::new(Position::new(i, 10), Position::new(i, 20)),
name_range: Range::new(Position::new(i, 0), Position::new(i, 5)),
});
vulns.insert(
crate::test_util::vuln_key(&name),
ScanOutcome::Skipped(SkipReason::NoConcreteVersion),
);
}
let parse_result = MockParseResult {
deps,
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let notice = diagnostics
.iter()
.find(|d| {
d.message()
.contains("vulnerability data was not checked for")
})
.unwrap_or_else(|| panic!("expected the notice; got: {diagnostics:?}"));
assert!(
notice.message().contains("10 dependencies"),
"all 10 skipped dependencies must be counted; got: {}",
notice.message()
);
let related = notice.related_information.as_deref().unwrap_or_default();
assert_eq!(
related.len(),
10,
"expected 9 named entries plus one trailing '+N more'; got: {related:?}"
);
assert!(
related
.last()
.is_some_and(|r| r.message().contains("1 more")),
"the trailing entry must report the 1 remaining dependency; got: {related:?}"
);
}
#[test]
fn test_generate_diagnostics_from_cache_fetch_failed_actionable_shows_hint() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "rate-limited-pkg".into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 16)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let outcomes = DependencyOutcomes::new().with_fetch_failure(
"rate-limited-pkg",
FetchFailure::Actionable("set GITHUB_TOKEN to increase the rate limit".to_string()),
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert!(!diagnostics[0].message().contains("Unknown package"));
assert!(
diagnostics[0]
.message()
.contains("set GITHUB_TOKEN to increase the rate limit")
);
assert!(diagnostics[0].message().contains("rate-limited-pkg"));
}
#[test]
fn test_generate_diagnostics_from_cache_multiple_fetch_failed_collapse_into_one_diagnostic() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let name_range_1 = Range::new(Position::new(0, 0), Position::new(0, 8));
let name_range_2 = Range::new(Position::new(1, 0), Position::new(1, 8));
let name_range_3 = Range::new(Position::new(2, 0), Position::new(2, 8));
let parse_result = MockParseResult {
deps: vec![
MockDep {
name: "flaky-1".into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: name_range_1,
},
MockDep {
name: "flaky-2".into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(1, 10), Position::new(1, 20)),
name_range: name_range_2,
},
MockDep {
name: "flaky-3".into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(2, 10), Position::new(2, 20)),
name_range: name_range_3,
},
],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let outcomes = DependencyOutcomes::new()
.with_fetch_failure("flaky-1", FetchFailure::Transient)
.with_fetch_failure("flaky-2", FetchFailure::Transient)
.with_fetch_failure("flaky-3", FetchFailure::Transient);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(
diagnostics.len(),
1,
"3 fetch-failed dependencies must collapse into exactly one diagnostic, got: {diagnostics:?}"
);
assert!(
diagnostics[0]
.message()
.contains("Registry lookup failed for 3 packages")
);
assert_eq!(
diagnostics[0].range, name_range_1,
"the combined diagnostic must sit on the first failing dependency's range"
);
let related_information = diagnostics[0]
.related_information
.as_ref()
.expect("collapsed diagnostic must carry related_information for the dropped deps");
assert_eq!(
related_information.len(),
2,
"expected one related_information entry per additional failing dependency (n - 1)"
);
assert_eq!(related_information[0].range, name_range_2);
assert_eq!(related_information[0].uri, parse_result.uri().clone());
assert!(related_information[0].message().contains("flaky-2"));
assert_eq!(related_information[1].range, name_range_3);
assert_eq!(related_information[1].uri, parse_result.uri().clone());
assert!(related_information[1].message().contains("flaky-3"));
}
#[test]
fn test_generate_diagnostics_from_cache_two_fetch_failed_collapse_boundary() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let name_range_1 = Range::new(Position::new(0, 0), Position::new(0, 8));
let name_range_2 = Range::new(Position::new(1, 0), Position::new(1, 8));
let parse_result = MockParseResult {
deps: vec![
MockDep {
name: "flaky-1".into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: name_range_1,
},
MockDep {
name: "flaky-2".into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(1, 10), Position::new(1, 20)),
name_range: name_range_2,
},
],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let outcomes = DependencyOutcomes::new()
.with_fetch_failure("flaky-1", FetchFailure::Transient)
.with_fetch_failure("flaky-2", FetchFailure::Transient);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(
diagnostics.len(),
1,
"2 fetch-failed dependencies must already collapse into one diagnostic, got: {diagnostics:?}"
);
assert!(
diagnostics[0]
.message()
.contains("Registry lookup failed for 2 packages")
);
let related_information = diagnostics[0]
.related_information
.as_ref()
.expect("the n==2 collapse must already carry related_information");
assert_eq!(
related_information.len(),
1,
"n==2 collapse must carry exactly one related_information entry (n - 1)"
);
assert_eq!(related_information[0].range, name_range_2);
assert_eq!(related_information[0].uri, parse_result.uri().clone());
assert!(related_information[0].message().contains("flaky-2"));
}
#[test]
fn test_generate_diagnostics_from_cache_multiple_fetch_failed_shared_actionable_hint() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let name_range_1 = Range::new(Position::new(0, 0), Position::new(0, 8));
let name_range_2 = Range::new(Position::new(1, 0), Position::new(1, 8));
let name_range_3 = Range::new(Position::new(2, 0), Position::new(2, 8));
let parse_result = MockParseResult {
deps: vec![
MockDep {
name: "flaky-1".into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: name_range_1,
},
MockDep {
name: "flaky-2".into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(1, 10), Position::new(1, 20)),
name_range: name_range_2,
},
MockDep {
name: "flaky-3".into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(2, 10), Position::new(2, 20)),
name_range: name_range_3,
},
],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let shared_hint = "set GITHUB_TOKEN to increase the rate limit".to_string();
let outcomes = DependencyOutcomes::new()
.with_fetch_failure("flaky-1", FetchFailure::Actionable(shared_hint.clone()))
.with_fetch_failure("flaky-2", FetchFailure::Actionable(shared_hint.clone()))
.with_fetch_failure("flaky-3", FetchFailure::Actionable(shared_hint.clone()));
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(
diagnostics.len(),
1,
"3 fetch-failed dependencies must still collapse into exactly one diagnostic, got: {diagnostics:?}"
);
assert!(
diagnostics[0].message().contains(&shared_hint),
"collapsed diagnostic must surface the shared actionable hint, got: {}",
diagnostics[0].message()
);
assert!(
!diagnostics[0]
.message()
.contains("package status could not be determined"),
"the actionable hint must replace, not accompany, the generic fallback message"
);
let related_information = diagnostics[0]
.related_information
.as_ref()
.expect("collapsed diagnostic must carry related_information for the dropped deps");
assert_eq!(
related_information.len(),
2,
"expected one related_information entry per additional failing dependency (n - 1)"
);
}
#[test]
fn test_generate_diagnostics_from_cache_fetch_failed_transient_shows_generic_message() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "transient-pkg".into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 13)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let outcomes =
DependencyOutcomes::new().with_fetch_failure("transient-pkg", FetchFailure::Transient);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert!(!diagnostics[0].message().contains("Unknown package"));
assert!(
diagnostics[0]
.message()
.contains("package status could not be determined")
);
}
#[test]
fn test_generate_diagnostics_from_cache_fetch_failed_not_attempted_shows_generic_message() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "collided-pkg".into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 12)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let outcomes = DependencyOutcomes::new()
.with_fetch_failure("collided-pkg", FetchFailure::NotAttempted);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert!(!diagnostics[0].message().contains("Unknown package"));
assert!(
diagnostics[0]
.message()
.contains("package status could not be determined")
);
}
#[test]
fn test_generate_diagnostics_from_cache_fetch_failed_does_not_mask_invalid_name() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = RejectingFormatter;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "bad name".into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 11)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let outcomes =
DependencyOutcomes::new().with_fetch_failure("bad name", FetchFailure::Transient);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert!(diagnostics[0].message().contains("Invalid package name"));
}
#[test]
fn test_generate_diagnostics_from_cache_invalid_package_name() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = RejectingFormatter;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "bad-pkg".into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 7)),
}],
uri: crate::test_util::test_uri("/test/package.json"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].severity, Some(Severity::Warning));
assert!(diagnostics[0].message().starts_with("Invalid package name"));
assert!(!diagnostics[0].message().contains("Unknown package"));
}
#[test]
fn test_generate_diagnostics_from_cache_outdated_version() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "1.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert("serde".into(), PackageVersions::latest_only("2.0.0"));
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].severity, Some(Severity::Hint));
assert!(diagnostics[0].message().contains("Newer version available"));
assert!(diagnostics[0].message().contains("2.0.0"));
}
#[test]
fn test_generate_diagnostics_from_cache_outdated_within_cooldown_appends_context() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "1.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "2.0.0".into(),
available: Arc::from(vec!["2.0.0".into()]),
yanked: Arc::from(Vec::new()),
published_at: Some(PublishTime::from_unix_secs(
PublishTime::now().as_unix_secs() - 60 * 60,
)),
gossip_excluded_version: None,
cooldown_fallback: None,
},
);
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].severity, Some(Severity::Hint));
assert_eq!(
diagnostics[0].message(),
"Newer version available: 2.0.0 (published 1 hour ago — still within the release cooldown window)"
);
assert!(!diagnostics[0].message().contains("ago ago"));
}
#[test]
fn test_generate_diagnostics_from_cache_outdated_gossip_cooldown_takes_precedence() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "1.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "2.0.0".into(),
available: Arc::from(vec!["2.0.0".into()]),
yanked: Arc::from(Vec::new()),
published_at: Some(PublishTime::from_unix_secs(
PublishTime::now().as_unix_secs() - 30 * 24 * 60 * 60,
)),
gossip_excluded_version: None,
cooldown_fallback: None,
},
);
let resolved_versions = HashMap::new();
let mut gossip = HashMap::new();
gossip.insert(
PackageName::new("serde"),
crate::GossipFindings {
version: "2.0.0".to_string(),
cooldown: Some(crate::GossipCooldown {
end: PublishTime::from_unix_secs(PublishTime::now().as_unix_secs() + 1_000),
risk: crate::GossipRiskLevel::High,
}),
low_usage: None,
},
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_gossip_prefetch(&gossip),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert!(
diagnostics[0].message().contains("deps.dev/GOSSIP"),
"got: {}",
diagnostics[0].message()
);
}
#[test]
fn test_generate_diagnostics_from_cache_outdated_gossip_version_mismatch_falls_back() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "1.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "2.0.0".into(),
available: Arc::from(vec!["2.0.0".into()]),
yanked: Arc::from(Vec::new()),
published_at: Some(PublishTime::from_unix_secs(
PublishTime::now().as_unix_secs() - 60 * 60,
)),
gossip_excluded_version: None,
cooldown_fallback: None,
},
);
let resolved_versions = HashMap::new();
let mut gossip = HashMap::new();
gossip.insert(
PackageName::new("serde"),
crate::GossipFindings {
version: "1.9.0".to_string(),
cooldown: Some(crate::GossipCooldown {
end: PublishTime::from_unix_secs(PublishTime::now().as_unix_secs() + 1_000),
risk: crate::GossipRiskLevel::High,
}),
low_usage: None,
},
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_gossip_prefetch(&gossip),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert!(!diagnostics[0].message().contains("deps.dev/GOSSIP"));
assert!(
diagnostics[0]
.message()
.contains("still within the release cooldown window"),
"must fall back to the local heuristic unchanged: {}",
diagnostics[0].message()
);
}
#[test]
fn test_generate_diagnostics_outdated_latest_flagged_malicious_renders_error() {
use crate::osv::{Capped, LatestStatusMap, UpgradeStatus, VulnSeverity};
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "feed-widget-helper".into(),
version_req: "1.0.4".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/package.json"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert(
"feed-widget-helper".into(),
PackageVersions {
latest: "1.0.8".into(),
available: Arc::from(vec!["1.0.8".into()]),
yanked: Arc::from(Vec::new()),
published_at: None,
gossip_excluded_version: None,
cooldown_fallback: None,
},
);
let resolved_versions = HashMap::new();
let mut latest_status = LatestStatusMap::new();
latest_status.insert(
crate::test_util::vuln_key("feed-widget-helper"),
UpgradeStatus::CandidateVulnerable {
version: ConcreteVersion::new("1.0.8"),
advisory_ids: Capped::new(vec!["MAL-2026-16332".to_string()], 1),
worst_severity: Some(VulnSeverity::Malicious),
},
);
let mut gossip = HashMap::new();
gossip.insert(
PackageName::new("feed-widget-helper"),
crate::GossipFindings {
version: "1.0.8".to_string(),
cooldown: Some(crate::GossipCooldown {
end: PublishTime::from_unix_secs(PublishTime::now().as_unix_secs() + 1_000),
risk: crate::GossipRiskLevel::High,
}),
low_usage: None,
},
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_latest_status(&latest_status)
.with_gossip_prefetch(&gossip),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1, "{diagnostics:?}");
assert_eq!(diagnostics[0].severity, Some(Severity::Error));
assert!(
diagnostics[0].message().contains("flagged by OSV"),
"got: {}",
diagnostics[0].message()
);
assert!(diagnostics[0].message().contains("MAL-2026-16332"));
assert!(
!diagnostics[0].message().contains("cooldown"),
"a confirmed-malicious latest must never read as a benign cooldown notice: {}",
diagnostics[0].message()
);
}
#[test]
fn test_generate_diagnostics_outdated_latest_flagged_non_malicious_renders_warning() {
use crate::osv::{Capped, LatestStatusMap, UpgradeStatus, VulnSeverity};
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "pkg".into(),
version_req: "1.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert(
"pkg".into(),
PackageVersions {
latest: "2.0.0".into(),
available: Arc::from(vec!["2.0.0".into()]),
yanked: Arc::from(Vec::new()),
published_at: None,
gossip_excluded_version: None,
cooldown_fallback: None,
},
);
let resolved_versions = HashMap::new();
let mut latest_status = LatestStatusMap::new();
latest_status.insert(
crate::test_util::vuln_key("pkg"),
UpgradeStatus::CandidateVulnerable {
version: ConcreteVersion::new("2.0.0"),
advisory_ids: Capped::new(vec!["GHSA-xxxx".to_string()], 1),
worst_severity: Some(VulnSeverity::High),
},
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_latest_status(&latest_status),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1, "{diagnostics:?}");
assert_eq!(diagnostics[0].severity, Some(Severity::Warning));
assert!(diagnostics[0].message().contains("flagged by OSV"));
}
#[test]
fn test_generate_diagnostics_up_to_date_admitted_latest_flagged_malicious_renders_error() {
use crate::osv::{Capped, LatestStatusMap, UpgradeStatus, VulnSeverity};
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "feed-widget-helper".into(),
version_req: "^1.0.4".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/package.json"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert(
"feed-widget-helper".into(),
PackageVersions {
latest: "1.0.8".into(),
available: Arc::from(vec!["1.0.8".into()]),
yanked: Arc::from(Vec::new()),
published_at: None,
gossip_excluded_version: None,
cooldown_fallback: None,
},
);
let resolved_versions = HashMap::new();
let mut latest_status = LatestStatusMap::new();
latest_status.insert(
crate::test_util::vuln_key("feed-widget-helper"),
UpgradeStatus::CandidateVulnerable {
version: ConcreteVersion::new("1.0.8"),
advisory_ids: Capped::new(vec!["MAL-2026-16332".to_string()], 1),
worst_severity: Some(VulnSeverity::Malicious),
},
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_latest_status(&latest_status),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1, "{diagnostics:?}");
assert_eq!(diagnostics[0].severity, Some(Severity::Error));
assert!(
diagnostics[0].message().contains("already admits"),
"got: {}",
diagnostics[0].message()
);
assert!(diagnostics[0].message().contains("1.0.8"));
assert!(diagnostics[0].message().contains("MAL-2026-16332"));
}
#[test]
fn test_generate_diagnostics_up_to_date_admitted_latest_flagged_non_malicious_renders_warning()
{
use crate::osv::{Capped, LatestStatusMap, UpgradeStatus, VulnSeverity};
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "pkg".into(),
version_req: "^1.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert("pkg".into(), PackageVersions::latest_only("1.5.0"));
let resolved_versions = HashMap::new();
let mut latest_status = LatestStatusMap::new();
latest_status.insert(
crate::test_util::vuln_key("pkg"),
UpgradeStatus::CandidateVulnerable {
version: ConcreteVersion::new("1.5.0"),
advisory_ids: Capped::new(vec!["GHSA-yyyy".to_string()], 1),
worst_severity: Some(VulnSeverity::High),
},
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_latest_status(&latest_status),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1, "{diagnostics:?}");
assert_eq!(diagnostics[0].severity, Some(Severity::Warning));
assert!(diagnostics[0].message().contains("already admits"));
}
#[test]
fn test_generate_diagnostics_up_to_date_admitted_latest_flagged_dedups_when_r2_severity_covers_r7()
{
use crate::osv::{
Capped, DependencyVulnerabilities, LatestStatusMap, ScanOutcome, UpgradeStatus,
VulnSeverity, VulnerabilityMap,
};
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "feed-widget-helper".into(),
version_req: "^1.0.4".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/package.json"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert(
"feed-widget-helper".into(),
PackageVersions {
latest: "1.0.8".into(),
available: Arc::from(vec!["1.0.8".into()]),
yanked: Arc::from(Vec::new()),
published_at: None,
gossip_excluded_version: None,
cooldown_fallback: None,
},
);
let mut resolved_versions = HashMap::new();
resolved_versions.insert(PackageName::new("feed-widget-helper"), "1.0.8".into());
let mut latest_status = LatestStatusMap::new();
latest_status.insert(
crate::test_util::vuln_key("feed-widget-helper"),
UpgradeStatus::CandidateVulnerable {
version: ConcreteVersion::new("1.0.8"),
advisory_ids: Capped::new(vec!["GHSA-xxxx".to_string()], 1),
worst_severity: Some(VulnSeverity::High),
},
);
let mut vulnerabilities: VulnerabilityMap = VulnerabilityMap::new();
vulnerabilities.insert(
crate::test_util::vuln_key("feed-widget-helper"),
ScanOutcome::Vulnerable(DependencyVulnerabilities {
sibling_matches: None,
advisories: Capped::new(vec![sample_advisory("GHSA-xxxx", VulnSeverity::High)], 1),
fix_target_status: UpgradeStatus::NotChecked,
}),
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_latest_status(&latest_status)
.with_vulnerabilities(&vulnerabilities)
.with_ecosystem(EcosystemId::Npm),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(
diagnostics.len(),
1,
"R2's advisory diagnostic alone, not duplicated by R7: {diagnostics:?}"
);
assert_eq!(
diagnostics[0].severity,
Some(Severity::Warning),
"R2's own severity must be preserved, not silently changed by dedup: {diagnostics:?}"
);
assert!(diagnostics[0].message().contains("GHSA-xxxx"));
assert!(
!diagnostics[0].message().contains("already admits"),
"R7's admits-flagged-latest wording must not also appear: {}",
diagnostics[0].message()
);
}
#[test]
fn test_generate_diagnostics_up_to_date_admitted_latest_flagged_keeps_both_when_r2_severity_is_lower()
{
use crate::osv::{
Capped, DependencyVulnerabilities, LatestStatusMap, ScanOutcome, UpgradeStatus,
VulnSeverity, VulnerabilityMap,
};
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "feed-widget-helper".into(),
version_req: "^1.0.4".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/package.json"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert(
"feed-widget-helper".into(),
PackageVersions {
latest: "1.0.8".into(),
available: Arc::from(vec!["1.0.8".into()]),
yanked: Arc::from(Vec::new()),
published_at: None,
gossip_excluded_version: None,
cooldown_fallback: None,
},
);
let mut resolved_versions = HashMap::new();
resolved_versions.insert(PackageName::new("feed-widget-helper"), "1.0.8".into());
let mut latest_status = LatestStatusMap::new();
latest_status.insert(
crate::test_util::vuln_key("feed-widget-helper"),
UpgradeStatus::CandidateVulnerable {
version: ConcreteVersion::new("1.0.8"),
advisory_ids: Capped::new(vec!["MAL-2026-16332".to_string()], 1),
worst_severity: Some(VulnSeverity::Malicious),
},
);
let mut vulnerabilities: VulnerabilityMap = VulnerabilityMap::new();
vulnerabilities.insert(
crate::test_util::vuln_key("feed-widget-helper"),
ScanOutcome::Vulnerable(DependencyVulnerabilities {
sibling_matches: None,
advisories: Capped::new(
vec![sample_advisory("MAL-2026-16332", VulnSeverity::Malicious)],
1,
),
fix_target_status: UpgradeStatus::NotChecked,
}),
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_latest_status(&latest_status)
.with_vulnerabilities(&vulnerabilities)
.with_ecosystem(EcosystemId::Npm),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let r2_diag = diagnostics
.iter()
.find(|d| {
d.message().contains("MAL-2026-16332") && !d.message().contains("already admits")
})
.expect("R2's advisory finding must still fire");
assert_eq!(r2_diag.severity, Some(Severity::Warning));
let r7_diag = diagnostics
.iter()
.find(|d| d.message().contains("already admits"))
.expect("R7's finding must not be suppressed — it carries the stronger Error severity");
assert_eq!(
r7_diag.severity,
Some(Severity::Error),
"the malicious finding's Error severity must not be silently downgraded to Warning"
);
}
#[test]
fn test_generate_diagnostics_up_to_date_admitted_latest_flagged_keeps_both_when_in_use_differs()
{
use crate::osv::{
Capped, DependencyVulnerabilities, LatestStatusMap, ScanOutcome, UpgradeStatus,
VulnSeverity, VulnerabilityMap,
};
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "feed-widget-helper".into(),
version_req: "^1.0.4".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/package.json"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert(
"feed-widget-helper".into(),
PackageVersions {
latest: "1.0.8".into(),
available: Arc::from(vec!["1.0.8".into()]),
yanked: Arc::from(Vec::new()),
published_at: None,
gossip_excluded_version: None,
cooldown_fallback: None,
},
);
let mut resolved_versions = HashMap::new();
resolved_versions.insert(PackageName::new("feed-widget-helper"), "1.0.4".into());
let mut latest_status = LatestStatusMap::new();
latest_status.insert(
crate::test_util::vuln_key("feed-widget-helper"),
UpgradeStatus::CandidateVulnerable {
version: ConcreteVersion::new("1.0.8"),
advisory_ids: Capped::new(vec!["MAL-2026-16332".to_string()], 1),
worst_severity: Some(VulnSeverity::Malicious),
},
);
let mut vulnerabilities: VulnerabilityMap = VulnerabilityMap::new();
vulnerabilities.insert(
crate::test_util::vuln_key("feed-widget-helper"),
ScanOutcome::Vulnerable(DependencyVulnerabilities {
sibling_matches: None,
advisories: Capped::new(
vec![sample_advisory("RUSTSEC-2020-0071", VulnSeverity::High)],
1,
),
fix_target_status: UpgradeStatus::NotChecked,
}),
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_latest_status(&latest_status)
.with_vulnerabilities(&vulnerabilities)
.with_ecosystem(EcosystemId::Npm),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics
.iter()
.any(|d| d.message().contains("RUSTSEC-2020-0071")),
"R2's finding on the in-use version must still fire: {diagnostics:?}"
);
assert!(
diagnostics
.iter()
.any(|d| d.message().contains("already admits")),
"R7's finding on the flagged latest must still fire too: {diagnostics:?}"
);
}
#[test]
fn test_generate_diagnostics_up_to_date_admitted_latest_query_failed_surfaces_hint() {
use crate::osv::{LatestStatusMap, SkipReason, UpgradeStatus};
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "pkg".into(),
version_req: "^1.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert("pkg".into(), PackageVersions::latest_only("1.5.0"));
let resolved_versions = HashMap::new();
let mut latest_status = LatestStatusMap::new();
latest_status.insert(
crate::test_util::vuln_key("pkg"),
UpgradeStatus::CandidateUnverified {
version: ConcreteVersion::new("1.5.0"),
reason: SkipReason::QueryFailed,
},
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_latest_status(&latest_status),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1, "{diagnostics:?}");
assert_eq!(diagnostics[0].severity, Some(Severity::Hint));
assert!(diagnostics[0].message().contains("already admits"));
assert!(diagnostics[0].message().contains("1.5.0"));
assert!(
diagnostics[0].message().contains("OSV.dev query failed"),
"got: {}",
diagnostics[0].message()
);
}
#[test]
fn test_generate_diagnostics_up_to_date_admitted_latest_stale_query_failed_entry_stays_silent()
{
use crate::osv::{LatestStatusMap, SkipReason, UpgradeStatus};
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "pkg".into(),
version_req: "^1.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert("pkg".into(), PackageVersions::latest_only("1.6.0"));
let resolved_versions = HashMap::new();
let mut latest_status = LatestStatusMap::new();
latest_status.insert(
crate::test_util::vuln_key("pkg"),
UpgradeStatus::CandidateUnverified {
version: ConcreteVersion::new("1.5.0"),
reason: SkipReason::QueryFailed,
},
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_latest_status(&latest_status),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(diagnostics.is_empty(), "{diagnostics:?}");
}
#[test]
fn test_generate_diagnostics_up_to_date_vulnerable_in_use_and_unverified_latest_both_fire() {
use crate::osv::{
Capped, DependencyVulnerabilities, LatestStatusMap, ScanOutcome, SkipReason,
UpgradeStatus, VulnSeverity, VulnerabilityMap,
};
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "pkg".into(),
version_req: "^1.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert("pkg".into(), PackageVersions::latest_only("1.5.0"));
let mut resolved_versions = HashMap::new();
resolved_versions.insert(PackageName::new("pkg"), "1.2.0".into());
let mut latest_status = LatestStatusMap::new();
latest_status.insert(
crate::test_util::vuln_key("pkg"),
UpgradeStatus::CandidateUnverified {
version: ConcreteVersion::new("1.5.0"),
reason: SkipReason::QueryFailed,
},
);
let mut vulnerabilities: VulnerabilityMap = VulnerabilityMap::new();
vulnerabilities.insert(
crate::test_util::vuln_key("pkg"),
ScanOutcome::Vulnerable(DependencyVulnerabilities {
sibling_matches: None,
advisories: Capped::new(
vec![sample_advisory("RUSTSEC-2020-0071", VulnSeverity::High)],
1,
),
fix_target_status: UpgradeStatus::NotChecked,
}),
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_latest_status(&latest_status)
.with_vulnerabilities(&vulnerabilities)
.with_ecosystem(EcosystemId::Cargo),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics
.iter()
.any(|d| d.message().contains("RUSTSEC-2020-0071")),
"R2's finding on the in-use version must fire: {diagnostics:?}"
);
assert!(
diagnostics
.iter()
.any(|d| d.severity == Some(Severity::Hint)
&& d.message().contains("already admits")),
"R7's Hint on the separately-failed latest check must also fire: {diagnostics:?}"
);
}
#[test]
fn test_generate_diagnostics_up_to_date_admitted_latest_not_yet_checked_stays_silent() {
use crate::osv::LatestStatusMap;
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "pkg".into(),
version_req: "^1.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert("pkg".into(), PackageVersions::latest_only("1.5.0"));
let resolved_versions = HashMap::new();
let latest_status = LatestStatusMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_latest_status(&latest_status),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(diagnostics.is_empty(), "{diagnostics:?}");
}
#[test]
fn test_generate_diagnostics_up_to_date_admitted_latest_structural_skip_stays_silent() {
use crate::osv::{LatestStatusMap, SkipReason, UpgradeStatus};
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "pkg".into(),
version_req: "^1.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert("pkg".into(), PackageVersions::latest_only("1.5.0"));
let resolved_versions = HashMap::new();
let mut latest_status = LatestStatusMap::new();
latest_status.insert(
crate::test_util::vuln_key("pkg"),
UpgradeStatus::CandidateUnverified {
version: ConcreteVersion::new("1.5.0"),
reason: SkipReason::UnmappableName,
},
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_latest_status(&latest_status),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(diagnostics.is_empty(), "{diagnostics:?}");
}
#[test]
fn test_generate_diagnostics_outdated_unverified_latest_appends_hint() {
use crate::osv::LatestStatusMap;
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "pkg".into(),
version_req: "1.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert(
"pkg".into(),
PackageVersions {
latest: "2.0.0".into(),
available: Arc::from(vec!["2.0.0".into()]),
yanked: Arc::from(Vec::new()),
published_at: None,
gossip_excluded_version: None,
cooldown_fallback: None,
},
);
let resolved_versions = HashMap::new();
let latest_status = LatestStatusMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_latest_status(&latest_status),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1, "{diagnostics:?}");
assert_eq!(
diagnostics[0].severity,
Some(DiagnosticSeverities::default().outdated)
);
assert!(
diagnostics[0]
.message()
.contains("not yet verified against OSV"),
"got: {}",
diagnostics[0].message()
);
}
#[test]
fn test_generate_diagnostics_from_cache_outdated_gossip_unavailable_falls_back_to_local() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "1.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "2.0.0".into(),
available: Arc::from(vec!["2.0.0".into()]),
yanked: Arc::from(Vec::new()),
published_at: Some(PublishTime::from_unix_secs(
PublishTime::now().as_unix_secs() - 60 * 60,
)),
gossip_excluded_version: None,
cooldown_fallback: None,
},
);
let resolved_versions = HashMap::new();
let mut gossip = HashMap::new();
gossip.insert(
PackageName::new("serde"),
crate::GossipFindings {
version: "2.0.0".to_string(),
cooldown: None,
low_usage: None,
},
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_gossip_prefetch(&gossip),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert!(
diagnostics[0]
.message()
.contains("still within the release cooldown window"),
"got: {}",
diagnostics[0].message()
);
}
#[test]
fn test_generate_diagnostics_from_cache_outdated_outside_cooldown_plain_message() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "1.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "2.0.0".into(),
available: Arc::from(vec!["2.0.0".into()]),
yanked: Arc::from(Vec::new()),
published_at: Some(PublishTime::from_unix_secs(
PublishTime::now().as_unix_secs() - 10 * 24 * 60 * 60,
)),
gossip_excluded_version: None,
cooldown_fallback: None,
},
);
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].message(), "Newer version available: 2.0.0");
}
#[test]
fn test_generate_diagnostics_from_cache_outdated_freshness_disabled_plain_message() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "1.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "2.0.0".into(),
available: Arc::from(vec!["2.0.0".into()]),
yanked: Arc::from(Vec::new()),
published_at: Some(PublishTime::from_unix_secs(
PublishTime::now().as_unix_secs() - 60 * 60,
)),
gossip_excluded_version: None,
cooldown_fallback: None,
},
);
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::Disabled,
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].message(), "Newer version available: 2.0.0");
}
#[test]
fn test_generate_diagnostics_from_cache_outdated_cooldown_boundary_is_exclusive() {
use crate::position::{Position, Range};
use std::collections::HashMap;
const COOLDOWN_SECS: u64 = 100;
let now = PublishTime::from_unix_secs(10_000);
let published_at_at_boundary =
PublishTime::from_unix_secs(10_000 - COOLDOWN_SECS.cast_signed());
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "1.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "2.0.0".into(),
available: Arc::from(vec!["2.0.0".into()]),
yanked: Arc::from(Vec::new()),
published_at: Some(published_at_at_boundary),
gossip_excluded_version: None,
cooldown_fallback: None,
},
);
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::Enabled {
cooldown: crate::CooldownWindow::from_secs(COOLDOWN_SECS),
},
DiagnosticSeverities::default(),
now,
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(
diagnostics[0].message(),
"Newer version available: 2.0.0",
"age exactly equal to the cooldown window must not be within cooldown"
);
}
#[test]
fn test_generate_diagnostics_from_cache_outdated_cooldown_boundary_one_second_inside() {
use crate::position::{Position, Range};
use std::collections::HashMap;
const COOLDOWN_SECS: u64 = 100;
let now = PublishTime::from_unix_secs(10_000);
let published_at_just_inside =
PublishTime::from_unix_secs(10_000 - (COOLDOWN_SECS.cast_signed() - 1));
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "1.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "2.0.0".into(),
available: Arc::from(vec!["2.0.0".into()]),
yanked: Arc::from(Vec::new()),
published_at: Some(published_at_just_inside),
gossip_excluded_version: None,
cooldown_fallback: None,
},
);
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::Enabled {
cooldown: crate::CooldownWindow::from_secs(COOLDOWN_SECS),
},
DiagnosticSeverities::default(),
now,
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(
diagnostics[0].message(),
"Newer version available: 2.0.0 (published 1 minute ago — still within the release cooldown window)",
"age == cooldown window - 1 must be within cooldown"
);
}
#[test]
fn test_generate_diagnostics_from_cache_up_to_date() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "^1.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert("serde".into(), PackageVersions::latest_only("1.0.214"));
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics.is_empty(),
"Expected no diagnostics for up-to-date dependency"
);
}
#[test]
fn test_generate_diagnostics_from_cache_multiple_deps() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![
MockDep {
name: "serde".into(),
version_req: "^1.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
},
MockDep {
name: "tokio".into(),
version_req: "1.0".into(),
version_range: Range::new(Position::new(1, 10), Position::new(1, 20)),
name_range: Range::new(Position::new(1, 0), Position::new(1, 5)),
},
MockDep {
name: "unknown".into(),
version_req: "1.0".into(),
version_range: Range::new(Position::new(2, 10), Position::new(2, 20)),
name_range: Range::new(Position::new(2, 0), Position::new(2, 7)),
},
],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert("serde".into(), PackageVersions::latest_only("1.0.214"));
cached_versions.insert("tokio".into(), PackageVersions::latest_only("2.0.0"));
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 2);
assert_eq!(diagnostics[0].message(), "Newer version available: 2.0.0");
assert_eq!(diagnostics[0].code(), None);
assert_eq!(diagnostics[1].message(), "Unknown package 'unknown'");
assert_eq!(diagnostics[1].code(), None);
}
#[test]
fn test_generate_diagnostics_from_cache_unresolved_emits_no_diagnostic() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MockUnresolvedFormatter;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "spring-boot-starter".into(),
version_req: "$missing".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/libs.versions.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert(
"spring-boot-starter".into(),
PackageVersions::latest_only("3.2.0"),
);
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics.is_empty(),
"Expected no diagnostics for an unresolved requirement, got: {diagnostics:?}"
);
}
#[test]
fn test_generate_diagnostics_from_cache_yanked_uses_configured_severity() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "1.0.5".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let outcomes =
DependencyOutcomes::new().with_yanked("serde", ("1.0.5".into(), RemovalStatus::Yanked));
let severities = DiagnosticSeverities {
yanked: Severity::Error,
..DiagnosticSeverities::default()
};
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
severities,
PublishTime::now(),
);
let yanked_diag = diagnostics
.iter()
.find(|d| d.message().starts_with(formatter.yanked_message()))
.expect("expected a yanked diagnostic");
assert_eq!(yanked_diag.severity, Some(Severity::Error));
assert!(yanked_diag.message().contains("1.0.5"));
}
#[test]
fn test_generate_diagnostics_from_cache_yanked_default_severity_unchanged() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "1.0.5".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let outcomes =
DependencyOutcomes::new().with_yanked("serde", ("1.0.5".into(), RemovalStatus::Yanked));
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let yanked_diag = diagnostics
.iter()
.find(|d| d.message().starts_with(formatter.yanked_message()))
.expect("expected a yanked diagnostic");
assert_eq!(yanked_diag.severity, Some(Severity::Warning));
}
#[test]
fn test_generate_diagnostics_from_cache_no_yanked_map_emits_no_yanked_diagnostic() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "1.0.5".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert("serde".into(), PackageVersions::latest_only("1.0.5"));
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
!diagnostics
.iter()
.any(|d| d.message().starts_with(formatter.yanked_message())),
"Expected no yanked diagnostic when `yanked` is None, got: {diagnostics:?}"
);
}
#[test]
fn test_generate_diagnostics_from_cache_yanked_and_outdated_both_emitted() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "1.0.5".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert("serde".into(), PackageVersions::latest_only("2.0.0"));
let resolved_versions = HashMap::new();
let outcomes =
DependencyOutcomes::new().with_yanked("serde", ("1.0.5".into(), RemovalStatus::Yanked));
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(
diagnostics.len(),
2,
"expected both diagnostics: {diagnostics:?}"
);
assert_eq!(
diagnostics[0].message(),
format!("{} (1.0.5)", formatter.yanked_message())
);
assert_eq!(diagnostics[0].code(), None);
assert_eq!(diagnostics[1].message(), "Newer version available: 2.0.0");
assert_eq!(diagnostics[1].code(), None);
}
#[test]
fn test_generate_diagnostics_from_cache_deprecation_suppresses_advisory_deprecated_yanked_finding()
{
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "left-pad".into(),
version_req: "1.3.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/package.json"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert("left-pad".into(), PackageVersions::latest_only("1.3.0"));
let resolved_versions = HashMap::new();
let outcomes = DependencyOutcomes::new()
.with_yanked(
"left-pad",
("1.3.0".into(), RemovalStatus::AdvisoryDeprecated),
)
.with_deprecation(
"left-pad",
Deprecation {
reason: Some("use String.prototype.padStart()".to_string()),
replacement: None,
},
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(
diagnostics.len(),
1,
"the deprecation diagnostic must suppress the AdvisoryDeprecated yanked \
finding, not double-report: {diagnostics:?}"
);
assert!(
diagnostics[0]
.message()
.starts_with(formatter.deprecated_message())
);
}
#[test]
fn test_generate_diagnostics_from_cache_deprecation_never_suppresses_real_yanked_finding() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "pkg".into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/pyproject.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert("pkg".into(), PackageVersions::latest_only("1.0.0"));
let resolved_versions = HashMap::new();
let outcomes = DependencyOutcomes::new()
.with_yanked("pkg", ("1.0.0".into(), RemovalStatus::Yanked))
.with_deprecation(
"pkg",
Deprecation {
reason: Some("project archived".to_string()),
replacement: None,
},
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(
diagnostics.len(),
2,
"a genuine Yanked finding must never be hidden behind a package-level \
deprecation notice: {diagnostics:?}"
);
assert_eq!(
diagnostics[0].message(),
format!("{}: project archived", formatter.deprecated_message())
);
assert_eq!(diagnostics[0].code(), Some(DEPRECATED_DIAGNOSTIC_CODE));
assert_eq!(
diagnostics[1].message(),
format!("{} (1.0.0)", formatter.yanked_message())
);
assert_eq!(diagnostics[1].code(), None);
}
#[test]
fn test_generate_diagnostics_from_cache_deprecation_suppression_is_severity_independent() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "left-pad".into(),
version_req: "1.3.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/package.json"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert("left-pad".into(), PackageVersions::latest_only("1.3.0"));
let resolved_versions = HashMap::new();
let outcomes = DependencyOutcomes::new()
.with_yanked(
"left-pad",
("1.3.0".into(), RemovalStatus::AdvisoryDeprecated),
)
.with_deprecation(
"left-pad",
Deprecation {
reason: Some("use String.prototype.padStart()".to_string()),
replacement: None,
},
);
let versions =
VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes);
let default_severities = DiagnosticSeverities::default();
let inverted_severities = DiagnosticSeverities {
deprecated: Severity::Hint,
yanked: Severity::Error,
..DiagnosticSeverities::default()
};
for severities in [default_severities, inverted_severities] {
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
versions,
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
severities,
PublishTime::now(),
);
assert_eq!(
diagnostics.len(),
1,
"suppression must not depend on severity configuration: {diagnostics:?}"
);
assert!(
diagnostics[0]
.message()
.starts_with(formatter.deprecated_message())
);
}
}
#[test]
fn test_generate_diagnostics_from_cache_deprecation_skipped_for_non_registry_sources() {
use crate::parser::DependencySource;
let mut cached_versions = HashMap::new();
cached_versions.insert("dep".into(), PackageVersions::latest_only("1.0.0"));
let resolved_versions = HashMap::new();
let outcomes = DependencyOutcomes::new().with_deprecation(
"dep",
Deprecation {
reason: Some("archived".to_string()),
replacement: None,
},
);
let uri = crate::test_util::test_uri("/test/Cargo.toml");
for source in [
DependencySource::Path {
path: "../local".into(),
},
DependencySource::Git {
url: "https://example.com/repo.git".into(),
rev: None,
},
DependencySource::Sdk {
sdk: "flutter".into(),
},
DependencySource::Workspace,
] {
let parse_result = SingleDepParseResult {
dep: NonRegistryDep(dep_at("dep"), source.clone()),
uri: uri.clone(),
};
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
&MOCK_FORMATTER,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics
.iter()
.all(|d| !d.message().starts_with(MOCK_FORMATTER.deprecated_message())),
"source {source:?} must never surface the deprecation diagnostic for a \
coincidentally-named registry package: {diagnostics:?}"
);
}
let registry_parse_result = SingleDepParseResult {
dep: dep_at("dep"),
uri,
};
let diagnostics = generate_diagnostics_from_cache(
®istry_parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
&MOCK_FORMATTER,
registry_parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics
.iter()
.any(|d| d.message().starts_with(MOCK_FORMATTER.deprecated_message())),
"control case: a Registry-source dependency must still produce the diagnostic"
);
}
#[test]
fn test_generate_diagnostics_from_cache_yanked_no_version_range_uses_name_range() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let name_range = Range::new(Position::new(0, 0), Position::new(0, 5));
let parse_result = MockMarkedParseResult {
dep: MockMarkedDep {
name: "serde".into(),
name_range,
markers: None,
},
uri: crate::test_util::test_uri("/test/pyproject.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let outcomes =
DependencyOutcomes::new().with_yanked("serde", ("1.0.5".into(), RemovalStatus::Yanked));
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let yanked_diag = diagnostics
.iter()
.find(|d| d.message().starts_with(formatter.yanked_message()))
.expect("expected a yanked diagnostic even without a version_range");
assert_eq!(yanked_diag.range, name_range);
}
#[test]
fn test_generate_diagnostics_from_cache_yanked_normalized_name_keying() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = crate::test_util::StubFormatter::new().with_lowercase_names();
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "Newtonsoft.Json".into(),
version_req: "13.0.1".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/project.csproj"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let outcomes = DependencyOutcomes::new()
.with_yanked("newtonsoft.json", ("13.0.1".into(), RemovalStatus::Yanked));
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics
.iter()
.any(|d| d.message().starts_with(formatter.yanked_message())),
"expected normalized-name lookup to resolve the yanked entry, got: {diagnostics:?}"
);
}
#[test]
fn test_generate_diagnostics_from_cache_yanked_not_shared_across_duplicate_name_occurrences() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![
MockDep {
name: "time".into(),
version_req: "=0.1.43".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
},
MockDep {
name: "time".into(),
version_req: "=0.1.44".into(),
version_range: Range::new(Position::new(3, 10), Position::new(3, 20)),
name_range: Range::new(Position::new(3, 0), Position::new(3, 5)),
},
],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let outcomes =
DependencyOutcomes::new().with_yanked("time", ("0.1.43".into(), RemovalStatus::Yanked));
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_outcomes(&outcomes)
.with_ecosystem(crate::EcosystemId::Cargo),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let yanked_diags: Vec<_> = diagnostics
.iter()
.filter(|d| d.message().starts_with(formatter.yanked_message()))
.collect();
assert_eq!(
yanked_diags.len(),
1,
"exactly one occurrence must get the yanked diagnostic, got: {diagnostics:?}"
);
assert_eq!(
yanked_diags[0].range.start.line, 0,
"must land on the yanked occurrence's own line"
);
assert!(yanked_diags[0].message().contains("0.1.43"));
}
#[test]
fn test_generate_diagnostics_from_cache_yanked_no_ecosystem_keeps_pre_394_behavior() {
use crate::position::{Position, Range};
use std::collections::HashMap;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![
MockDep {
name: "time".into(),
version_req: "=0.1.43".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
},
MockDep {
name: "time".into(),
version_req: "=0.1.44".into(),
version_range: Range::new(Position::new(3, 10), Position::new(3, 20)),
name_range: Range::new(Position::new(3, 0), Position::new(3, 5)),
},
],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let outcomes =
DependencyOutcomes::new().with_yanked("time", ("0.1.43".into(), RemovalStatus::Yanked));
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let yanked_diags = diagnostics
.iter()
.filter(|d| d.message().starts_with(formatter.yanked_message()))
.count();
assert_eq!(
yanked_diags, 2,
"no `ecosystem` set: both occurrences share the finding as before #394"
);
}
#[test]
fn test_generate_diagnostics_unsatisfiable_enriched_with_matching_prerelease() {
let cached_versions = {
let mut m = HashMap::new();
m.insert(
"dep".into(),
PackageVersions {
latest: "1.5.0".into(),
available: Arc::from(vec!["2.0.0-rc.1".into(), "1.5.0".into(), "1.4.0".into()]),
yanked: Arc::from(Vec::new()),
published_at: None,
gossip_excluded_version: None,
cooldown_fallback: None,
},
);
m
};
let resolved_versions = HashMap::new();
let uri = crate::test_util::test_uri("/test/Cargo.toml");
let mut dependency = dep_at("dep");
dependency.version_req = VersionReq::new("^2.0.0");
let parse_result = SingleDepParseResult {
dep: dependency,
uri,
};
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&StrictSemverFormatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let message = diagnostics
.iter()
.find(|d| d.message().contains("No published version satisfies"))
.map(|d| d.message())
.expect("unsatisfiable WARNING must fire");
assert!(
message.contains("2.0.0-rc.1") && message.contains("pre-release"),
"message must mention the matching pre-release, got: {message}"
);
}
#[test]
fn test_generate_diagnostics_unsatisfiable_sanitizes_bidi_in_matching_prerelease() {
let cached_versions = {
let mut m = HashMap::new();
m.insert(
"dep".into(),
PackageVersions {
latest: "1.5.0".into(),
available: Arc::from(vec![
"2.0.0-rc.1\u{202E}evil".into(),
"1.5.0".into(),
"1.4.0".into(),
]),
yanked: Arc::from(Vec::new()),
published_at: None,
gossip_excluded_version: None,
cooldown_fallback: None,
},
);
m
};
let resolved_versions = HashMap::new();
let uri = crate::test_util::test_uri("/test/Cargo.toml");
let mut dependency = dep_at("dep");
dependency.version_req = VersionReq::new("^2.0.0");
let parse_result = SingleDepParseResult {
dep: dependency,
uri,
};
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&StrictSemverFormatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let message = diagnostics
.iter()
.find(|d| d.message().contains("No published version satisfies"))
.map(|d| d.message())
.expect("unsatisfiable WARNING must fire");
assert!(
message.contains("pre-release"),
"message must still mention the matching pre-release, got: {message}"
);
assert!(!message.contains('\u{202E}'));
assert!(message.contains("2.0.0-rc.1"));
}
#[test]
fn test_generate_diagnostics_unsatisfiable_no_enrichment_without_matching_prerelease() {
let cached_versions = {
let mut m = HashMap::new();
m.insert(
"dep".into(),
PackageVersions {
latest: "1.5.0".into(),
available: Arc::from(vec!["1.5.0".into(), "1.4.0".into()]),
yanked: Arc::from(Vec::new()),
published_at: None,
gossip_excluded_version: None,
cooldown_fallback: None,
},
);
m
};
let resolved_versions = HashMap::new();
let uri = crate::test_util::test_uri("/test/Cargo.toml");
let mut dependency = dep_at("dep");
dependency.version_req = VersionReq::new("^2.0.0");
let parse_result = SingleDepParseResult {
dep: dependency,
uri,
};
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&StrictSemverFormatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let message = diagnostics
.iter()
.find(|d| d.message().contains("No published version satisfies"))
.map(|d| d.message())
.expect("unsatisfiable WARNING must fire");
assert!(
!message.contains("pre-release"),
"message must not mention a pre-release when none satisfies, got: {message}"
);
}
#[test]
fn test_generate_diagnostics_unsatisfiable_no_enrichment_for_non_strict_ecosystem() {
let cached_versions = {
let mut m = HashMap::new();
m.insert(
"dep".into(),
PackageVersions {
latest: "1.5.0".into(),
available: Arc::from(vec!["2.0.0-rc.1".into(), "1.5.0".into(), "1.4.0".into()]),
yanked: Arc::from(Vec::new()),
published_at: None,
gossip_excluded_version: None,
cooldown_fallback: None,
},
);
m
};
let resolved_versions = HashMap::new();
let uri = crate::test_util::test_uri("/test/pom.xml");
let mut dependency = dep_at("dep");
dependency.version_req = VersionReq::new("2.0.0");
let parse_result = SingleDepParseResult {
dep: dependency,
uri,
};
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&ExactMatchFormatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let message = diagnostics
.iter()
.find(|d| d.message().contains("No published version satisfies"))
.map(|d| d.message())
.expect("unsatisfiable WARNING must fire");
assert!(
!message.contains("pre-release"),
"non-strict ecosystem must never get pre-release enrichment, got: {message}"
);
}
#[test]
fn test_apply_unsatisfiable_rule_compiles_requirement_only_once() {
struct CountingFormatter {
compile_calls: std::sync::atomic::AtomicUsize,
}
impl PackageNaming for CountingFormatter {}
impl PackageRendering for CountingFormatter {
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 CountingFormatter {
fn compile_bounded_requirement(
&self,
requirement: BoundedVersionReq<'_>,
) -> Option<Box<dyn RequirementMatcher>> {
let requirement = requirement.get();
self.compile_calls
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
crate::lsp_helpers::compile_semver_requirement(requirement)
}
}
impl DiagnosticMessages for CountingFormatter {}
impl DiagnosticPolicy for CountingFormatter {}
impl SourcePolicy for CountingFormatter {}
impl OsvNaming for CountingFormatter {}
let cached_versions = {
let mut m = HashMap::new();
m.insert(
"dep".into(),
PackageVersions {
latest: "1.5.0".into(),
available: Arc::from(vec!["2.0.0-rc.1".into(), "1.5.0".into(), "1.4.0".into()]),
yanked: Arc::from(Vec::new()),
published_at: None,
gossip_excluded_version: None,
cooldown_fallback: None,
},
);
m
};
let resolved_versions = HashMap::new();
let uri = crate::test_util::test_uri("/test/Cargo.toml");
let mut dependency = dep_at("dep");
dependency.version_req = VersionReq::new("^2.0.0");
let parse_result = SingleDepParseResult {
dep: dependency,
uri,
};
let formatter = CountingFormatter {
compile_calls: std::sync::atomic::AtomicUsize::new(0),
};
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics
.iter()
.any(|d| d.message().contains("No published version satisfies")),
"unsatisfiable WARNING must fire so both the unsatisfiable check and the \
pre-release enrichment run"
);
assert_eq!(
formatter
.compile_calls
.load(std::sync::atomic::Ordering::SeqCst),
1,
"requirement must be compiled exactly once, reusing the matcher for both the \
unsatisfiable decision and the pre-release enrichment (#1494) — a redundant \
second compile must not be reintroduced"
);
}
#[test]
fn test_generate_diagnostics_unsatisfiable_skipped_for_non_registry_sources() {
use crate::parser::DependencySource;
let cached_versions = {
let mut m = HashMap::new();
m.insert("dep".into(), PackageVersions::latest_only("9.9.9"));
m
};
let resolved_versions = HashMap::new();
let uri = crate::test_util::test_uri("/test/Cargo.toml");
for source in [
DependencySource::Path {
path: "../local".into(),
},
DependencySource::Git {
url: "https://example.com/repo.git".into(),
rev: None,
},
DependencySource::Url {
url: "https://example.com/pkg.tar.gz".into(),
},
DependencySource::Sdk {
sdk: "flutter".into(),
},
DependencySource::Workspace,
DependencySource::CustomRegistry {
url: "my-corp".into(),
},
] {
let parse_result = SingleDepParseResult {
dep: NonRegistryDep(dep_at("dep"), source.clone()),
uri: uri.clone(),
};
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&ExactMatchFormatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics
.iter()
.all(|d| !d.message().contains("No published version satisfies")),
"source {source:?} must never produce the unsatisfiable-requirement WARNING"
);
}
let registry_parse_result = SingleDepParseResult {
dep: dep_at("dep"),
uri,
};
let diagnostics = generate_diagnostics_from_cache(
®istry_parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&ExactMatchFormatter,
registry_parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics
.iter()
.any(|d| d.message().contains("No published version satisfies")),
"control case: a Registry-source dependency must still produce the WARNING"
);
}
#[test]
fn test_generate_diagnostics_unknown_package_skipped_for_non_registry_sources() {
use crate::parser::DependencySource;
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let uri = crate::test_util::test_uri("/test/Cargo.toml");
let parse_result = SingleDepParseResult {
dep: NonRegistryDep(
dep_at("dep"),
DependencySource::CustomRegistry {
url: "my-corp".into(),
},
),
uri: uri.clone(),
};
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&MOCK_FORMATTER,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics
.iter()
.all(|d| !d.message().contains("Unknown package")),
"a CustomRegistry-sourced dependency must never produce the \"Unknown package\" WARNING"
);
let registry_parse_result = SingleDepParseResult {
dep: dep_at("dep"),
uri,
};
let diagnostics = generate_diagnostics_from_cache(
®istry_parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&MOCK_FORMATTER,
registry_parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics
.iter()
.any(|d| d.message().contains("Unknown package")),
"control case: a Registry-source dependency must still produce the WARNING"
);
}
#[test]
fn test_generate_diagnostics_invalid_name_still_reported_for_non_registry_sources() {
use crate::parser::DependencySource;
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let uri = crate::test_util::test_uri("/test/package.json");
let parse_result = SingleDepParseResult {
dep: NonRegistryDep(
dep_at("dep"),
DependencySource::CustomRegistry {
url: "my-corp".into(),
},
),
uri,
};
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&RejectingFormatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert!(diagnostics[0].message().starts_with("Invalid package name"));
}
#[test]
fn test_generate_diagnostics_outdated_skipped_for_non_registry_sources() {
use crate::parser::DependencySource;
let cached_versions = {
let mut m = HashMap::new();
m.insert("dep".into(), PackageVersions::latest_only("9.9.9"));
m
};
let resolved_versions = HashMap::new();
let uri = crate::test_util::test_uri("/test/Cargo.toml");
let parse_result = SingleDepParseResult {
dep: NonRegistryDep(
dep_at("dep"),
DependencySource::CustomRegistry {
url: "my-corp".into(),
},
),
uri: uri.clone(),
};
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&MOCK_FORMATTER,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics
.iter()
.all(|d| !d.message().contains("Newer version available")),
"a CustomRegistry-sourced dependency must never produce the \"Outdated\" WARNING"
);
let registry_parse_result = SingleDepParseResult {
dep: dep_at("dep"),
uri,
};
let diagnostics = generate_diagnostics_from_cache(
®istry_parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&MOCK_FORMATTER,
registry_parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics
.iter()
.any(|d| d.message().contains("Newer version available")),
"control case: a Registry-source dependency must still produce the WARNING"
);
}
#[test]
fn test_generate_diagnostics_vulnerable_dependency_emits_advisory_diagnostic_even_without_registry_data()
{
use crate::osv::{
Capped, DependencyVulnerabilities, ScanOutcome, UpgradeStatus, VulnSeverity,
VulnerabilityMap,
};
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![dep_at("vulnerable-pkg")],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
vulns.insert(
crate::test_util::vuln_key("vulnerable-pkg"),
ScanOutcome::Vulnerable(DependencyVulnerabilities {
sibling_matches: None,
advisories: Capped::new(
vec![sample_advisory("RUSTSEC-2020-0071", VulnSeverity::High)],
1,
),
fix_target_status: UpgradeStatus::NotChecked,
}),
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
&formatter,
parse_result.uri(),
crate::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let vuln_diag = diagnostics
.iter()
.find(|d| d.message().contains("RUSTSEC-2020-0071"))
.expect("vulnerability diagnostic must be emitted even without registry data");
assert_eq!(vuln_diag.severity, Some(Severity::Warning));
assert_eq!(vuln_diag.code(), Some("RUSTSEC-2020-0071"));
}
#[test]
fn test_generate_diagnostics_sibling_only_advisory_names_matched_tags() {
use crate::osv::{
Capped, DependencyVulnerabilities, ScanOutcome, SiblingMatches, VulnSeverity,
VulnerabilityMap,
};
let mut matches = SiblingMatches::new(crate::osv::OsvVersion::new("1.0.0"));
matches.insert("A-1".to_string(), matched_tags(&["v4.9.0", "v4.10.0"]));
let dv = DependencyVulnerabilities::new(Capped::new(
vec![
sample_advisory("A-1", VulnSeverity::High),
sample_advisory("A-2", VulnSeverity::High),
],
2,
))
.with_sibling_matches(matches);
let parse_result = MockParseResult {
deps: vec![dep_at("vulnerable-pkg")],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
vulns.insert(
crate::test_util::vuln_key("vulnerable-pkg"),
ScanOutcome::Vulnerable(dv),
);
let (cached, resolved) = (HashMap::new(), HashMap::new());
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached, &resolved).with_vulnerabilities(&vulns),
&MOCK_FORMATTER,
parse_result.uri(),
crate::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let message_of = |id: &str| {
diagnostics
.iter()
.find(|d| d.code() == Some(id))
.expect("advisory diagnostic")
.message()
.to_string()
};
assert!(
message_of("A-1").ends_with("(matched tag v4.9.0, v4.10.0)"),
"{}",
message_of("A-1")
);
assert!(!message_of("A-2").contains("matched tag"));
}
#[test]
fn test_generate_diagnostics_malicious_advisory_is_distinguishable_from_unknown() {
use crate::osv::{
Capped, DependencyVulnerabilities, ScanOutcome, UpgradeStatus, VulnSeverity,
VulnerabilityMap,
};
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![dep_at("vulnerable-pkg")],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
vulns.insert(
crate::test_util::vuln_key("vulnerable-pkg"),
ScanOutcome::Vulnerable(DependencyVulnerabilities {
sibling_matches: None,
advisories: Capped::new(
vec![
sample_advisory("MAL-2025-47141", VulnSeverity::Malicious),
sample_advisory("RUSTSEC-2020-0071", VulnSeverity::Unknown),
],
2,
),
fix_target_status: UpgradeStatus::NotChecked,
}),
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
&formatter,
parse_result.uri(),
crate::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let malicious_diag = diagnostics
.iter()
.find(|d| d.message().contains("MAL-2025-47141"))
.expect("malicious advisory diagnostic must be emitted");
let unknown_diag = diagnostics
.iter()
.find(|d| d.message().contains("RUSTSEC-2020-0071"))
.expect("unknown-severity advisory diagnostic must be emitted");
assert_ne!(malicious_diag.message(), unknown_diag.message());
assert_ne!(malicious_diag.code(), unknown_diag.code());
assert!(malicious_diag.message().contains("[MALWARE]"));
assert!(!unknown_diag.message().contains("[MALWARE]"));
}
#[test]
fn test_generate_diagnostics_vulnerability_sanitizes_and_caps_advisory_summary() {
use crate::osv::{
Capped, DependencyVulnerabilities, ScanOutcome, UpgradeStatus, VulnSeverity,
VulnerabilityMap,
};
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![dep_at("vulnerable-pkg")],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let overlong_summary = "X".repeat(MAX_DIAGNOSTIC_PROSE_CHARS + 50);
let advisory = crate::osv::Advisory::new(
"RUSTSEC-2020-0071".to_string(),
"2023-01-01T00:00:00Z".to_string(),
VulnSeverity::High,
)
.expect("valid osv id")
.with_summary(format!("bidi\u{202E}{overlong_summary}"));
let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
vulns.insert(
crate::test_util::vuln_key("vulnerable-pkg"),
ScanOutcome::Vulnerable(DependencyVulnerabilities {
sibling_matches: None,
advisories: Capped::new(vec![std::sync::Arc::new(advisory)], 1),
fix_target_status: UpgradeStatus::NotChecked,
}),
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
&formatter,
parse_result.uri(),
crate::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let vuln_diag = diagnostics
.iter()
.find(|d| d.message().contains("RUSTSEC"))
.expect("vulnerability diagnostic must be emitted");
assert!(!vuln_diag.message().contains('\u{202E}'));
assert!(vuln_diag.message().contains('…'));
assert!(
!vuln_diag.message().contains(&overlong_summary),
"expected the summary to be truncated rather than interpolated verbatim, got: {:?}",
vuln_diag.message()
);
assert_eq!(vuln_diag.code(), Some("RUSTSEC-2020-0071"));
}
#[test]
fn test_generate_diagnostics_informational_advisory_is_distinguishable_from_unknown() {
use crate::osv::{
Capped, DependencyVulnerabilities, ScanOutcome, UpgradeStatus, VulnSeverity,
VulnerabilityMap,
};
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![dep_at("vulnerable-pkg")],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
vulns.insert(
crate::test_util::vuln_key("vulnerable-pkg"),
ScanOutcome::Vulnerable(DependencyVulnerabilities {
sibling_matches: None,
advisories: Capped::new(
vec![
sample_advisory("RUSTSEC-2024-0320", VulnSeverity::Informational),
sample_advisory("RUSTSEC-2020-0071", VulnSeverity::Unknown),
],
2,
),
fix_target_status: UpgradeStatus::NotChecked,
}),
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
&formatter,
parse_result.uri(),
crate::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let informational_diag = diagnostics
.iter()
.find(|d| d.message().contains("RUSTSEC-2024-0320"))
.expect("informational advisory diagnostic must be emitted");
let unknown_diag = diagnostics
.iter()
.find(|d| d.message().contains("RUSTSEC-2020-0071"))
.expect("unknown-severity advisory diagnostic must be emitted");
assert_ne!(informational_diag.message(), unknown_diag.message());
assert_ne!(informational_diag.severity, unknown_diag.severity);
assert_eq!(informational_diag.severity, Some(Severity::Information));
assert_eq!(unknown_diag.severity, Some(Severity::Warning));
assert!(informational_diag.message().contains("[INFORMATIONAL]"));
assert!(!unknown_diag.message().contains("[INFORMATIONAL]"));
}
#[test]
fn test_generate_diagnostics_malicious_message_does_not_repeat_the_word_malicious() {
use crate::osv::{
Advisory, Capped, DependencyVulnerabilities, ScanOutcome, UpgradeStatus, VulnSeverity,
VulnerabilityMap,
};
use std::sync::Arc;
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![dep_at("bad-pkg")],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let advisory = Arc::new(
Advisory::new(
"MAL-2025-47141".to_string(),
"2025-09-17T06:23:36Z".to_string(),
VulnSeverity::Malicious,
)
.expect("valid osv id")
.with_summary("Malicious code in @ctrl/tinycolor (npm)".to_string())
.with_aliases(vec!["GHSA-qjqf-7j6f-82c4".to_string()]),
);
let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
vulns.insert(
crate::test_util::vuln_key("bad-pkg"),
ScanOutcome::Vulnerable(DependencyVulnerabilities {
sibling_matches: None,
advisories: Capped::new(vec![advisory], 1),
fix_target_status: UpgradeStatus::NotChecked,
}),
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
&formatter,
parse_result.uri(),
crate::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let diag = diagnostics
.iter()
.find(|d| d.message().contains("MAL-2025-47141"))
.expect("malicious advisory diagnostic must be emitted");
assert!(
diag.message().contains("[MALWARE]"),
"message must carry the distinguishing [MALWARE] tag, got: {}",
diag.message()
);
assert!(
!diag.message().contains("Malicious package"),
"message must not also prefix the redundant 'Malicious package' wording \
on top of OSV's own \"Malicious ...\" summary text, got: {}",
diag.message()
);
}
#[test]
fn test_generate_diagnostics_advisory_cap_emits_more_count_from_total_known() {
use crate::osv::{
ADVISORY_DISPLAY_CAP, Capped, DependencyVulnerabilities, ScanOutcome, UpgradeStatus,
VulnSeverity, VulnerabilityMap,
};
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![dep_at("noisy-pkg")],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let advisories: Vec<_> = (0..ADVISORY_DISPLAY_CAP)
.map(|i| sample_advisory(&format!("ADV-{i}"), VulnSeverity::Low))
.collect();
let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
vulns.insert(
crate::test_util::vuln_key("noisy-pkg"),
ScanOutcome::Vulnerable(DependencyVulnerabilities {
sibling_matches: None,
advisories: Capped::new(advisories, 40),
fix_target_status: UpgradeStatus::NotChecked,
}),
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
&formatter,
parse_result.uri(),
crate::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let more_diag = diagnostics
.iter()
.find(|d| d.message().contains("more advisories"))
.expect("expected a trailing +N more advisories diagnostic");
assert!(
more_diag.message().contains("+35"),
"got: {}",
more_diag.message()
);
}
#[test]
fn test_generate_diagnostics_vulnerability_not_shared_across_duplicate_name_occurrences() {
use crate::osv::{
Capped, DependencyVulnerabilities, ScanOutcome, UpgradeStatus, VulnSeverity,
VulnerabilityMap, vulnerability_keys,
};
use crate::position::{Position, Range};
let formatter = MOCK_FORMATTER;
let vulnerable_dep = MockDep {
name: "pkg".into(),
version_req: "=1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
};
let patched_dep = MockDep {
name: "pkg".into(),
version_req: "=2.0.0".into(),
version_range: Range::new(Position::new(3, 10), Position::new(3, 20)),
name_range: Range::new(Position::new(3, 0), Position::new(3, 5)),
};
let parse_result = MockParseResult {
deps: vec![vulnerable_dep, patched_dep],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let keys = vulnerability_keys(
&parse_result,
&resolved_versions,
None,
&formatter,
crate::EcosystemId::Cargo,
);
let deps = parse_result.dependencies();
let vulnerable_key = keys.get(&deps[0].name_range()).unwrap().clone();
let patched_key = keys.get(&deps[1].name_range()).unwrap().clone();
assert_ne!(
vulnerable_key, patched_key,
"differently-versioned occurrences of one name must get distinct keys"
);
let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
vulns.insert(
vulnerable_key,
ScanOutcome::Vulnerable(DependencyVulnerabilities {
sibling_matches: None,
advisories: Capped::new(
vec![sample_advisory("RUSTSEC-2020-0071", VulnSeverity::High)],
1,
),
fix_target_status: UpgradeStatus::NotChecked,
}),
);
vulns.insert(patched_key, ScanOutcome::Clean);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_vulnerabilities(&vulns)
.with_ecosystem(crate::EcosystemId::Cargo),
&formatter,
parse_result.uri(),
crate::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let advisory_diags: Vec<_> = diagnostics
.iter()
.filter(|d| d.message().contains("RUSTSEC-2020-0071"))
.collect();
assert_eq!(
advisory_diags.len(),
1,
"exactly one occurrence must get the advisory diagnostic, got: {diagnostics:?}"
);
assert_eq!(
advisory_diags[0].range.start.line, 0,
"must land on the vulnerable occurrence's own line, not the patched one"
);
}
#[test]
fn test_generate_diagnostics_vulnerability_attributed_via_resolved_version_candidates() {
use crate::osv::{
Capped, DependencyVulnerabilities, ScanOutcome, UpgradeStatus, VulnSeverity,
VulnerabilityMap, vulnerability_keys,
};
use crate::position::{Position, Range};
let formatter = MOCK_FORMATTER;
let current_major = MockDep {
name: "serde".into(),
version_req: "1.0".into(),
version_range: Range::new(Position::new(0, 8), Position::new(0, 13)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
};
let renamed_old_major = MockDep {
name: "serde".into(),
version_req: "0.9".into(),
version_range: Range::new(Position::new(1, 8), Position::new(1, 13)),
name_range: Range::new(Position::new(1, 0), Position::new(1, 9)),
};
let parse_result = MockParseResult {
deps: vec![current_major, renamed_old_major],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let cached_versions = HashMap::new();
let mut resolved_versions = HashMap::new();
resolved_versions.insert("serde".into(), ConcreteVersion::from("1.0.219"));
let mut resolved_version_candidates = HashMap::new();
resolved_version_candidates.insert(
"serde".into(),
vec![
ConcreteVersion::from("0.9.15"),
ConcreteVersion::from("1.0.219"),
],
);
let keys = vulnerability_keys(
&parse_result,
&resolved_versions,
Some(&resolved_version_candidates),
&formatter,
crate::EcosystemId::Cargo,
);
let deps = parse_result.dependencies();
let current_key = keys.get(&deps[0].name_range()).unwrap().clone();
let renamed_key = keys.get(&deps[1].name_range()).unwrap().clone();
assert_ne!(
current_key, renamed_key,
"occurrences resolving to different lockfile candidates must get distinct keys"
);
let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
vulns.insert(
current_key,
ScanOutcome::Vulnerable(DependencyVulnerabilities {
sibling_matches: None,
advisories: Capped::new(
vec![sample_advisory("RUSTSEC-2020-0071", VulnSeverity::High)],
1,
),
fix_target_status: UpgradeStatus::NotChecked,
}),
);
vulns.insert(renamed_key, ScanOutcome::Clean);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_resolved_version_candidates(&resolved_version_candidates)
.with_vulnerabilities(&vulns)
.with_ecosystem(crate::EcosystemId::Cargo),
&formatter,
parse_result.uri(),
crate::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
let advisory_diags: Vec<_> = diagnostics
.iter()
.filter(|d| d.message().contains("RUSTSEC-2020-0071"))
.collect();
assert_eq!(
advisory_diags.len(),
1,
"exactly one occurrence must get the advisory diagnostic, got: {diagnostics:?}"
);
assert_eq!(
advisory_diags[0].range.start.line, 0,
"the advisory affecting 1.0.219 must land on the plain (1.0) occurrence's line, \
not the renamed (0.9) occurrence sharing the same resolved name"
);
}
#[test]
fn test_generate_diagnostics_skipped_outcome_emits_no_vulnerability_diagnostic() {
use crate::osv::{ScanOutcome, SkipReason, VulnerabilityMap};
let formatter = MOCK_FORMATTER;
let parse_result = MockParseResult {
deps: vec![dep_at("git-pkg")],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert("git-pkg".into(), PackageVersions::latest_only("1.0.0"));
let resolved_versions = HashMap::new();
let mut vulns: VulnerabilityMap = VulnerabilityMap::new();
vulns.insert(
crate::test_util::vuln_key("git-pkg"),
ScanOutcome::Skipped(SkipReason::NonRegistrySource),
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_vulnerabilities(&vulns),
&formatter,
parse_result.uri(),
crate::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics.iter().all(|d| d.code().is_none()),
"a Skipped outcome must never render an advisory diagnostic"
);
}
mod requirement_is_unsatisfiable_tests {
use super::*;
type Decide = Arc<dyn Fn(&str) -> Option<bool> + Send + Sync>;
struct ClosureMatcher(Decide);
impl RequirementMatcher for ClosureMatcher {
fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
(self.0)(version.as_str())
}
fn strict_prerelease_exclusion(&self) -> bool {
false
}
}
struct TableFormatter {
decide: Decide,
}
impl TableFormatter {
fn new(decide: impl Fn(&str) -> Option<bool> + Send + Sync + 'static) -> Self {
Self {
decide: Arc::new(decide),
}
}
}
impl PackageNaming for TableFormatter {}
impl PackageRendering for TableFormatter {
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 TableFormatter {
fn bounded_requirement_is_unresolved(
&self,
requirement: BoundedVersionReq<'_>,
) -> bool {
requirement.as_str() == "unresolved"
}
fn compile_bounded_requirement(
&self,
requirement: BoundedVersionReq<'_>,
) -> Option<Box<dyn RequirementMatcher>> {
if requirement.as_str() != "modelled" {
return None;
}
Some(Box::new(ClosureMatcher(Arc::clone(&self.decide)))
as Box<dyn RequirementMatcher>)
}
}
impl DiagnosticMessages for TableFormatter {}
impl DiagnosticPolicy for TableFormatter {}
impl SourcePolicy for TableFormatter {}
impl OsvNaming for TableFormatter {}
fn versions(strs: &[&str]) -> Vec<ConcreteVersion> {
strs.iter().map(|s| (*s).into()).collect()
}
#[test]
fn test_empty_available_list_is_false() {
let formatter = TableFormatter::new(|_v| Some(true));
assert!(!requirement_is_unsatisfiable(
&formatter,
&VersionReq::new("modelled"),
&[],
));
}
#[test]
fn test_empty_requirement_string_is_false() {
let formatter = TableFormatter::new(|_v| Some(false));
assert!(!requirement_is_unsatisfiable(
&formatter,
&VersionReq::new(""),
&versions(&["1.0.0"]),
));
}
#[test]
fn test_oversized_requirement_is_false_without_compiling() {
let formatter =
TableFormatter::new(|_v| panic!("must not compile/scan an oversized requirement"));
let oversized = "1".repeat(MAX_REQUIREMENT_LEN + 1);
assert!(!requirement_is_unsatisfiable(
&formatter,
&VersionReq::new(oversized),
&versions(&["1.0.0"]),
));
}
#[test]
fn test_oversized_requirement_never_reaches_predicate_hooks() {
struct PanickingPredicates;
impl PackageNaming for PanickingPredicates {}
impl PackageRendering for PanickingPredicates {
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 PanickingPredicates {
fn bounded_requirement_is_unresolved(
&self,
_requirement: BoundedVersionReq<'_>,
) -> bool {
panic!("unresolved hook must not run for an oversized requirement")
}
fn bounded_requirement_is_placeholder(
&self,
_requirement: BoundedVersionReq<'_>,
) -> bool {
panic!("placeholder hook must not run for an oversized requirement")
}
fn bounded_requirement_is_undecidable_given_available(
&self,
_requirement: BoundedVersionReq<'_>,
_available: &[ConcreteVersion],
) -> bool {
panic!("undecidable hook must not run for an oversized requirement")
}
}
impl DiagnosticMessages for PanickingPredicates {}
impl DiagnosticPolicy for PanickingPredicates {}
impl SourcePolicy for PanickingPredicates {}
impl OsvNaming for PanickingPredicates {}
let oversized = "1".repeat(MAX_REQUIREMENT_LEN + 1);
assert!(!requirement_is_unsatisfiable(
&PanickingPredicates,
&VersionReq::new(oversized),
&versions(&["1.0.0"]),
));
}
#[test]
fn test_unresolved_requirement_is_false() {
let formatter = TableFormatter::new(|_v| Some(false));
assert!(!requirement_is_unsatisfiable(
&formatter,
&VersionReq::new("unresolved"),
&versions(&["1.0.0"]),
));
}
#[test]
fn test_compile_requirement_none_is_false() {
let formatter = TableFormatter::new(|_v| Some(false));
assert!(!requirement_is_unsatisfiable(
&formatter,
&VersionReq::new("not-modelled"),
&versions(&["1.0.0"]),
));
}
#[test]
fn test_all_candidates_decided_false_is_true() {
let formatter = TableFormatter::new(|_v| Some(false));
assert!(requirement_is_unsatisfiable(
&formatter,
&VersionReq::new("modelled"),
&versions(&["1.0.0", "2.0.0", "3.0.0"]),
));
}
#[test]
fn test_one_match_among_many_non_matches_is_false() {
let formatter = TableFormatter::new(|v| Some(v == "2.0.0"));
assert!(!requirement_is_unsatisfiable(
&formatter,
&VersionReq::new("modelled"),
&versions(&["1.0.0", "2.0.0", "3.0.0"]),
));
}
#[test]
fn test_all_candidates_unparseable_is_false() {
let formatter = TableFormatter::new(|_v| None);
assert!(!requirement_is_unsatisfiable(
&formatter,
&VersionReq::new("modelled"),
&versions(&["1.0.0", "2.0.0"]),
));
}
#[test]
fn test_one_unparseable_candidate_among_false_is_still_true() {
let formatter = TableFormatter::new(|v| if v == "junk" { None } else { Some(false) });
assert!(requirement_is_unsatisfiable(
&formatter,
&VersionReq::new("modelled"),
&versions(&["1.0.0", "junk", "2.0.0"]),
));
}
#[test]
fn test_match_on_yanked_only_candidate_is_false() {
let formatter = TableFormatter::new(|v| Some(v == "1.0.0-yanked"));
assert!(!requirement_is_unsatisfiable(
&formatter,
&VersionReq::new("modelled"),
&versions(&["1.0.0-yanked"]),
));
}
#[test]
fn test_match_on_prerelease_only_candidate_is_false() {
let formatter = TableFormatter::new(|v| Some(v == "2.0.0-beta.1"));
assert!(!requirement_is_unsatisfiable(
&formatter,
&VersionReq::new("modelled"),
&versions(&["2.0.0-beta.1"]),
));
}
#[test]
fn test_scan_short_circuits_on_first_match() {
let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
let calls_clone = Arc::clone(&calls);
let formatter = TableFormatter::new(move |v| {
calls_clone.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Some(v == "1.0.0")
});
let result = requirement_is_unsatisfiable(
&formatter,
&VersionReq::new("modelled"),
&versions(&["1.0.0", "0.9.0", "0.8.0"]),
);
assert!(!result);
assert_eq!(
calls.load(std::sync::atomic::Ordering::SeqCst),
1,
"must stop scanning at the first Some(true)"
);
}
}
mod matching_prerelease_would_satisfy_tests {
use super::*;
struct NonStrictSemverMatcher(semver::VersionReq);
impl RequirementMatcher for NonStrictSemverMatcher {
fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
crate::lsp_helpers::semver_req_matches(&self.0, version)
}
fn strict_prerelease_exclusion(&self) -> bool {
false
}
}
struct NonStrictFormatter;
impl PackageNaming for NonStrictFormatter {}
impl PackageRendering for NonStrictFormatter {
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 NonStrictFormatter {
fn compile_bounded_requirement(
&self,
requirement: BoundedVersionReq<'_>,
) -> Option<Box<dyn RequirementMatcher>> {
let requirement = requirement.get();
requirement
.as_str()
.parse::<semver::VersionReq>()
.ok()
.map(|req| Box::new(NonStrictSemverMatcher(req)) as Box<dyn RequirementMatcher>)
}
}
impl DiagnosticMessages for NonStrictFormatter {}
impl DiagnosticPolicy for NonStrictFormatter {}
impl SourcePolicy for NonStrictFormatter {}
impl OsvNaming for NonStrictFormatter {}
fn versions(strs: &[&str]) -> Vec<ConcreteVersion> {
strs.iter().map(|s| (*s).into()).collect()
}
fn yanked_versions(strs: &[&str]) -> Vec<(ConcreteVersion, RemovalStatus)> {
strs.iter()
.map(|s| ((*s).into(), RemovalStatus::Yanked))
.collect()
}
fn compile(
formatter: &impl RequirementResolution,
requirement: &VersionReq,
) -> Box<dyn RequirementMatcher> {
formatter
.compile_requirement(requirement)
.expect("test requirement compiles")
}
#[test]
fn test_semver_prerelease_base() {
assert_eq!(semver_prerelease_base("2.0.0-rc.1"), Some("2.0.0"));
assert_eq!(semver_prerelease_base("2.0.0-rc.1+build.5"), Some("2.0.0"));
assert_eq!(semver_prerelease_base("2.0.0"), None);
assert_eq!(semver_prerelease_base("2.0.0+build.5"), None);
}
#[test]
fn test_requirement_names_prerelease() {
assert!(requirement_names_prerelease("2.0.0-rc.5"));
assert!(requirement_names_prerelease("^2.0.0-rc.5"));
assert!(requirement_names_prerelease("~2.0.0-rc.5"));
assert!(requirement_names_prerelease(">=2.0.0-rc.5"));
assert!(requirement_names_prerelease("=2.0.0-rc.5"));
assert!(!requirement_names_prerelease("^2.0.0"));
assert!(!requirement_names_prerelease(">=1.0.0, <2.0.0"));
assert!(!requirement_names_prerelease("1.2.3 - 2.3.4"));
}
#[test]
fn test_no_prerelease_available_returns_none() {
let requirement = VersionReq::new("^2.0.0");
assert_eq!(
matching_prerelease_would_satisfy(
compile(&StrictSemverFormatter, &requirement).as_ref(),
&requirement,
&versions(&["1.5.0", "1.4.0"]),
&[],
),
None
);
}
#[test]
fn test_matching_prerelease_is_found() {
let requirement = VersionReq::new("^2.0.0");
assert_eq!(
matching_prerelease_would_satisfy(
compile(&StrictSemverFormatter, &requirement).as_ref(),
&requirement,
&versions(&["2.0.0-rc.1", "1.5.0"]),
&[],
),
Some("2.0.0-rc.1".to_string())
);
}
#[test]
fn test_returns_newest_matching_prerelease_first() {
let requirement = VersionReq::new("^2.0.0");
assert_eq!(
matching_prerelease_would_satisfy(
compile(&StrictSemverFormatter, &requirement).as_ref(),
&requirement,
&versions(&["2.0.0-rc.2", "2.0.0-rc.1", "1.5.0"]),
&[],
),
Some("2.0.0-rc.2".to_string())
);
}
#[test]
fn test_non_matching_prerelease_returns_none() {
let requirement = VersionReq::new("^2.0.0");
assert_eq!(
matching_prerelease_would_satisfy(
compile(&StrictSemverFormatter, &requirement).as_ref(),
&requirement,
&versions(&["3.0.0-rc.1", "1.5.0"]),
&[],
),
None
);
}
#[test]
fn test_exact_prerelease_pin_does_not_misfire_on_unrelated_prerelease() {
let requirement = VersionReq::new("=2.0.0-rc.5");
assert_eq!(
matching_prerelease_would_satisfy(
compile(&StrictSemverFormatter, &requirement).as_ref(),
&requirement,
&versions(&["2.0.0-rc.1", "1.5.0"]),
&[],
),
None
);
}
#[test]
fn test_caret_requirement_naming_prerelease_returns_none() {
let requirement = VersionReq::new("^2.0.0-rc.5");
assert_eq!(
matching_prerelease_would_satisfy(
compile(&StrictSemverFormatter, &requirement).as_ref(),
&requirement,
&versions(&["2.0.0-rc.1", "1.5.0"]),
&[],
),
None
);
}
#[test]
fn test_tilde_requirement_naming_prerelease_returns_none() {
let requirement = VersionReq::new("~2.0.0-rc.5");
assert_eq!(
matching_prerelease_would_satisfy(
compile(&StrictSemverFormatter, &requirement).as_ref(),
&requirement,
&versions(&["2.0.0-rc.1", "1.5.0"]),
&[],
),
None
);
}
#[test]
fn test_gte_requirement_naming_prerelease_returns_none() {
let requirement = VersionReq::new(">=2.0.0-rc.5");
assert_eq!(
matching_prerelease_would_satisfy(
compile(&StrictSemverFormatter, &requirement).as_ref(),
&requirement,
&versions(&["2.0.0-rc.1", "1.5.0"]),
&[],
),
None
);
}
#[test]
fn test_yanked_matching_prerelease_is_skipped() {
let requirement = VersionReq::new("^2.0.0");
assert_eq!(
matching_prerelease_would_satisfy(
compile(&StrictSemverFormatter, &requirement).as_ref(),
&requirement,
&versions(&["2.0.0-rc.1", "1.5.0"]),
&yanked_versions(&["2.0.0-rc.1"]),
),
None
);
}
#[test]
fn test_yanked_matching_prerelease_falls_back_to_non_yanked() {
let requirement = VersionReq::new("^2.0.0");
assert_eq!(
matching_prerelease_would_satisfy(
compile(&StrictSemverFormatter, &requirement).as_ref(),
&requirement,
&versions(&["2.0.0-rc.2", "2.0.0-rc.1", "1.5.0"]),
&yanked_versions(&["2.0.0-rc.2"]),
),
Some("2.0.0-rc.1".to_string())
);
}
#[test]
fn test_non_opted_in_ecosystem_returns_none() {
let requirement = VersionReq::new("^2.0.0");
assert_eq!(
matching_prerelease_would_satisfy(
compile(&NonStrictFormatter, &requirement).as_ref(),
&requirement,
&versions(&["2.0.0-rc.1", "1.5.0"]),
&[],
),
None
);
}
}
mod requirement_matches_only_yanked_tests {
use super::*;
type Decide = Arc<dyn Fn(&str) -> Option<bool> + Send + Sync>;
struct ClosureMatcher(Decide);
impl RequirementMatcher for ClosureMatcher {
fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
(self.0)(version.as_str())
}
fn strict_prerelease_exclusion(&self) -> bool {
false
}
}
struct TableFormatter {
decide: Decide,
}
impl TableFormatter {
fn new(decide: impl Fn(&str) -> Option<bool> + Send + Sync + 'static) -> Self {
Self {
decide: Arc::new(decide),
}
}
}
impl PackageNaming for TableFormatter {}
impl PackageRendering for TableFormatter {
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 TableFormatter {
fn bounded_requirement_is_unresolved(
&self,
requirement: BoundedVersionReq<'_>,
) -> bool {
requirement.as_str() == "unresolved"
}
fn compile_bounded_requirement(
&self,
requirement: BoundedVersionReq<'_>,
) -> Option<Box<dyn RequirementMatcher>> {
if requirement.as_str() != "modelled" {
return None;
}
Some(Box::new(ClosureMatcher(Arc::clone(&self.decide)))
as Box<dyn RequirementMatcher>)
}
}
impl DiagnosticMessages for TableFormatter {}
impl DiagnosticPolicy for TableFormatter {}
impl SourcePolicy for TableFormatter {}
impl OsvNaming for TableFormatter {}
fn versions(strs: &[&str]) -> Vec<ConcreteVersion> {
strs.iter().map(|s| (*s).into()).collect()
}
fn yanked_versions(strs: &[&str]) -> Vec<(ConcreteVersion, RemovalStatus)> {
strs.iter()
.map(|s| ((*s).into(), RemovalStatus::Yanked))
.collect()
}
#[test]
fn test_yanked_only_match_is_true() {
let formatter = TableFormatter::new(|v| Some(v == "1.2.1"));
assert_eq!(
requirement_matches_only_yanked(
&formatter,
&VersionReq::new("modelled"),
&versions(&["1.2.1"]),
&yanked_versions(&["1.2.1"]),
),
Some(RemovalStatus::Yanked)
);
}
#[test]
fn test_duplicate_version_prefers_yanked_status_regardless_of_order() {
let formatter = TableFormatter::new(|v| Some(v == "1.2.1"));
let advisory_then_yanked = vec![
("1.2.1".into(), RemovalStatus::AdvisoryDeprecated),
("1.2.1".into(), RemovalStatus::Yanked),
];
assert_eq!(
requirement_matches_only_yanked(
&formatter,
&VersionReq::new("modelled"),
&versions(&["1.2.1"]),
&advisory_then_yanked,
),
Some(RemovalStatus::Yanked),
"Yanked must win when it comes after AdvisoryDeprecated in the duplicate list"
);
let yanked_then_advisory = vec![
("1.2.1".into(), RemovalStatus::Yanked),
("1.2.1".into(), RemovalStatus::AdvisoryDeprecated),
];
assert_eq!(
requirement_matches_only_yanked(
&formatter,
&VersionReq::new("modelled"),
&versions(&["1.2.1"]),
&yanked_then_advisory,
),
Some(RemovalStatus::Yanked),
"Yanked must win when it comes before AdvisoryDeprecated in the duplicate list"
);
}
#[test]
fn test_no_match_is_false() {
let formatter = TableFormatter::new(|_v| Some(false));
assert!(
requirement_matches_only_yanked(
&formatter,
&VersionReq::new("modelled"),
&versions(&["1.2.1"]),
&yanked_versions(&["1.2.1"]),
)
.is_none()
);
}
#[test]
fn test_match_on_non_yanked_alongside_yanked_is_false() {
let formatter = TableFormatter::new(|v| Some(v == "1.0.0" || v == "1.0.1"));
assert!(
requirement_matches_only_yanked(
&formatter,
&VersionReq::new("modelled"),
&versions(&["1.0.1", "1.0.0"]),
&yanked_versions(&["1.0.0"]),
)
.is_none()
);
}
#[test]
fn test_scan_continues_past_a_yanked_match_to_find_a_non_yanked_alternative() {
let formatter = TableFormatter::new(|v| Some(v == "1.0.0" || v == "1.0.1"));
assert!(
requirement_matches_only_yanked(
&formatter,
&VersionReq::new("modelled"),
&versions(&["1.0.0", "1.0.1"]),
&yanked_versions(&["1.0.0"]),
)
.is_none()
);
}
#[test]
fn test_empty_yanked_list_is_false_without_compiling() {
let formatter =
TableFormatter::new(|_v| panic!("must not compile/scan when yanked is empty"));
assert!(
requirement_matches_only_yanked(
&formatter,
&VersionReq::new("modelled"),
&versions(&["1.0.0"]),
&[],
)
.is_none()
);
}
#[test]
fn test_empty_available_list_is_false() {
let formatter = TableFormatter::new(|_v| Some(true));
assert!(
requirement_matches_only_yanked(
&formatter,
&VersionReq::new("modelled"),
&[],
&yanked_versions(&["1.0.0"]),
)
.is_none()
);
}
#[test]
fn test_unresolved_requirement_is_false() {
let formatter = TableFormatter::new(|_v| Some(true));
assert!(
requirement_matches_only_yanked(
&formatter,
&VersionReq::new("unresolved"),
&versions(&["1.0.0"]),
&yanked_versions(&["1.0.0"]),
)
.is_none()
);
}
#[test]
fn test_compile_requirement_none_is_false() {
let formatter = TableFormatter::new(|_v| Some(true));
assert!(
requirement_matches_only_yanked(
&formatter,
&VersionReq::new("not-modelled"),
&versions(&["1.0.0"]),
&yanked_versions(&["1.0.0"]),
)
.is_none()
);
}
#[test]
fn test_generate_diagnostics_from_cache_yanked_only_match_fires_yanked_diagnostic() {
let formatter = TableFormatter::new(|v| Some(v == "1.2.1"));
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "modelled".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "2.0.0".into(),
available: Arc::from(vec!["2.0.0".into(), "1.2.1".into()]),
yanked: Arc::from(vec![("1.2.1".into(), RemovalStatus::Yanked)]),
published_at: None,
gossip_excluded_version: None,
cooldown_fallback: None,
},
);
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1, "expected exactly one diagnostic");
assert_eq!(diagnostics[0].severity, Some(Severity::Warning));
assert_eq!(
diagnostics[0].message(),
format!("{}; latest is 2.0.0", formatter.yanked_message())
);
}
#[test]
fn test_generate_diagnostics_from_cache_deprecation_never_suppresses_yanked_only_match_without_263_entry()
{
let formatter = TableFormatter::new(|v| Some(v == "1.2.1"));
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "modelled".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "2.0.0".into(),
available: Arc::from(vec!["2.0.0".into(), "1.2.1".into()]),
yanked: Arc::from(vec![("1.2.1".into(), RemovalStatus::Yanked)]),
published_at: None,
gossip_excluded_version: None,
cooldown_fallback: None,
},
);
let resolved_versions = HashMap::new();
let outcomes = DependencyOutcomes::new().with_deprecation(
"serde",
Deprecation {
reason: Some("archived".to_string()),
replacement: None,
},
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(
diagnostics.len(),
2,
"expected exactly the deprecation diagnostic plus the #247 yanked-only \
match, got: {diagnostics:?}"
);
assert_eq!(
diagnostics[0].message(),
format!("{}: archived", formatter.deprecated_message()),
"a genuine Yanked #247 match must still fire, without a #263 entry: {diagnostics:?}"
);
assert_eq!(diagnostics[0].code(), Some(DEPRECATED_DIAGNOSTIC_CODE));
assert_eq!(
diagnostics[1].message(),
format!("{}; latest is 2.0.0", formatter.yanked_message())
);
assert_eq!(diagnostics[1].code(), None);
}
#[test]
fn test_generate_diagnostics_from_cache_deprecation_suppresses_yanked_only_match_without_263_entry()
{
let formatter = TableFormatter::new(|v| Some(v == "1.2.1"));
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "modelled".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "2.0.0".into(),
available: Arc::from(vec!["2.0.0".into(), "1.2.1".into()]),
yanked: Arc::from(vec![("1.2.1".into(), RemovalStatus::AdvisoryDeprecated)]),
published_at: None,
gossip_excluded_version: None,
cooldown_fallback: None,
},
);
let resolved_versions = HashMap::new();
let outcomes = DependencyOutcomes::new().with_deprecation(
"serde",
Deprecation {
reason: Some("archived".to_string()),
replacement: None,
},
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics
.iter()
.all(|d| !d.message().starts_with(formatter.yanked_message())),
"an AdvisoryDeprecated #247 match must yield to the co-occurring deprecation \
finding, got: {diagnostics:?}"
);
assert!(
diagnostics
.iter()
.any(|d| d.message().starts_with(formatter.deprecated_message())),
"the deprecation finding must still fire: {diagnostics:?}"
);
}
#[test]
fn test_generate_diagnostics_from_cache_suppressed_263_entry_does_not_gate_off_yanked_247_match()
{
let formatter = TableFormatter::new(|v| Some(v == "1.2.1"));
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "modelled".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "2.0.0".into(),
available: Arc::from(vec!["2.0.0".into(), "1.2.1".into()]),
yanked: Arc::from(vec![("1.2.1".into(), RemovalStatus::Yanked)]),
published_at: None,
gossip_excluded_version: None,
cooldown_fallback: None,
},
);
let resolved_versions = HashMap::new();
let outcomes = DependencyOutcomes::new()
.with_yanked("serde", ("2.0.0".into(), RemovalStatus::AdvisoryDeprecated))
.with_deprecation(
"serde",
Deprecation {
reason: Some("archived".to_string()),
replacement: None,
},
);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(
diagnostics.len(),
2,
"expected exactly the deprecation diagnostic plus the #247 yanked-only \
match — the D5-suppressed #263 in-use-version diagnostic (for the \
deprecated 2.0.0) must stay suppressed and contribute nothing, got: \
{diagnostics:?}"
);
assert_eq!(
diagnostics[0].message(),
format!("{}: archived", formatter.deprecated_message()),
"the package-level deprecation finding must still fire: {diagnostics:?}"
);
assert_eq!(diagnostics[0].code(), Some(DEPRECATED_DIAGNOSTIC_CODE));
assert_eq!(
diagnostics[1].message(),
format!("{}; latest is 2.0.0", formatter.yanked_message()),
"the #247 match against the genuinely Yanked 1.2.1 must still fire even though \
the unrelated #263 in-use-version finding was D5-suppressed, got: {diagnostics:?}"
);
assert_eq!(diagnostics[1].code(), None);
}
#[test]
fn test_generate_diagnostics_from_cache_yanked_dedup_in_use_and_requirement_only_match() {
let formatter = TableFormatter::new(|v| Some(v == "1.2.1"));
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "modelled".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "2.0.0".into(),
available: Arc::from(vec!["2.0.0".into(), "1.2.1".into()]),
yanked: Arc::from(vec![("1.2.1".into(), RemovalStatus::Yanked)]),
published_at: None,
gossip_excluded_version: None,
cooldown_fallback: None,
},
);
let resolved_versions = HashMap::new();
let outcomes = DependencyOutcomes::new()
.with_yanked("serde", ("1.2.1".into(), RemovalStatus::Yanked));
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_outcomes(&outcomes),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(
diagnostics.len(),
2,
"expected exactly the yanked diagnostic plus the co-emitted outdated \
diagnostic (#263's policy), got: {diagnostics:?}"
);
assert_eq!(
diagnostics[0].message(),
format!("{} (1.2.1)", formatter.yanked_message()),
"expected the in-use-version check (#263) to run first and win, got: {diagnostics:?}"
);
assert_eq!(diagnostics[0].code(), None);
assert_eq!(
diagnostics[1].message(),
"Newer version available: 2.0.0",
"expected the co-emitted outdated diagnostic, got: {diagnostics:?}"
);
assert_eq!(diagnostics[1].code(), None);
}
#[test]
fn test_generate_diagnostics_from_cache_yanked_only_match_uses_configured_severity() {
let formatter = TableFormatter::new(|v| Some(v == "1.2.1"));
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "modelled".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "1.2.1".into(),
available: Arc::from(vec!["1.2.1".into()]),
yanked: Arc::from(vec![("1.2.1".into(), RemovalStatus::Yanked)]),
published_at: None,
gossip_excluded_version: None,
cooldown_fallback: None,
},
);
let resolved_versions = HashMap::new();
let severities = DiagnosticSeverities {
yanked: Severity::Error,
..DiagnosticSeverities::default()
};
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
severities,
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].severity, Some(Severity::Error));
}
#[test]
fn test_generate_diagnostics_from_cache_match_with_non_yanked_alternative_skips_yanked_diagnostic()
{
let formatter = TableFormatter::new(|v| Some(v == "1.0.0" || v == "1.0.1"));
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "modelled".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "2.0.0".into(),
available: Arc::from(vec!["2.0.0".into(), "1.0.1".into(), "1.0.0".into()]),
yanked: Arc::from(vec![("1.0.0".into(), RemovalStatus::Yanked)]),
published_at: None,
gossip_excluded_version: None,
cooldown_fallback: None,
},
);
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
!diagnostics
.iter()
.any(|d| d.message().starts_with(formatter.yanked_message())),
"a non-yanked match exists, so no yanked diagnostic should fire, got: {diagnostics:?}"
);
}
#[test]
fn test_undecided_candidate_prevents_true_verdict() {
let formatter = TableFormatter::new(|v| match v {
"1.2.1" => Some(true),
"unparseable" => None,
_ => Some(false),
});
assert!(
requirement_matches_only_yanked(
&formatter,
&VersionReq::new("modelled"),
&versions(&["1.2.1", "unparseable"]),
&yanked_versions(&["1.2.1"]),
)
.is_none()
);
}
#[test]
fn test_undecided_candidate_before_match_still_prevents_true_verdict() {
let formatter = TableFormatter::new(|v| match v {
"1.2.1" => Some(true),
"unparseable" => None,
_ => Some(false),
});
assert!(
requirement_matches_only_yanked(
&formatter,
&VersionReq::new("modelled"),
&versions(&["unparseable", "1.2.1"]),
&yanked_versions(&["1.2.1"]),
)
.is_none()
);
}
#[test]
fn test_oversized_requirement_is_false_without_compiling() {
let formatter =
TableFormatter::new(|_v| panic!("must not compile/scan an oversized requirement"));
let oversized = "1".repeat(MAX_REQUIREMENT_LEN + 1);
assert!(
requirement_matches_only_yanked(
&formatter,
&VersionReq::new(oversized),
&versions(&["1.0.0"]),
&yanked_versions(&["1.0.0"]),
)
.is_none()
);
}
#[test]
fn test_yanked_only_match_skipped_for_non_registry_sources() {
let formatter = TableFormatter::new(|v| Some(v == "1.2.1"));
let uri = crate::test_util::test_uri("/test/Cargo.toml");
let mut cached_versions = HashMap::new();
cached_versions.insert(
"dep".into(),
PackageVersions {
latest: "2.0.0".into(),
available: Arc::from(vec!["2.0.0".into(), "1.2.1".into()]),
yanked: Arc::from(vec![("1.2.1".into(), RemovalStatus::Yanked)]),
published_at: None,
gossip_excluded_version: None,
cooldown_fallback: None,
},
);
let resolved_versions = HashMap::new();
let dep = MockDep {
name: "dep".into(),
version_req: "modelled".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 3)),
};
let parse_result = SingleDepParseResult {
dep: NonRegistryDep(
dep,
crate::parser::DependencySource::Path {
path: "../local".into(),
},
),
uri,
};
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics
.iter()
.all(|d| !d.message().starts_with(formatter.yanked_message())),
"a path dependency must never produce the yanked diagnostic, got: {diagnostics:?}"
);
}
#[test]
fn test_yanked_only_match_suppresses_outdated_diagnostic() {
let formatter = TableFormatter::new(|v| Some(v == "1.2.1"));
let parse_result = MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "modelled".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert(
"serde".into(),
PackageVersions {
latest: "2.0.0".into(),
available: Arc::from(vec!["2.0.0".into(), "1.2.1".into()]),
yanked: Arc::from(vec![("1.2.1".into(), RemovalStatus::Yanked)]),
published_at: None,
gossip_excluded_version: None,
cooldown_fallback: None,
},
);
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
!diagnostics
.iter()
.any(|d| d.message().contains("Newer version available")),
"the yanked diagnostic must suppress the outdated hint, not add to it, got: {diagnostics:?}"
);
}
}
mod license_policy_tests {
use super::*;
use crate::LicensePolicy;
use crate::position::{Position, Range};
fn single_dep_parse_result() -> MockParseResult {
MockParseResult {
deps: vec![MockDep {
name: "serde".into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
}
}
#[test]
fn no_policy_configured_produces_no_diagnostic() {
let formatter = MOCK_FORMATTER;
let parse_result = single_dep_parse_result();
let mut cached_versions = HashMap::new();
cached_versions.insert(
PackageName::from("serde"),
PackageVersions::latest_only("1.0.0"),
);
let resolved_versions = HashMap::new();
let mut license_prefetch = HashMap::new();
license_prefetch.insert(PackageName::from("serde"), vec!["GPL-3.0".to_string()]);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_license_prefetch(&license_prefetch),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics.is_empty(),
"no VersionData::license_policy attached must produce no license diagnostic, got: {diagnostics:?}"
);
}
#[test]
fn policy_configured_but_no_prefetch_data_produces_no_diagnostic() {
let formatter = MOCK_FORMATTER;
let parse_result = single_dep_parse_result();
let mut cached_versions = HashMap::new();
cached_versions.insert(
PackageName::from("serde"),
PackageVersions::latest_only("1.0.0"),
);
let resolved_versions = HashMap::new();
let policy = LicensePolicy::new(vec![], vec!["GPL-3.0".to_string()]);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions).with_license_policy(&policy),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics.is_empty(),
"a dependency with no known license (NFR-003) must never be treated as a \
violation, got: {diagnostics:?}"
);
}
#[test]
fn denied_license_emits_error_diagnostic() {
let formatter = MOCK_FORMATTER;
let parse_result = single_dep_parse_result();
let mut cached_versions = HashMap::new();
cached_versions.insert(
PackageName::from("serde"),
PackageVersions::latest_only("1.0.0"),
);
let resolved_versions = HashMap::new();
let mut license_prefetch = HashMap::new();
license_prefetch.insert(PackageName::from("serde"), vec!["GPL-3.0".to_string()]);
let policy = LicensePolicy::new(vec![], vec!["GPL-3.0".to_string()]);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_license_prefetch(&license_prefetch)
.with_license_policy(&policy),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].severity, Some(Severity::Error));
assert_eq!(diagnostics[0].message(), "serde: GPL-3.0 denied by policy");
assert_eq!(
diagnostics[0].code(),
Some(LICENSE_POLICY_VIOLATION_DIAGNOSTIC_CODE)
);
assert_eq!(
diagnostics[0].range,
Range::new(Position::new(0, 0), Position::new(0, 5))
);
}
#[test]
fn denied_license_diagnostic_redacts_credential_shaped_name() {
let formatter = MOCK_FORMATTER;
let credential_name = "https://svcacct:glpat-AAAABBBBCCCCDDDD@gitlab.corp/g/p";
let parse_result = MockParseResult {
deps: vec![MockDep {
name: credential_name.into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 11)),
}],
uri: crate::test_util::test_uri("/test/Cargo.toml"),
};
let mut cached_versions = HashMap::new();
cached_versions.insert(
PackageName::from(credential_name),
PackageVersions::latest_only("1.0.0"),
);
let resolved_versions = HashMap::new();
let mut license_prefetch = HashMap::new();
license_prefetch.insert(
PackageName::from(credential_name),
vec!["GPL-3.0".to_string()],
);
let policy = LicensePolicy::new(vec![], vec!["GPL-3.0".to_string()]);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_license_prefetch(&license_prefetch)
.with_license_policy(&policy),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert!(diagnostics[0].message().contains("***@"));
assert!(!diagnostics[0].message().contains("glpat-AAAABBBBCCCCDDDD"));
}
#[test]
fn not_allowed_license_emits_warning_diagnostic() {
let formatter = MOCK_FORMATTER;
let parse_result = single_dep_parse_result();
let mut cached_versions = HashMap::new();
cached_versions.insert(
PackageName::from("serde"),
PackageVersions::latest_only("1.0.0"),
);
let resolved_versions = HashMap::new();
let mut license_prefetch = HashMap::new();
license_prefetch.insert(PackageName::from("serde"), vec!["ISC".to_string()]);
let policy = LicensePolicy::new(vec!["MIT".to_string()], vec![]);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_license_prefetch(&license_prefetch)
.with_license_policy(&policy),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].severity, Some(Severity::Warning));
assert_eq!(
diagnostics[0].message(),
"serde: ISC not on the allowed license list"
);
}
#[test]
fn not_allowed_license_diagnostic_sanitizes_bidi_override_in_license_text() {
let formatter = MOCK_FORMATTER;
let parse_result = single_dep_parse_result();
let mut cached_versions = HashMap::new();
cached_versions.insert(
PackageName::from("serde"),
PackageVersions::latest_only("1.0.0"),
);
let resolved_versions = HashMap::new();
let mut license_prefetch = HashMap::new();
license_prefetch.insert(
PackageName::from("serde"),
vec!["ISC\u{202E}evil".to_string()],
);
let policy = LicensePolicy::new(vec!["MIT".to_string()], vec![]);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_license_prefetch(&license_prefetch)
.with_license_policy(&policy),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert!(!diagnostics[0].message().contains('\u{202E}'));
assert!(diagnostics[0].message().contains("ISC"));
}
#[test]
fn compliant_license_produces_no_diagnostic() {
let formatter = MOCK_FORMATTER;
let parse_result = single_dep_parse_result();
let mut cached_versions = HashMap::new();
cached_versions.insert(
PackageName::from("serde"),
PackageVersions::latest_only("1.0.0"),
);
let resolved_versions = HashMap::new();
let mut license_prefetch = HashMap::new();
license_prefetch.insert(PackageName::from("serde"), vec!["MIT".to_string()]);
let policy = LicensePolicy::new(vec!["MIT".to_string()], vec!["GPL-3.0".to_string()]);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_license_prefetch(&license_prefetch)
.with_license_policy(&policy),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(diagnostics.is_empty());
}
#[test]
fn fires_alongside_unknown_package_diagnostic() {
let formatter = MOCK_FORMATTER;
let parse_result = single_dep_parse_result();
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let mut license_prefetch = HashMap::new();
license_prefetch.insert(PackageName::from("serde"), vec!["GPL-3.0".to_string()]);
let policy = LicensePolicy::new(vec![], vec!["GPL-3.0".to_string()]);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_license_prefetch(&license_prefetch)
.with_license_policy(&policy),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(
diagnostics.len(),
2,
"expected both diagnostics, got: {diagnostics:?}"
);
assert!(
diagnostics
.iter()
.any(|d| d.message().contains("Unknown package"))
);
assert!(
diagnostics
.iter()
.any(|d| d.message().contains("denied by policy"))
);
}
#[test]
fn gradle_dependency_with_recognized_license_is_evaluated_and_compliant() {
let formatter = MOCK_FORMATTER;
let parse_result = single_dep_parse_result();
let mut cached_versions = HashMap::new();
cached_versions.insert(
PackageName::from("serde"),
PackageVersions::latest_only("1.0.0"),
);
let resolved_versions = HashMap::new();
let mut license_prefetch = HashMap::new();
license_prefetch.insert(
PackageName::from("serde"),
vec!["The Apache Software License, Version 2.0".to_string()],
);
let policy = LicensePolicy::new(vec!["Apache-2.0".to_string()], vec![]);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_license_prefetch(&license_prefetch)
.with_license_policy(&policy)
.with_ecosystem(crate::EcosystemId::Gradle)
.with_license_source(crate::LicenseSource::PomFreeText),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics.is_empty(),
"normalized Apache-2.0 must be compliant with an Apache-2.0 allow-list, \
got: {diagnostics:?}"
);
}
#[test]
fn gradle_dependency_with_recognized_license_is_denied() {
let formatter = MOCK_FORMATTER;
let parse_result = single_dep_parse_result();
let mut cached_versions = HashMap::new();
cached_versions.insert(
PackageName::from("serde"),
PackageVersions::latest_only("1.0.0"),
);
let resolved_versions = HashMap::new();
let mut license_prefetch = HashMap::new();
license_prefetch.insert(
PackageName::from("serde"),
vec!["GNU General Public License v3".to_string()],
);
let policy = LicensePolicy::new(vec![], vec!["GPL-3.0".to_string()]);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_license_prefetch(&license_prefetch)
.with_license_policy(&policy)
.with_ecosystem(crate::EcosystemId::Gradle)
.with_license_source(crate::LicenseSource::PomFreeText),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics
.iter()
.any(|d| d.message().contains("denied by policy")),
"normalized GPL-3.0 must be denied by a GPL-3.0 deny-list, got: {diagnostics:?}"
);
}
#[test]
fn gradle_dependency_with_unrecognized_license_fails_closed() {
let formatter = MOCK_FORMATTER;
let parse_result = single_dep_parse_result();
let mut cached_versions = HashMap::new();
cached_versions.insert(
PackageName::from("serde"),
PackageVersions::latest_only("1.0.0"),
);
let resolved_versions = HashMap::new();
let mut license_prefetch = HashMap::new();
license_prefetch.insert(
PackageName::from("serde"),
vec!["Some Bespoke Corporate License".to_string()],
);
let policy = LicensePolicy::new(vec!["Apache-2.0".to_string()], vec![]);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_license_prefetch(&license_prefetch)
.with_license_policy(&policy)
.with_ecosystem(crate::EcosystemId::Gradle)
.with_license_source(crate::LicenseSource::PomFreeText),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics.is_empty(),
"unrecognized free-text license must never be flagged, got: {diagnostics:?}"
);
}
#[test]
fn gradle_partial_normalization_never_manufactures_false_not_allowed() {
let formatter = MOCK_FORMATTER;
let parse_result = single_dep_parse_result();
let mut cached_versions = HashMap::new();
cached_versions.insert(
PackageName::from("serde"),
PackageVersions::latest_only("1.0.0"),
);
let resolved_versions = HashMap::new();
let mut license_prefetch = HashMap::new();
license_prefetch.insert(
PackageName::from("serde"),
vec![
"Eclipse Public License 2.0".to_string(),
"Some Custom OEM License Addendum".to_string(),
],
);
let policy = LicensePolicy::new(vec!["BSD-3-Clause".to_string()], vec![]);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_license_prefetch(&license_prefetch)
.with_license_policy(&policy)
.with_ecosystem(crate::EcosystemId::Gradle)
.with_license_source(crate::LicenseSource::PomFreeText),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics.is_empty(),
"partial normalization must never manufacture a NotAllowed violation, \
got: {diagnostics:?}"
);
}
#[test]
fn gradle_partial_normalization_still_allows_denied_to_fire() {
let formatter = MOCK_FORMATTER;
let parse_result = single_dep_parse_result();
let mut cached_versions = HashMap::new();
cached_versions.insert(
PackageName::from("serde"),
PackageVersions::latest_only("1.0.0"),
);
let resolved_versions = HashMap::new();
let mut license_prefetch = HashMap::new();
license_prefetch.insert(
PackageName::from("serde"),
vec![
"GNU General Public License v3".to_string(),
"Some Custom OEM License Addendum".to_string(),
],
);
let policy = LicensePolicy::new(vec![], vec!["GPL-3.0".to_string()]);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_license_prefetch(&license_prefetch)
.with_license_policy(&policy)
.with_ecosystem(crate::EcosystemId::Gradle)
.with_license_source(crate::LicenseSource::PomFreeText),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics
.iter()
.any(|d| d.message().contains("denied by policy")),
"a recognized denied entry must still fire despite a sibling \
unrecognized entry, got: {diagnostics:?}"
);
}
#[test]
fn gradle_dual_licensed_pom_is_compliant_if_any_normalized_entry_matches_allow() {
let formatter = MOCK_FORMATTER;
let parse_result = single_dep_parse_result();
let mut cached_versions = HashMap::new();
cached_versions.insert(
PackageName::from("serde"),
PackageVersions::latest_only("1.0.0"),
);
let resolved_versions = HashMap::new();
let mut license_prefetch = HashMap::new();
license_prefetch.insert(
PackageName::from("serde"),
vec![
"Apache License, Version 2.0".to_string(),
"GNU General Public License v3".to_string(),
],
);
let policy = LicensePolicy::new(vec!["Apache-2.0".to_string()], vec![]);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_license_prefetch(&license_prefetch)
.with_license_policy(&policy)
.with_ecosystem(crate::EcosystemId::Gradle)
.with_license_source(crate::LicenseSource::PomFreeText),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics.is_empty(),
"dual-licensed dependency must be compliant when the Apache-2.0 side \
matches the allow-list, got: {diagnostics:?}"
);
}
#[test]
fn gradle_dual_licensed_pom_denied_wins_over_allow() {
let formatter = MOCK_FORMATTER;
let parse_result = single_dep_parse_result();
let mut cached_versions = HashMap::new();
cached_versions.insert(
PackageName::from("serde"),
PackageVersions::latest_only("1.0.0"),
);
let resolved_versions = HashMap::new();
let mut license_prefetch = HashMap::new();
license_prefetch.insert(
PackageName::from("serde"),
vec![
"Apache License, Version 2.0".to_string(),
"GNU General Public License v3".to_string(),
],
);
let policy =
LicensePolicy::new(vec!["Apache-2.0".to_string()], vec!["GPL-3.0".to_string()]);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_license_prefetch(&license_prefetch)
.with_license_policy(&policy)
.with_ecosystem(crate::EcosystemId::Gradle)
.with_license_source(crate::LicenseSource::PomFreeText),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics
.iter()
.any(|d| d.message().contains("denied by policy")),
"GPL-3.0 side must be denied even though Apache-2.0 is also present and \
allow-listed, got: {diagnostics:?}"
);
}
#[test]
fn overlong_not_allowed_license_is_truncated_in_message() {
let formatter = MOCK_FORMATTER;
let parse_result = single_dep_parse_result();
let mut cached_versions = HashMap::new();
cached_versions.insert(
PackageName::from("serde"),
PackageVersions::latest_only("1.0.0"),
);
let resolved_versions = HashMap::new();
let overlong = "X".repeat(MAX_DIAGNOSTIC_VALUE_CHARS + 50);
let mut license_prefetch = HashMap::new();
license_prefetch.insert(PackageName::from("serde"), vec![overlong.clone()]);
let policy = LicensePolicy::new(vec!["MIT".to_string()], vec![]);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_license_prefetch(&license_prefetch)
.with_license_policy(&policy),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert!(
diagnostics[0].message().len() < overlong.len(),
"expected the message to be truncated, got: {:?}",
diagnostics[0].message()
);
assert!(diagnostics[0].message().contains('…'));
}
#[test]
fn many_license_entries_are_capped_in_not_allowed_message() {
let formatter = MOCK_FORMATTER;
let parse_result = single_dep_parse_result();
let mut cached_versions = HashMap::new();
cached_versions.insert(
PackageName::from("serde"),
PackageVersions::latest_only("1.0.0"),
);
let resolved_versions = HashMap::new();
let many: Vec<String> = (0..50).map(|i| format!("License-{i}")).collect();
let mut license_prefetch = HashMap::new();
license_prefetch.insert(PackageName::from("serde"), many);
let policy = LicensePolicy::new(vec!["MIT".to_string()], vec![]);
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_license_prefetch(&license_prefetch)
.with_license_policy(&policy),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert!(
diagnostics[0].message().contains("more)"),
"expected the entry list to be capped with a '(+N more)' suffix, got: {:?}",
diagnostics[0].message()
);
}
}
mod typosquat_tests {
use super::*;
use crate::deps_dev::DepsDevClient;
use crate::position::{Position, Range};
use crate::{EcosystemId, HttpCache, TyposquatSignal};
fn single_dep_parse_result(name: &str) -> MockParseResult {
MockParseResult {
deps: vec![MockDep {
name: name.into(),
version_req: "1.0.0".into(),
version_range: Range::new(Position::new(0, 10), Position::new(0, 20)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
}],
uri: crate::test_util::test_uri("/test/package.json"),
}
}
fn crossenv_signal() -> TyposquatSignal {
TyposquatSignal {
suspected_name: "cross-env".to_string(),
declared_dependent_count: 3,
suspected_dependent_count: 900,
}
}
struct MockGitSourceDep {
name: PackageName,
name_range: Range,
}
impl Dependency for MockGitSourceDep {
fn name(&self) -> &PackageName {
&self.name
}
fn name_range(&self) -> Range {
self.name_range
}
fn version_requirement(&self) -> Option<&VersionReq> {
None
}
fn version_range(&self) -> Option<Range> {
None
}
fn source(&self) -> crate::parser::DependencySource {
crate::parser::DependencySource::Git {
url: "https://example.com/private/repo.git".to_string(),
rev: None,
}
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
#[test]
fn no_signal_entry_produces_no_diagnostic() {
let formatter = MOCK_FORMATTER;
let parse_result = single_dep_parse_result("serde");
let mut cached_versions = HashMap::new();
cached_versions.insert(
PackageName::from("serde"),
PackageVersions::latest_only("1.0.0"),
);
let resolved_versions = HashMap::new();
let typosquat: HashMap<PackageName, TyposquatSignal> = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_typosquat_prefetch(&typosquat),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics.is_empty(),
"an empty typosquat map must produce no diagnostic, got: {diagnostics:?}"
);
}
#[test]
fn no_typosquat_field_attached_produces_no_diagnostic() {
let formatter = MOCK_FORMATTER;
let parse_result = single_dep_parse_result("crossenv");
let mut cached_versions = HashMap::new();
cached_versions.insert(
PackageName::from("crossenv"),
PackageVersions::latest_only("1.0.0"),
);
let resolved_versions = HashMap::new();
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
diagnostics.is_empty(),
"VersionData::typosquat left None must produce no diagnostic, got: {diagnostics:?}"
);
}
#[test]
fn matching_signal_produces_hint_diagnostic() {
let formatter = MOCK_FORMATTER;
let parse_result = single_dep_parse_result("crossenv");
let mut cached_versions = HashMap::new();
cached_versions.insert(
PackageName::from("crossenv"),
PackageVersions::latest_only("1.0.0"),
);
let resolved_versions = HashMap::new();
let mut typosquat: HashMap<PackageName, TyposquatSignal> = HashMap::new();
typosquat.insert(PackageName::from("crossenv"), crossenv_signal());
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_typosquat_prefetch(&typosquat),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert_eq!(diagnostics.len(), 1);
assert_eq!(diagnostics[0].severity, Some(Severity::Hint));
assert_eq!(diagnostics[0].code(), Some(TYPOSQUAT_DIAGNOSTIC_CODE));
assert!(diagnostics[0].message().contains("cross-env"));
}
#[test]
fn matching_signal_for_now_private_source_does_not_render() {
let formatter = MOCK_FORMATTER;
let parse_result = MockMixedParseResult {
deps: vec![Box::new(MockGitSourceDep {
name: PackageName::from("crossenv"),
name_range: Range::new(Position::new(0, 0), Position::new(0, 8)),
})],
uri: crate::test_util::test_uri("/test/package.json"),
};
let cached_versions = HashMap::new();
let resolved_versions = HashMap::new();
let mut typosquat: HashMap<PackageName, TyposquatSignal> = HashMap::new();
typosquat.insert(PackageName::from("crossenv"), crossenv_signal());
let diagnostics = generate_diagnostics_from_cache(
&parse_result,
VersionData::new(&cached_versions, &resolved_versions)
.with_typosquat_prefetch(&typosquat),
&formatter,
parse_result.uri(),
crate::freshness::FreshnessSettings::default(),
DiagnosticSeverities::default(),
PublishTime::now(),
);
assert!(
!diagnostics
.iter()
.any(|d| d.code() == Some(TYPOSQUAT_DIAGNOSTIC_CODE)),
"a stale signal for a now-git-sourced dependency must not render, got: \
{diagnostics:?}"
);
}
#[tokio::test]
async fn fetch_typosquat_signals_no_client_issues_zero_requests() {
let mut server = mockito::Server::new_async().await;
let package_call = server
.mock("GET", mockito::Matcher::Any)
.expect(0)
.create_async()
.await;
let parse_result = single_dep_parse_result("crossenv");
let outcome = fetch_typosquat_signals(
EcosystemId::Npm,
&parse_result,
&MOCK_FORMATTER,
crate::NetworkMode::Online,
None,
)
.await;
assert!(outcome.signals.is_empty());
assert_eq!(outcome.completeness, FetchCompleteness::Complete);
package_call.assert_async().await;
drop(server);
}
#[tokio::test]
async fn fetch_typosquat_signals_offline_issues_zero_requests() {
let mut server = mockito::Server::new_async().await;
let package_call = server
.mock("GET", mockito::Matcher::Any)
.expect(0)
.create_async()
.await;
let client = Arc::new(DepsDevClient::for_test(
Arc::new(HttpCache::new()),
server.url(),
));
let parse_result = single_dep_parse_result("crossenv");
let outcome = fetch_typosquat_signals(
EcosystemId::Npm,
&parse_result,
&MOCK_FORMATTER,
crate::NetworkMode::Offline,
Some(&client),
)
.await;
assert!(outcome.signals.is_empty());
assert_eq!(outcome.completeness, FetchCompleteness::Complete);
package_call.assert_async().await;
}
#[tokio::test]
async fn fetch_typosquat_signals_unsupported_ecosystem_issues_zero_requests() {
let mut server = mockito::Server::new_async().await;
let package_call = server
.mock("GET", mockito::Matcher::Any)
.expect(0)
.create_async()
.await;
let client = Arc::new(DepsDevClient::for_test(
Arc::new(HttpCache::new()),
server.url(),
));
let parse_result = single_dep_parse_result("crossenv");
let outcome = fetch_typosquat_signals(
EcosystemId::Deno,
&parse_result,
&MOCK_FORMATTER,
crate::NetworkMode::Online,
Some(&client),
)
.await;
assert!(outcome.signals.is_empty());
assert_eq!(outcome.completeness, FetchCompleteness::Complete);
package_call.assert_async().await;
}
#[tokio::test]
async fn fetch_typosquat_signals_resolves_positive_case() {
let mut server = mockito::Server::new_async().await;
let _similarity = server
.mock(
"GET",
"/v3alpha/systems/npm/packages/crossenv:similarlyNamedPackages",
)
.with_status(200)
.with_body(
r#"{"packageKey": {"name": "crossenv"}, "packages": [{"packageKey": {"name": "cross-env"}}]}"#,
)
.create_async()
.await;
let _declared_package = server
.mock("GET", "/v3alpha/systems/npm/packages/crossenv")
.with_status(200)
.with_body(
r#"{"versions": [{"versionKey": {"version": "1.0.0"}, "isDefault": true}]}"#,
)
.create_async()
.await;
let _declared_dependents = server
.mock(
"GET",
"/v3alpha/systems/npm/packages/crossenv/versions/1.0.0:dependents",
)
.with_status(200)
.with_body(r#"{"dependentCount": 3}"#)
.create_async()
.await;
let _candidate_package = server
.mock("GET", "/v3alpha/systems/npm/packages/cross-env")
.with_status(200)
.with_body(
r#"{"versions": [{"versionKey": {"version": "7.0.0"}, "isDefault": true}]}"#,
)
.create_async()
.await;
let _candidate_dependents = server
.mock(
"GET",
"/v3alpha/systems/npm/packages/cross-env/versions/7.0.0:dependents",
)
.with_status(200)
.with_body(r#"{"dependentCount": 900}"#)
.create_async()
.await;
let client = Arc::new(DepsDevClient::for_test(
Arc::new(HttpCache::new()),
server.url(),
));
let parse_result = single_dep_parse_result("crossenv");
let outcome = fetch_typosquat_signals(
EcosystemId::Npm,
&parse_result,
&MOCK_FORMATTER,
crate::NetworkMode::Online,
Some(&client),
)
.await;
assert_eq!(outcome.completeness, FetchCompleteness::Complete);
let signal = outcome
.signals
.get(&PackageName::from("crossenv"))
.expect("signal expected");
assert_eq!(signal.suspected_name, "cross-env");
}
#[tokio::test]
async fn fetch_typosquat_signals_empty_parse_result_issues_zero_requests() {
let mut server = mockito::Server::new_async().await;
let package_call = server
.mock("GET", mockito::Matcher::Any)
.expect(0)
.create_async()
.await;
let client = Arc::new(DepsDevClient::for_test(
Arc::new(HttpCache::new()),
server.url(),
));
let parse_result = MockParseResult {
deps: vec![],
uri: crate::test_util::test_uri("/test/package.json"),
};
let outcome = fetch_typosquat_signals(
EcosystemId::Npm,
&parse_result,
&MOCK_FORMATTER,
crate::NetworkMode::Online,
Some(&client),
)
.await;
assert!(outcome.signals.is_empty());
assert_eq!(outcome.completeness, FetchCompleteness::Complete);
package_call.assert_async().await;
}
#[tokio::test]
async fn fetch_typosquat_signals_non_public_registry_source_issues_zero_requests() {
let mut server = mockito::Server::new_async().await;
let package_call = server
.mock("GET", mockito::Matcher::Any)
.expect(0)
.create_async()
.await;
let client = Arc::new(DepsDevClient::for_test(
Arc::new(HttpCache::new()),
server.url(),
));
let parse_result = MockMixedParseResult {
deps: vec![Box::new(MockGitSourceDep {
name: PackageName::from("private-pkg"),
name_range: Range::new(Position::new(0, 0), Position::new(0, 11)),
})],
uri: crate::test_util::test_uri("/test/package.json"),
};
let outcome = fetch_typosquat_signals(
EcosystemId::Npm,
&parse_result,
&MOCK_FORMATTER,
crate::NetworkMode::Online,
Some(&client),
)
.await;
assert!(outcome.signals.is_empty());
assert_eq!(outcome.completeness, FetchCompleteness::Complete);
package_call.assert_async().await;
}
}
}