use std::collections::HashMap;
use std::sync::Arc;
use std::time::Duration;
use crate::error::DepsError;
use crate::licenses::LicensePolicy;
use crate::osv::{
CandidateStatusMap, CandidateStatuses, LatestStatusMap, ScanOutcome, StructuralSkipReason,
UpgradeStatus, VulnKey, VulnSeverity, VulnerabilityMap,
};
use crate::position::{Position, Range};
use crate::{
ConcreteVersion, CooldownWindow, Dependency, Deprecation, DepsDevClient, EcosystemId,
FetchFailure, GossipFindings, LicenseSource, PackageName, PublishTime, RemovalStatus,
TyposquatSignal, VersionReq,
};
#[cfg(feature = "lsp-responses")]
mod code_actions;
#[cfg(feature = "lsp-responses")]
mod code_lenses;
mod diagnostics;
mod formatter;
mod git_ref;
#[cfg(feature = "lsp-responses")]
mod hover;
#[cfg(feature = "lsp-responses")]
mod hover_markdown;
mod in_use_version;
#[cfg(feature = "lsp-responses")]
mod inlay_hints;
#[cfg(test)]
pub(crate) mod test_support;
#[cfg(test)]
#[cfg(feature = "lsp-responses")]
pub(crate) mod test_support_lsp;
#[cfg(feature = "lsp-responses")]
pub use crate::edit::dedup_overlapping_edits;
#[cfg(feature = "lsp-responses")]
pub use code_actions::generate_code_actions;
#[cfg(feature = "lsp-responses")]
pub use code_lenses::{
PIN_ALL_TO_SHA_COMMAND_ID, PinNoun, build_pin_all_to_sha_lens, collect_update_all_edits,
generate_code_lenses,
};
pub use diagnostics::{
BoundedVersionReq, DEPRECATED_DIAGNOSTIC_CODE, DiagnosticSeverities,
LICENSE_POLICY_VIOLATION_DIAGNOSTIC_CODE, MAX_DIAGNOSTIC_VALUE_CHARS, MAX_REQUIREMENT_LEN,
TYPOSQUAT_DIAGNOSTIC_CODE, TyposquatFetchOutcome, UNSATISFIABLE_DIAGNOSTIC_CODE,
compile_requirement_unless, fetch_gossip_findings_batch, fetch_typosquat_signals,
force_refresh_gossip_findings, generate_diagnostics_from_cache, redact_name_for_diagnostic,
redact_requirement_for_diagnostic, requirement_is_unsatisfiable, requirement_len_exceeds_cap,
sanitize_advisory_text_for_diagnostic, sanitize_and_truncate_for_diagnostic,
truncate_for_diagnostic,
};
pub use diagnostics::requirement_is_oversized;
#[cfg(feature = "lsp-responses")]
pub(crate) use diagnostics::MAX_VERSION_DIAGNOSTIC_CHARS;
pub use formatter::{
BareMeaning, BuildMetadataPolicy, DiagnosticMessages, DiagnosticPolicy, EcosystemFormatter,
OsvNameAvailability, OsvNaming, PackageNaming, PackageRendering, RequirementGate,
RequirementResolution, RequirementRewriteShape, SourcePolicy, bare_meaning,
classify_requirement_rewrite_shape, format_version_replacing_by_shape, requirement_is_compound,
up_to_date_for_comparators_via_compiled_matcher, up_to_date_via_compiled_matcher,
};
pub use git_ref::{
CommitSha, MAX_FALLBACK_SCAN_BYTES, MarkedScalar, ResolvedPin, ShaPinLookup, SiblingTags,
TagIndex, byte_span_to_range, extends_tag, is_full_sha, is_null_tag, is_partial_semver_shaped,
is_plain_null, is_tag_shaped, locate_value_span, marker_byte_offset, match_v_prefix_style,
short_sha,
};
#[cfg(feature = "lsp-responses")]
pub use git_ref::{
ResolvedShaPin, ShaPinning, build_sha_pin_action, sha_pin_text_edit, splice_hover_line,
splice_resolved_line,
};
#[cfg(feature = "lsp-responses")]
pub use hover::{CMD_DOT_FOOTER, generate_hover};
#[cfg(feature = "lsp-responses")]
pub use hover_markdown::{FieldKind, HoverMarkdown, SafeNumber};
pub use in_use_version::{
InUseVersions, concrete_pin_version, has_unqueryable_resolved_pin, is_full_semver_shape,
resolve_in_use_version, resolve_in_use_versions,
};
#[cfg(feature = "lsp-responses")]
pub use inlay_hints::generate_inlay_hints;
pub const HOVER_RECENT_VERSIONS: usize = 8;
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CooldownFallback {
pub version: ConcreteVersion,
pub published_at: PublishTime,
}
impl CooldownFallback {
#[must_use]
pub const fn new(version: ConcreteVersion, published_at: PublishTime) -> Self {
Self {
version,
published_at,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CooldownBlocker {
Gossip,
Local {
published_at: PublishTime,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CooldownDisposition<'a> {
NotEvaluated,
Cleared,
Blocked {
by: CooldownBlocker,
fallback: Option<&'a CooldownFallback>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CooldownPrecedence {
Cleared,
Blocked(CooldownBlocker),
}
#[must_use]
pub fn cooldown_precedence(
gossip_prefetch: Option<&HashMap<PackageName, GossipFindings>>,
name: &PackageName,
version: &str,
published_at: Option<PublishTime>,
cooldown: CooldownWindow,
now: PublishTime,
) -> CooldownPrecedence {
match gossip_cooldown_for(gossip_prefetch, name, version, now) {
GossipCooldownLookup::Active => CooldownPrecedence::Blocked(CooldownBlocker::Gossip),
GossipCooldownLookup::NotActive => CooldownPrecedence::Cleared,
GossipCooldownLookup::Unavailable => local_cooldown_precedence(published_at, cooldown, now),
}
}
#[must_use]
pub fn local_cooldown_precedence(
published_at: Option<PublishTime>,
cooldown: CooldownWindow,
now: PublishTime,
) -> CooldownPrecedence {
match published_at {
Some(published_at) if cooldown.contains(published_at.age_secs_from(now)) => {
CooldownPrecedence::Blocked(CooldownBlocker::Local { published_at })
}
_ => CooldownPrecedence::Cleared,
}
}
#[must_use]
pub fn cooldown_disposition<'a>(
versions: &'a PackageVersions,
name: &PackageName,
freshness: crate::freshness::FreshnessSettings,
gossip_prefetch: Option<&HashMap<PackageName, GossipFindings>>,
now: PublishTime,
) -> CooldownDisposition<'a> {
let crate::freshness::FreshnessSettings::Enabled { cooldown } = freshness else {
return CooldownDisposition::NotEvaluated;
};
let fallback = versions.cooldown_fallback.as_ref().filter(|fallback| {
matches!(
local_cooldown_precedence(Some(fallback.published_at), cooldown, now),
CooldownPrecedence::Cleared
)
});
match cooldown_precedence(
gossip_prefetch,
name,
versions.latest.as_str(),
versions.published_at,
cooldown,
now,
) {
CooldownPrecedence::Cleared => CooldownDisposition::Cleared,
CooldownPrecedence::Blocked(by) => CooldownDisposition::Blocked { by, fallback },
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FallbackEditVerdict {
Writable,
Rejected(FallbackEditRejection),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FallbackEditRejection {
CandidateSpanMismatch,
FallbackUnlisted,
OriginalOversized,
OriginalUncompilable,
OriginalAlreadyUpToDate,
OriginalResolvesPastFallback,
OriginalExcludesFallback,
ReparseFailed,
OccurrenceNotUnique,
EditedOversized,
EditedUncompilable,
EditedExcludesFallback,
EditedAdmitsNewer,
}
#[must_use]
pub fn fallback_edit_excludes_newer(
formatter: &dyn crate::lsp_helpers::EcosystemFormatter,
reparse: &dyn crate::edit::ManifestReparse,
content: &str,
dep: &dyn Dependency,
candidate: &crate::edit::ManifestEdit,
fallback: &ConcreteVersion,
available: &[ConcreteVersion],
) -> FallbackEditVerdict {
use FallbackEditRejection::{
CandidateSpanMismatch, EditedAdmitsNewer, EditedExcludesFallback, EditedOversized,
EditedUncompilable, FallbackUnlisted, OccurrenceNotUnique, OriginalAlreadyUpToDate,
OriginalExcludesFallback, OriginalOversized, OriginalResolvesPastFallback,
OriginalUncompilable, ReparseFailed,
};
if Some(candidate.range) != dep.version_range() {
return FallbackEditVerdict::Rejected(CandidateSpanMismatch);
}
let Some(r0) = dep.version_requirement() else {
return FallbackEditVerdict::Rejected(OriginalUncompilable);
};
let Some(r0) = BoundedVersionReq::new(r0) else {
return FallbackEditVerdict::Rejected(OriginalOversized);
};
let Some(r0_matcher) = formatter.compile_bounded_requirement(r0) else {
return FallbackEditVerdict::Rejected(OriginalUncompilable);
};
let Some(fallback_pos) = available.iter().position(|v| v == fallback) else {
return FallbackEditVerdict::Rejected(FallbackUnlisted);
};
if formatter.is_bounded_requirement_up_to_date(r0, fallback) {
return FallbackEditVerdict::Rejected(OriginalAlreadyUpToDate);
}
let r0_floor = available
.iter()
.rposition(|v| r0_matcher.matches(v) == Some(true));
if r0_floor.is_none_or(|floor| floor < fallback_pos) {
return FallbackEditVerdict::Rejected(OriginalResolvesPastFallback);
}
let fallback_matches_r0 =
r0_floor == Some(fallback_pos) || r0_matcher.matches(fallback) == Some(true);
if !fallback_matches_r0 {
if r0_matcher.explicitly_excludes(fallback) {
return FallbackEditVerdict::Rejected(OriginalExcludesFallback);
}
#[allow(clippy::indexing_slicing)]
let newer_than_fallback = &available[..fallback_pos];
if newer_than_fallback
.iter()
.any(|v| r0_matcher.matches(v) == Some(true))
{
return FallbackEditVerdict::Rejected(OriginalExcludesFallback);
}
}
let edited_content = crate::edit::apply_edits(content, std::slice::from_ref(candidate));
let Some(parsed) = reparse.reparse(&edited_content) else {
return FallbackEditVerdict::Rejected(ReparseFailed);
};
let key = formatter.normalize_package_name(dep.name());
let orig_start = dep.version_range().map(|r| r.start);
let mut candidates = parsed.dependencies().into_iter().filter(|edited_dep| {
formatter.normalize_package_name(edited_dep.name()) == key
&& edited_dep.version_range().map(|r| r.start) == orig_start
});
let Some(edited_dep) = candidates.next() else {
return FallbackEditVerdict::Rejected(OccurrenceNotUnique);
};
if candidates.next().is_some() {
return FallbackEditVerdict::Rejected(OccurrenceNotUnique);
}
let Some(r1) = edited_dep.version_requirement() else {
return FallbackEditVerdict::Rejected(EditedUncompilable);
};
let Some(r1) = BoundedVersionReq::new(r1) else {
return FallbackEditVerdict::Rejected(EditedOversized);
};
let Some(matcher) = formatter.compile_bounded_requirement(r1) else {
return FallbackEditVerdict::Rejected(EditedUncompilable);
};
if matcher.matches(fallback) != Some(true) {
return FallbackEditVerdict::Rejected(EditedExcludesFallback);
}
#[allow(clippy::indexing_slicing)]
let strictly_newer = &available[..fallback_pos];
if strictly_newer
.iter()
.any(|v| formatter.bounded_requirement_already_resolves_to(r1, v))
{
return FallbackEditVerdict::Rejected(EditedAdmitsNewer);
}
FallbackEditVerdict::Writable
}
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PackageVersions {
pub latest: ConcreteVersion,
pub available: Arc<[ConcreteVersion]>,
pub yanked: Arc<[(ConcreteVersion, RemovalStatus)]>,
pub published_at: Option<crate::freshness::PublishTime>,
pub gossip_excluded_version: Option<ConcreteVersion>,
pub cooldown_fallback: Option<CooldownFallback>,
}
impl PackageVersions {
#[must_use]
pub fn new(latest: ConcreteVersion, available: Arc<[ConcreteVersion]>) -> Self {
Self {
latest,
available,
yanked: Arc::from(Vec::new()),
published_at: None,
gossip_excluded_version: None,
cooldown_fallback: None,
}
}
#[must_use]
pub fn with_yanked(mut self, yanked: Arc<[(ConcreteVersion, RemovalStatus)]>) -> Self {
self.yanked = yanked;
self
}
#[must_use]
pub const fn with_published_at(mut self, published_at: crate::freshness::PublishTime) -> Self {
self.published_at = Some(published_at);
self
}
#[must_use]
pub fn with_gossip_excluded_version(mut self, excluded: ConcreteVersion) -> Self {
self.gossip_excluded_version = Some(excluded);
self
}
#[must_use]
pub fn with_cooldown_fallback(mut self, fallback: CooldownFallback) -> Self {
self.cooldown_fallback = Some(fallback);
self
}
pub fn latest_only(latest: impl Into<ConcreteVersion>) -> Self {
let latest = latest.into();
let available = Arc::from(vec![latest.clone()]);
Self {
latest,
available,
yanked: Arc::from(Vec::new()),
published_at: None,
gossip_excluded_version: None,
cooldown_fallback: None,
}
}
pub fn latest_without_list(latest: impl Into<ConcreteVersion>) -> Self {
Self {
latest: latest.into(),
available: Arc::from(Vec::new()),
yanked: Arc::from(Vec::new()),
published_at: None,
gossip_excluded_version: None,
cooldown_fallback: None,
}
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Default, PartialEq)]
pub struct DependencyOutcome {
pub yanked: Option<(ConcreteVersion, RemovalStatus)>,
pub deprecation: Option<Deprecation>,
pub fetch_failure: Option<FetchFailure>,
pub no_comparable_versions: bool,
}
impl DependencyOutcome {
#[must_use]
pub const fn is_empty(&self) -> bool {
self.yanked.is_none()
&& self.deprecation.is_none()
&& self.fetch_failure.is_none()
&& !self.no_comparable_versions
}
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct DependencyOutcomes(HashMap<String, DependencyOutcome>);
impl DependencyOutcomes {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn get(&self, name: &str) -> Option<&DependencyOutcome> {
self.0.get(name)
}
#[must_use]
pub fn yanked(&self, name: &str) -> Option<&(ConcreteVersion, RemovalStatus)> {
self.0.get(name)?.yanked.as_ref()
}
#[must_use]
pub fn deprecation(&self, name: &str) -> Option<&Deprecation> {
self.0.get(name)?.deprecation.as_ref()
}
#[must_use]
pub fn fetch_failure(&self, name: &str) -> Option<&FetchFailure> {
self.0.get(name)?.fetch_failure.as_ref()
}
#[must_use]
pub fn no_comparable_versions(&self, name: &str) -> bool {
self.0.get(name).is_some_and(|o| o.no_comparable_versions)
}
pub fn set_yanked(&mut self, name: String, yanked: (ConcreteVersion, RemovalStatus)) {
self.0.entry(name).or_default().yanked = Some(yanked);
}
pub fn set_deprecation(&mut self, name: String, deprecation: Deprecation) {
self.0.entry(name).or_default().deprecation = Some(deprecation);
}
pub fn set_fetch_failure(&mut self, name: String, failure: FetchFailure) {
self.0.entry(name).or_default().fetch_failure = Some(failure);
}
pub fn set_fetch_failure_if_absent(&mut self, name: String, failure: FetchFailure) {
self.0
.entry(name)
.or_default()
.fetch_failure
.get_or_insert(failure);
}
pub fn set_no_comparable_versions(&mut self, name: String) {
self.0.entry(name).or_default().no_comparable_versions = true;
}
pub fn clear_no_comparable_versions(&mut self, name: &str) {
if let Some(entry) = self.0.get_mut(name) {
entry.no_comparable_versions = false;
self.prune(name);
}
}
pub fn clear_yanked(&mut self, name: &str) {
if let Some(entry) = self.0.get_mut(name) {
entry.yanked = None;
self.prune(name);
}
}
pub fn clear_deprecation(&mut self, name: &str) {
if let Some(entry) = self.0.get_mut(name) {
entry.deprecation = None;
self.prune(name);
}
}
pub fn clear_fetch_failure(&mut self, name: &str) {
if let Some(entry) = self.0.get_mut(name) {
entry.fetch_failure = None;
self.prune(name);
}
}
pub fn remove(&mut self, name: &str) {
self.0.remove(name);
}
pub fn clear_all_fetch_failures(&mut self) {
let names: Vec<String> = self.0.keys().cloned().collect();
for name in names {
self.clear_fetch_failure(&name);
}
}
fn prune(&mut self, name: &str) {
if self.0.get(name).is_some_and(DependencyOutcome::is_empty) {
self.0.remove(name);
}
}
#[must_use]
pub fn len(&self) -> usize {
self.0.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
#[must_use]
pub fn yanked_count(&self) -> usize {
self.0.values().filter(|o| o.yanked.is_some()).count()
}
#[must_use]
pub fn deprecation_count(&self) -> usize {
self.0.values().filter(|o| o.deprecation.is_some()).count()
}
#[must_use]
pub fn fetch_failure_count(&self) -> usize {
self.0
.values()
.filter(|o| o.fetch_failure.is_some())
.count()
}
#[must_use]
pub fn with_yanked(
mut self,
name: impl Into<String>,
yanked: (ConcreteVersion, RemovalStatus),
) -> Self {
self.set_yanked(name.into(), yanked);
self
}
#[must_use]
pub fn with_deprecation(mut self, name: impl Into<String>, deprecation: Deprecation) -> Self {
self.set_deprecation(name.into(), deprecation);
self
}
#[must_use]
pub fn with_fetch_failure(mut self, name: impl Into<String>, failure: FetchFailure) -> Self {
self.set_fetch_failure(name.into(), failure);
self
}
#[must_use]
pub fn with_no_comparable_versions(mut self, name: impl Into<String>) -> Self {
self.set_no_comparable_versions(name.into());
self
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy)]
pub struct VersionData<'a> {
pub cached: &'a HashMap<PackageName, PackageVersions>,
pub resolved: &'a HashMap<PackageName, ConcreteVersion>,
pub resolved_version_candidates: Option<&'a HashMap<PackageName, Vec<ConcreteVersion>>>,
pub vulnerabilities: Option<&'a VulnerabilityMap>,
pub outcomes: Option<&'a DependencyOutcomes>,
pub ecosystem: Option<EcosystemId>,
pub network: crate::NetworkMode,
pub trust: Option<&'a Arc<DepsDevClient>>,
pub license_prefetch: Option<&'a HashMap<PackageName, Vec<String>>>,
pub license_source: Option<LicenseSource>,
pub license_policy: Option<&'a LicensePolicy>,
pub typosquat_prefetch: Option<&'a HashMap<PackageName, TyposquatSignal>>,
pub gossip_prefetch: Option<&'a HashMap<PackageName, GossipFindings>>,
pub gossip_client: Option<&'a Arc<DepsDevClient>>,
pub latest_status: Option<&'a LatestStatusMap>,
pub candidate_status: Option<&'a CandidateStatusMap>,
}
impl<'a> VersionData<'a> {
pub fn new(
cached: &'a HashMap<PackageName, PackageVersions>,
resolved: &'a HashMap<PackageName, ConcreteVersion>,
) -> Self {
Self {
cached,
resolved,
resolved_version_candidates: None,
vulnerabilities: None,
outcomes: None,
ecosystem: None,
network: crate::NetworkMode::Online,
trust: None,
license_prefetch: None,
license_source: None,
license_policy: None,
typosquat_prefetch: None,
gossip_prefetch: None,
gossip_client: None,
latest_status: None,
candidate_status: None,
}
}
#[must_use]
pub const fn with_resolved_version_candidates(
mut self,
candidates: &'a HashMap<PackageName, Vec<ConcreteVersion>>,
) -> Self {
self.resolved_version_candidates = Some(candidates);
self
}
#[must_use]
pub fn with_vulnerabilities(mut self, vulnerabilities: &'a VulnerabilityMap) -> Self {
self.vulnerabilities = Some(vulnerabilities);
self
}
#[must_use]
pub fn with_latest_status(mut self, latest_status: &'a LatestStatusMap) -> Self {
self.latest_status = Some(latest_status);
self
}
#[must_use]
pub fn with_candidate_status(mut self, candidate_status: &'a CandidateStatusMap) -> Self {
self.candidate_status = Some(candidate_status);
self
}
#[must_use]
pub fn with_outcomes(mut self, outcomes: &'a DependencyOutcomes) -> Self {
self.outcomes = Some(outcomes);
self
}
#[must_use]
pub const fn with_ecosystem(mut self, ecosystem: EcosystemId) -> Self {
self.ecosystem = Some(ecosystem);
self
}
#[must_use]
pub const fn with_network(mut self, network: crate::NetworkMode) -> Self {
self.network = network;
self
}
#[must_use]
pub const fn with_trust(mut self, client: &'a Arc<DepsDevClient>) -> Self {
self.trust = Some(client);
self
}
#[must_use]
pub const fn with_license_prefetch(
mut self,
licenses: &'a HashMap<PackageName, Vec<String>>,
) -> Self {
self.license_prefetch = Some(licenses);
self
}
#[must_use]
pub const fn with_license_source(mut self, source: LicenseSource) -> Self {
self.license_source = Some(source);
self
}
#[must_use]
pub const fn with_license_policy(mut self, policy: &'a LicensePolicy) -> Self {
self.license_policy = Some(policy);
self
}
#[must_use]
pub const fn with_typosquat_prefetch(
mut self,
typosquat_prefetch: &'a HashMap<PackageName, TyposquatSignal>,
) -> Self {
self.typosquat_prefetch = Some(typosquat_prefetch);
self
}
#[must_use]
pub const fn with_gossip_prefetch(
mut self,
gossip_prefetch: &'a HashMap<PackageName, GossipFindings>,
) -> Self {
self.gossip_prefetch = Some(gossip_prefetch);
self
}
#[must_use]
pub const fn with_gossip_client(mut self, client: &'a Arc<DepsDevClient>) -> Self {
self.gossip_client = Some(client);
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GossipCooldownLookup {
Unavailable,
Active,
NotActive,
}
#[must_use]
pub fn gossip_cooldown_for(
gossip_prefetch: Option<&HashMap<PackageName, GossipFindings>>,
name: &PackageName,
comparand: &str,
now: PublishTime,
) -> GossipCooldownLookup {
let Some(findings) = gossip_prefetch.and_then(|m| m.get(name)) else {
return GossipCooldownLookup::Unavailable;
};
if findings.version != comparand {
return GossipCooldownLookup::Unavailable;
}
match findings.cooldown.as_ref() {
Some(cooldown) if cooldown.is_active(now) => GossipCooldownLookup::Active,
Some(_) => GossipCooldownLookup::NotActive,
None => GossipCooldownLookup::Unavailable,
}
}
pub const COMPLETION_SEARCH_TIMEOUT: Duration = Duration::from_secs(2);
pub const REGISTRY_FETCH_BUDGET: Duration = Duration::from_secs(10);
#[cfg(feature = "lsp-responses")]
async fn await_versions_fetch<T, E>(
fetch: impl std::future::Future<Output = Result<T, E>>,
package: &PackageName,
context: &'static str,
) -> (Option<T>, bool) {
match tokio::time::timeout(REGISTRY_FETCH_BUDGET, fetch).await {
Ok(result) => (result.ok(), false),
Err(_) => {
tracing::warn!(
package = %package.for_tracing(),
context,
timeout_secs = REGISTRY_FETCH_BUDGET.as_secs(),
"primary registry version fetch timed out"
);
(None, true)
}
}
}
pub fn utf16_to_byte_offset(s: &str, utf16_offset: u32) -> Option<usize> {
let mut utf16_count = 0u32;
for (byte_idx, ch) in s.char_indices() {
if utf16_count >= utf16_offset {
return Some(byte_idx);
}
#[expect(
clippy::cast_possible_truncation,
reason = "char::len_utf16 always returns 1 or 2, so this cast never truncates"
)]
{
utf16_count += ch.len_utf16() as u32;
}
}
if utf16_count == utf16_offset {
return Some(s.len());
}
None
}
#[expect(
clippy::string_slice,
reason = "end is floor_char_boundary-clamped just above, mirroring the already-hardened \
LineOffsetTable::byte_offset_to_position (lsp_helpers/mod.rs)"
)]
pub fn byte_to_utf16_offset(s: &str, byte_offset: usize) -> u32 {
let end = s.floor_char_boundary(byte_offset);
u32::try_from(s[..end].encode_utf16().count()).unwrap_or(u32::MAX)
}
pub fn position_in_range(pos: Position, range: Range) -> bool {
if pos.line < range.start.line || pos.line > range.end.line {
return false;
}
if pos.line == range.start.line && pos.character < range.start.character {
return false;
}
if pos.line == range.end.line && pos.character > range.end.character {
return false;
}
true
}
pub(crate) fn version_range_is_synthetic_empty(dep: &dyn Dependency) -> bool {
dep.version_requirement().is_none() && dep.version_range().is_some_and(|r| r.start == r.end)
}
pub fn resolve_scan_outcome<'a>(
vulnerabilities: &'a VulnerabilityMap,
dep: &dyn Dependency,
keys: Option<&crate::osv::VulnKeys>,
normalized_name: &str,
) -> Option<&'a ScanOutcome> {
resolve_by_vuln_key(vulnerabilities, dep, keys, normalized_name)
}
fn resolve_by_vuln_key<'a, V>(
map: &'a HashMap<VulnKey, V>,
dep: &dyn Dependency,
keys: Option<&crate::osv::VulnKeys>,
normalized_name: &str,
) -> Option<&'a V> {
keys.and_then(|k| k.get(&dep.name_range()))
.and_then(|key| map.get(key))
.or_else(|| map.get(&VulnKey::from_name(normalized_name.to_string())))
.or_else(|| map.get(&VulnKey::from_name(dep.name().as_str().to_string())))
}
pub fn resolve_latest_status<'a>(
latest_status: &'a LatestStatusMap,
dep: &dyn Dependency,
keys: Option<&crate::osv::VulnKeys>,
normalized_name: &str,
) -> Option<&'a UpgradeStatus> {
resolve_by_vuln_key(latest_status, dep, keys, normalized_name)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LatestVerdict {
NotApplicable,
Verified,
Flagged {
advisory_ids: Vec<String>,
malicious: bool,
},
Unverified,
}
#[must_use]
pub fn latest_verdict(
latest_status: Option<&LatestStatusMap>,
dep: &dyn Dependency,
keys: Option<&crate::osv::VulnKeys>,
normalized_name: &str,
displayed_latest: &str,
formatter: &dyn EcosystemFormatter,
) -> LatestVerdict {
let Some(map) = latest_status else {
return LatestVerdict::NotApplicable;
};
upgrade_status_to_verdict(
resolve_latest_status(map, dep, keys, normalized_name),
displayed_latest,
dep,
formatter,
)
}
fn upgrade_status_to_verdict(
status: Option<&UpgradeStatus>,
expected_version: &str,
dep: &dyn Dependency,
formatter: &dyn EcosystemFormatter,
) -> LatestVerdict {
match status {
None | Some(UpgradeStatus::NotChecked) => LatestVerdict::Unverified,
Some(UpgradeStatus::StructurallyUnchecked(reason)) => {
let stale_non_registry_skip = *reason == StructuralSkipReason::NonRegistrySource
&& formatter.source_is_public_registry_content(&dep.source());
if stale_non_registry_skip {
LatestVerdict::Unverified
} else {
LatestVerdict::NotApplicable
}
}
Some(UpgradeStatus::CandidateUnverified { reason, .. }) => {
if reason.is_structural() {
LatestVerdict::NotApplicable
} else {
LatestVerdict::Unverified
}
}
Some(UpgradeStatus::CandidateClean { version }) => {
if version == expected_version {
LatestVerdict::Verified
} else {
LatestVerdict::Unverified
}
}
Some(UpgradeStatus::CandidateVulnerable {
version,
advisory_ids,
worst_severity,
}) => {
if version != expected_version {
return LatestVerdict::Unverified;
}
if *worst_severity == Some(VulnSeverity::Informational) {
LatestVerdict::Verified
} else {
LatestVerdict::Flagged {
advisory_ids: advisory_ids.items().to_vec(),
malicious: *worst_severity == Some(VulnSeverity::Malicious),
}
}
}
}
}
#[must_use]
pub fn resolve_candidate_status<'a>(
candidate_status: &'a CandidateStatusMap,
dep: &dyn Dependency,
keys: Option<&crate::osv::VulnKeys>,
normalized_name: &str,
) -> Option<&'a CandidateStatuses> {
resolve_by_vuln_key(candidate_status, dep, keys, normalized_name)
}
#[must_use]
pub fn candidate_verdict(
candidate_status: Option<&CandidateStatusMap>,
dep: &dyn Dependency,
keys: Option<&crate::osv::VulnKeys>,
normalized_name: &str,
version: &str,
formatter: &dyn EcosystemFormatter,
) -> LatestVerdict {
let Some(map) = candidate_status else {
return LatestVerdict::NotApplicable;
};
match resolve_candidate_status(map, dep, keys, normalized_name) {
None => LatestVerdict::Unverified,
Some(CandidateStatuses::Structural(reason)) => upgrade_status_to_verdict(
Some(&UpgradeStatus::StructurallyUnchecked(*reason)),
version,
dep,
formatter,
),
Some(CandidateStatuses::PerVersion(per_version)) => {
let status = per_version.get(&ConcreteVersion::from(version));
upgrade_status_to_verdict(status, version, dep, formatter)
}
}
}
pub struct LineOffsetTable {
line_starts: Vec<usize>,
line_is_ascii: Vec<bool>,
non_ascii_line_index_cache:
std::cell::RefCell<std::collections::HashMap<usize, NonAsciiLineIndex>>,
}
struct NonAsciiLineIndex {
entries: Vec<(u32, u32)>,
}
impl NonAsciiLineIndex {
fn build(line_text: &str) -> Self {
let mut entries = Vec::with_capacity(line_text.len() + 1);
let mut utf16_units = 0u32;
for (byte_offset, c) in line_text.char_indices() {
entries.push((u32::try_from(byte_offset).unwrap_or(u32::MAX), utf16_units));
utf16_units = utf16_units.saturating_add(u32::try_from(c.len_utf16()).unwrap_or(2));
}
entries.push((
u32::try_from(line_text.len()).unwrap_or(u32::MAX),
utf16_units,
));
entries.shrink_to_fit();
Self { entries }
}
}
impl LineOffsetTable {
pub fn new(content: &str) -> Self {
let mut line_starts = vec![0];
let mut line_is_ascii = Vec::new();
let mut current_ascii = true;
for (i, c) in content.char_indices() {
if !c.is_ascii() {
current_ascii = false;
}
if c == '\n' {
line_starts.push(i + 1);
line_is_ascii.push(current_ascii);
current_ascii = true;
}
}
line_is_ascii.push(current_ascii);
debug_assert_eq!(
line_starts.len(),
line_is_ascii.len(),
"line_starts and line_is_ascii must stay in lockstep — one entry per line"
);
Self {
line_starts,
line_is_ascii,
non_ascii_line_index_cache: std::cell::RefCell::new(std::collections::HashMap::new()),
}
}
#[expect(
clippy::expect_used,
reason = "borrow_mut() is dropped before borrow() runs, and the inserted entry is \
never removed, so the lookup below always succeeds"
)]
fn with_non_ascii_line_index<R>(
&self,
line0: usize,
line_text: &str,
f: impl FnOnce(&NonAsciiLineIndex) -> R,
) -> R {
self.non_ascii_line_index_cache
.borrow_mut()
.entry(line0)
.or_insert_with(|| NonAsciiLineIndex::build(line_text));
let cache = self.non_ascii_line_index_cache.borrow();
let index = cache
.get(&line0)
.expect("just inserted above if it was missing");
f(index)
}
fn non_ascii_char_byte_offset(&self, line0: usize, line_text: &str, col: usize) -> usize {
self.with_non_ascii_line_index(line0, line_text, |index| {
index
.entries
.get(col)
.map_or(line_text.len(), |&(byte_offset, _)| byte_offset as usize)
})
}
fn non_ascii_utf16_units_before(
&self,
line0: usize,
line_text: &str,
byte_offset: usize,
) -> u32 {
self.with_non_ascii_line_index(line0, line_text, |index| {
let byte_offset = u32::try_from(byte_offset).unwrap_or(u32::MAX);
let found = index
.entries
.binary_search_by_key(&byte_offset, |&(b, _)| b);
let i = found.unwrap_or_else(|i| i.saturating_sub(1));
index.entries.get(i).map_or(0, |&(_, units)| units)
})
}
pub fn line_start(&self, line: usize) -> Option<usize> {
self.line_starts.get(line).copied()
}
pub fn byte_offset_to_position(&self, content: &str, offset: usize) -> Position {
let offset = offset.min(content.len());
let offset = content.floor_char_boundary(offset);
let line = self
.line_starts
.partition_point(|&start| start <= offset)
.saturating_sub(1);
#[expect(
clippy::indexing_slicing,
reason = "line_starts always has at least one element (vec![0] at construction), \
so partition_point().saturating_sub(1) is always a valid index into it"
)]
let line_start = self.line_starts[line];
let character = if self.line_is_ascii.get(line).copied().unwrap_or(false) {
u32::try_from(offset - line_start).unwrap_or(u32::MAX)
} else {
let line_end = self
.line_starts
.get(line + 1)
.copied()
.unwrap_or(content.len());
let line_text = content.get(line_start..line_end).unwrap_or_default();
self.non_ascii_utf16_units_before(line, line_text, offset - line_start)
};
Position::new(u32::try_from(line).unwrap_or(u32::MAX), character)
}
#[expect(
clippy::string_slice,
reason = "line_start/line_end come from line_starts (post-newline offsets, always \
char boundaries) or content.len()"
)]
pub fn position_to_byte_offset(&self, content: &str, position: Position) -> usize {
let line0 = position.line as usize;
let Some(&line_start) = self.line_starts.get(line0) else {
return content.len();
};
let line_end = self
.line_starts
.get(line0 + 1)
.copied()
.unwrap_or(content.len());
let line_len = line_end - line_start;
let relative = if self.line_is_ascii.get(line0).copied().unwrap_or(false) {
let character = position.character as usize;
if character <= line_len {
character
} else {
line_len
}
} else {
let line_text = &content[line_start..line_end];
self.with_non_ascii_line_index(line0, line_text, |index| {
let i = index
.entries
.partition_point(|&(_, units)| units < position.character);
index
.entries
.get(i)
.map_or(line_len, |&(byte_offset, _)| byte_offset as usize)
})
};
(line_start + relative).min(content.len())
}
}
pub fn escape_markdown(s: &str) -> String {
let mut escaped = String::with_capacity(s.len());
for c in s.chars() {
if is_markdown_unsafe(c) {
escaped.push(' ');
continue;
}
if c.is_ascii_punctuation() {
escaped.push('\\');
}
escaped.push(c);
}
escaped
}
fn is_markdown_unsafe(c: char) -> bool {
c.is_control()
|| matches!(c,
'\u{202A}'..='\u{202E}' | '\u{2060}'..='\u{206F}' | '\u{200B}' | '\u{2028}' | '\u{2029}' | '\u{FEFF}' | '\u{FFF9}'..='\u{FFFB}' | '\u{E0000}'..='\u{E007F}' | '\u{00AD}' | '\u{180E}' | '\u{13430}'..='\u{1343F}' | '\u{1BCA0}'..='\u{1BCA3}' | '\u{1D173}'..='\u{1D17A}' )
}
pub(crate) fn replace_markdown_unsafe_chars(s: &str) -> String {
s.chars()
.map(|c| if is_markdown_unsafe(c) { ' ' } else { c })
.collect()
}
#[cfg(feature = "lsp-responses")]
pub(crate) fn replace_markdown_unsafe_chars_keep_newlines(s: &str) -> String {
s.chars()
.map(|c| {
if c != '\n' && is_markdown_unsafe(c) {
' '
} else {
c
}
})
.collect()
}
pub fn strip_markdown_unsafe_chars(s: &str) -> String {
s.chars().filter(|c| !is_markdown_unsafe(*c)).collect()
}
pub fn markdown_code_span(content: &str) -> String {
let sanitized = replace_markdown_unsafe_chars(content);
let max_backtick_run = sanitized
.split(|c| c != '`')
.map(str::len)
.max()
.unwrap_or(0);
let fence = "`".repeat(max_backtick_run + 1);
if sanitized.is_empty() {
format!("{fence} {fence}")
} else if sanitized.starts_with(['`', ' ']) || sanitized.ends_with(['`', ' ']) {
format!("{fence} {sanitized} {fence}")
} else {
format!("{fence}{sanitized}{fence}")
}
}
pub fn is_same_major_minor(v1: &str, v2: &str) -> bool {
if v1.is_empty() || v2.is_empty() {
return false;
}
let mut parts1 = v1.split('.');
let mut parts2 = v2.split('.');
if parts1.next() != parts2.next() {
return false;
}
match (parts1.next(), parts2.next()) {
(Some(m1), Some(m2)) => m1 == m2,
_ => true,
}
}
fn template_placeholder_identifier_end(bytes: &[u8], start: usize) -> Option<usize> {
let first = *bytes.get(start)?;
if !(first.is_ascii_alphabetic() || first == b'_') {
return None;
}
let mut end = start + 1;
while bytes
.get(end)
.is_some_and(|&c| c.is_ascii_alphanumeric() || c == b'_')
{
end += 1;
}
Some(end)
}
fn hyphenated_identifier_end(bytes: &[u8], start: usize) -> Option<usize> {
let first = *bytes.get(start)?;
if !(first.is_ascii_alphabetic() || first == b'_') {
return None;
}
let mut end = start + 1;
while bytes
.get(end)
.is_some_and(|&c| c.is_ascii_alphanumeric() || matches!(c, b'_' | b'-'))
{
end += 1;
}
Some(end)
}
fn dotted_identifier_end(bytes: &[u8], start: usize) -> Option<usize> {
let first = *bytes.get(start)?;
if !(first.is_ascii_alphabetic() || first == b'_') {
return None;
}
let mut end = start + 1;
while bytes
.get(end)
.is_some_and(|&c| c.is_ascii_alphanumeric() || matches!(c, b'_' | b'.' | b'-'))
{
end += 1;
}
Some(end)
}
fn contains_delimited_identifier_placeholder(
text: &str,
delim: u8,
identifier_end: impl Fn(&[u8], usize) -> Option<usize>,
) -> bool {
let bytes = text.as_bytes();
bytes.iter().enumerate().any(|(i, &b)| {
b == delim && identifier_end(bytes, i + 1).is_some_and(|end| bytes.get(end) == Some(&delim))
})
}
fn contains_bracketed_placeholder(text: &str, open: &str, close_or_skip: &[u8]) -> bool {
let bytes = text.as_bytes();
text.match_indices(open).any(|(pos, _)| {
let mut i = pos + open.len();
if close_or_skip.contains(bytes.get(i).unwrap_or(&0)) {
i += 1;
}
while bytes.get(i).is_some_and(|b| b.is_ascii_whitespace()) {
i += 1;
}
bytes.get(i).is_some_and(|&b| b != b'}' && b != b'%')
})
}
pub fn requirement_contains_template_placeholder(requirement: &str) -> bool {
let bytes = requirement.as_bytes();
let has_dollar_form = bytes.iter().enumerate().any(|(i, &b)| {
b == b'$'
&& match bytes.get(i + 1) {
Some(b'{') => template_placeholder_identifier_end(bytes, i + 2).is_some(),
Some(b'(') => hyphenated_identifier_end(bytes, i + 2)
.is_some_and(|end| bytes.get(end) == Some(&b')')),
_ => template_placeholder_identifier_end(bytes, i + 1).is_some(),
}
});
has_dollar_form
|| contains_delimited_identifier_placeholder(requirement, b'@', dotted_identifier_end)
|| contains_delimited_identifier_placeholder(
requirement,
b'%',
template_placeholder_identifier_end,
)
|| contains_bracketed_placeholder(requirement, "{{", b"")
|| contains_bracketed_placeholder(requirement, "{%", b"-")
|| contains_bracketed_placeholder(requirement, "<%", b"=-")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RequirementStatus {
UpToDate,
Outdated,
Unresolved,
}
pub trait RequirementMatcher: Send + Sync {
fn matches(&self, version: &ConcreteVersion) -> Option<bool>;
fn strict_prerelease_exclusion(&self) -> bool;
fn explicitly_excludes(&self, _version: &ConcreteVersion) -> bool {
false
}
}
pub(crate) fn semver_req_matches(
req: &semver::VersionReq,
version: &ConcreteVersion,
) -> Option<bool> {
version
.as_str()
.parse::<semver::Version>()
.ok()
.map(|v| req.matches(&v))
}
struct SemverReqMatcher(semver::VersionReq);
impl RequirementMatcher for SemverReqMatcher {
fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
semver_req_matches(&self.0, version)
}
fn strict_prerelease_exclusion(&self) -> bool {
true
}
}
pub fn compile_semver_requirement(requirement: &VersionReq) -> Option<Box<dyn RequirementMatcher>> {
requirement
.as_str()
.parse::<semver::VersionReq>()
.ok()
.map(|req| Box::new(SemverReqMatcher(req)) as Box<dyn RequirementMatcher>)
}
#[cfg(test)]
mod semver_req_matcher_tests {
use super::compile_semver_requirement;
use crate::{ConcreteVersion, VersionReq};
#[test]
fn matches_rejects_prerelease_candidate_not_pinned_by_requirement() {
let matcher = compile_semver_requirement(&VersionReq::new(">=1.0.0, <2.0.0"))
.expect("valid semver requirement must compile");
assert_eq!(
matcher.matches(&ConcreteVersion::new("1.5.0")),
Some(true),
"a stable candidate in range must match"
);
assert_eq!(
matcher.matches(&ConcreteVersion::new("1.5.0-alpha.1")),
Some(false),
"a pre-release candidate must be rejected unless the requirement pins to its exact \
X.Y.Z tuple with a pre-release tag"
);
}
#[test]
fn matches_accepts_prerelease_candidate_pinned_by_requirement() {
let matcher = compile_semver_requirement(&VersionReq::new("=1.5.0-alpha.1"))
.expect("valid semver requirement must compile");
assert_eq!(
matcher.matches(&ConcreteVersion::new("1.5.0-alpha.1")),
Some(true)
);
}
}
pub fn is_dot_segment(segment: &str) -> bool {
segment == "." || segment == ".."
}
pub fn is_safe_version_string(version: &str) -> bool {
!version.is_empty()
&& version.len() <= 64
&& version.chars().all(|c| {
c.is_ascii_alphanumeric()
|| matches!(c, '.' | '+' | '_' | '~' | ':' | '*' | '^' | '!' | '-')
})
}
pub fn is_safe_maven_coordinate_segment(segment: &str) -> bool {
!segment.is_empty()
&& segment.len() <= 128
&& !is_dot_segment(segment)
&& segment
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_'))
}
pub fn maven_coordinate_path(group_id: &str, artifact_id: &str) -> Option<String> {
if !is_safe_maven_coordinate_segment(group_id) || !is_safe_maven_coordinate_segment(artifact_id)
{
return None;
}
let group_segments: Vec<&str> = group_id.split('.').collect();
if group_segments
.iter()
.any(|segment| !is_safe_maven_coordinate_segment(segment))
{
return None;
}
Some(format!("{}/{artifact_id}", group_segments.join("/")))
}
pub fn is_safe_registry_url(url: &str) -> bool {
!url.is_empty()
&& url.len() <= 2048
&& url.starts_with("https://")
&& url.chars().all(|c| {
c.is_ascii_alphanumeric()
|| matches!(
c,
'-' | '.'
| '_'
| '~'
| ':'
| '/'
| '?'
| '#'
| '['
| ']'
| '@'
| '!'
| '$'
| '&'
| '\''
| '('
| ')'
| '*'
| '+'
| ','
| ';'
| '='
| '%'
)
})
}
pub fn is_safe_package_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 256
&& name.chars().all(|c| {
c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '@' | ':' | '/' | '-' | '~')
})
}
pub fn is_safe_feature_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= 256
&& name
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '+' | '.'))
}
pub fn warn_rejected_value(gate: &str, context: &str, value: &str) {
tracing::warn!(
gate,
context,
len = value.len(),
"rejected unsafe value before manifest/registry sink"
);
}
#[must_use]
pub fn dot_segment_rejection_error(
gate: &str,
context: &str,
name: &str,
registry: &'static str,
) -> DepsError {
warn_rejected_value(gate, context, name);
DepsError::PackageNotFound {
package: name.into(),
registry,
}
}
#[cfg(feature = "lsp-responses")]
#[must_use]
pub fn to_ls_uri(url: &url::Url) -> tower_lsp_server::ls_types::Uri {
url.as_str()
.parse()
.unwrap_or_else(|e| panic!("document URL {url} did not round-trip to an LSP Uri: {e}"))
}
#[cfg(feature = "lsp-responses")]
#[must_use]
pub fn single_file_edit(
uri: &url::Url,
range: Range,
new_text: String,
) -> HashMap<tower_lsp_server::ls_types::Uri, Vec<tower_lsp_server::ls_types::TextEdit>> {
let mut edits = HashMap::new();
edits.insert(
to_ls_uri(uri),
vec![tower_lsp_server::ls_types::TextEdit {
range: range.into(),
new_text,
}],
);
edits
}
pub(crate) fn strip_whitespace(s: &str) -> String {
s.chars().filter(|c| !c.is_whitespace()).collect()
}
pub(crate) fn slice_for_range<'a>(
content: &'a str,
table: &LineOffsetTable,
range: Range,
) -> &'a str {
let start = table.position_to_byte_offset(content, range.start);
let end = table.position_to_byte_offset(content, range.end);
if start > end {
return "";
}
content.get(start..end).unwrap_or("")
}
pub(crate) fn literal_span_matches(slice: &str, requirement: &str) -> bool {
let norm_slice = strip_whitespace(slice);
let norm_req = strip_whitespace(requirement);
norm_slice == norm_req || format!("[{norm_slice}]") == norm_req
}
#[must_use]
pub fn dependency_version_range_is_literal(
dep: &dyn Dependency,
content: &str,
version_range: Range,
) -> bool {
if version_range == Range::default() {
return false;
}
let table = LineOffsetTable::new(content);
let slice = slice_for_range(content, &table, version_range);
let trimmed = slice.trim();
let Some(version_req) = dep.version_requirement() else {
return trimmed.is_empty();
};
if trimmed.contains('$')
|| (trimmed.starts_with('*') && !crate::is_existence_wildcard_str(trimmed))
{
return false;
}
let literal_target = dep
.version_literal()
.unwrap_or_else(|| version_req.as_str());
literal_span_matches(slice, literal_target)
}
#[cfg(test)]
#[expect(
clippy::string_slice,
reason = "fixtures are single-line ASCII literals with hand-computed byte offsets"
)]
mod tests {
use super::*;
use crate::lsp_helpers::test_support::*;
use crate::osv::SkipReason;
use crate::{DependencySource, PackageName, VersionReq};
fn gossip_findings_fixture(version: &str, end_unix_secs: i64) -> GossipFindings {
GossipFindings {
version: version.to_string(),
cooldown: Some(crate::GossipCooldown {
end: PublishTime::from_unix_secs(end_unix_secs),
risk: crate::GossipRiskLevel::High,
}),
low_usage: None,
}
}
fn gossip_findings_fixture_not_in_cooldown(version: &str) -> GossipFindings {
GossipFindings {
version: version.to_string(),
cooldown: None,
low_usage: None,
}
}
#[test]
fn gossip_cooldown_for_none_prefetch_is_unavailable() {
let name = PackageName::new("vite");
assert!(matches!(
gossip_cooldown_for(None, &name, "8.3.1", PublishTime::now()),
GossipCooldownLookup::Unavailable
));
}
#[test]
fn gossip_cooldown_for_matching_active_version_is_active() {
let name = PackageName::new("vite");
let mut prefetch = HashMap::new();
prefetch.insert(name.clone(), gossip_findings_fixture("8.3.1", 2_000));
let lookup = gossip_cooldown_for(
Some(&prefetch),
&name,
"8.3.1",
PublishTime::from_unix_secs(1_000),
);
assert_eq!(lookup, GossipCooldownLookup::Active);
}
#[test]
fn gossip_cooldown_for_version_mismatch_is_unavailable() {
let name = PackageName::new("vite");
let mut prefetch = HashMap::new();
prefetch.insert(name.clone(), gossip_findings_fixture("8.3.1", 2_000));
let lookup = gossip_cooldown_for(
Some(&prefetch),
&name,
"8.4.0",
PublishTime::from_unix_secs(1_000),
);
assert!(matches!(lookup, GossipCooldownLookup::Unavailable));
}
#[test]
fn gossip_cooldown_for_ended_cooldown_self_clears_to_not_active_at_read_time() {
let name = PackageName::new("vite");
let mut prefetch = HashMap::new();
prefetch.insert(name.clone(), gossip_findings_fixture("8.3.1", 1_000));
let lookup = gossip_cooldown_for(
Some(&prefetch),
&name,
"8.3.1",
PublishTime::from_unix_secs(2_000),
);
assert!(matches!(lookup, GossipCooldownLookup::NotActive));
}
#[test]
fn gossip_cooldown_for_present_but_no_cooldown_data_is_unavailable() {
let name = PackageName::new("vite");
let mut prefetch = HashMap::new();
prefetch.insert(
name.clone(),
gossip_findings_fixture_not_in_cooldown("8.3.1"),
);
let lookup = gossip_cooldown_for(Some(&prefetch), &name, "8.3.1", PublishTime::now());
assert!(matches!(lookup, GossipCooldownLookup::Unavailable));
}
#[test]
fn gossip_cooldown_for_absent_name_is_unavailable() {
let name = PackageName::new("vite");
let other = PackageName::new("left-pad");
let mut prefetch = HashMap::new();
prefetch.insert(other, gossip_findings_fixture("1.3.0", 2_000));
assert!(matches!(
gossip_cooldown_for(Some(&prefetch), &name, "8.3.1", PublishTime::now()),
GossipCooldownLookup::Unavailable
));
}
#[test]
fn cooldown_disposition_read_time_reevaluates_the_stored_fallback() {
let now = PublishTime::from_unix_secs(10_000);
let versions = PackageVersions::new("2.0.0".into(), Arc::from(vec!["2.0.0".into()]))
.with_published_at(PublishTime::from_unix_secs(9_900)) .with_cooldown_fallback(CooldownFallback::new(
"1.9.0".into(),
PublishTime::from_unix_secs(9_800), ));
let name = PackageName::new("pkg");
let disposition = cooldown_disposition(
&versions,
&name,
crate::freshness::FreshnessSettings::Enabled {
cooldown: CooldownWindow::from_secs(150),
},
None,
now,
);
assert!(
matches!(
disposition,
CooldownDisposition::Blocked {
fallback: Some(_),
..
}
),
"got: {disposition:?}"
);
let disposition = cooldown_disposition(
&versions,
&name,
crate::freshness::FreshnessSettings::Enabled {
cooldown: CooldownWindow::from_secs(10_000),
},
None,
now,
);
assert!(
matches!(
disposition,
CooldownDisposition::Blocked { fallback: None, .. }
),
"a fallback that no longer clears cooldown at read time must not be surfaced: {disposition:?}"
);
}
#[test]
fn test_dependency_version_range_is_literal_plain_literal_admitted() {
let content = r#"serde = "1.0.0""#;
let dep = MockDep {
name: PackageName::new("serde"),
version_req: VersionReq::new("1.0.0"),
version_range: Range::new(Position::new(0, 9), Position::new(0, 14)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
};
assert!(dependency_version_range_is_literal(
&dep,
content,
dep.version_range,
));
}
#[test]
fn test_dependency_version_range_is_literal_rejects_property_interpolation() {
let content = r"<version>${slf4j.version}</version>";
let dep = MockDep {
name: PackageName::new("slf4j-api"),
version_req: VersionReq::new("2.0.16"), version_range: Range::new(Position::new(0, 9), Position::new(0, 25)), name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
};
assert!(!dependency_version_range_is_literal(
&dep,
content,
dep.version_range,
));
}
#[test]
fn test_dependency_version_range_is_literal_rejects_yaml_alias() {
let content = "ref: *pin";
let dep = MockDep {
name: PackageName::new("job"),
version_req: VersionReq::new("1.0.0"),
version_range: Range::new(Position::new(0, 5), Position::new(0, 9)), name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
};
assert!(!dependency_version_range_is_literal(
&dep,
content,
dep.version_range,
));
}
#[test]
fn test_dependency_version_range_is_literal_admits_empty_requirement_with_empty_slice() {
let content = r#"serde = """#;
let dep = MockDep {
name: PackageName::new("serde"),
version_req: VersionReq::new(""),
version_range: Range::new(Position::new(0, 9), Position::new(0, 9)),
name_range: Range::new(Position::new(0, 0), Position::new(0, 5)),
};
assert!(dependency_version_range_is_literal(
&dep,
content,
dep.version_range,
));
}
#[test]
fn test_dependency_version_range_is_literal_rejects_empty_requirement_with_mismatched_slice() {
let content = r#"x = "1.0""#;
let dep = MockDep {
name: PackageName::new("x"),
version_req: VersionReq::new(""),
version_range: Range::new(Position::new(0, 5), Position::new(0, 8)), name_range: Range::new(Position::new(0, 0), Position::new(0, 1)),
};
assert!(!dependency_version_range_is_literal(
&dep,
content,
dep.version_range,
));
}
#[test]
fn test_dependency_version_range_is_literal_rejects_unresolved_maven_property() {
let content = r"<version>${slf4j.version}</version>";
let dep = MockDep {
name: PackageName::new("slf4j-api"),
version_req: VersionReq::new("${slf4j.version}"), version_range: Range::new(Position::new(0, 9), Position::new(0, 25)), name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
};
assert!(!dependency_version_range_is_literal(
&dep,
content,
dep.version_range,
));
}
#[test]
fn test_dependency_version_range_is_literal_rejects_unresolved_gradle_variable() {
let content = "implementation 'com.example:lib:$libVersion'";
let dep = MockDep {
name: PackageName::new("com.example:lib"),
version_req: VersionReq::new("$libVersion"), version_range: Range::new(Position::new(0, 32), Position::new(0, 43)), name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
};
assert!(!dependency_version_range_is_literal(
&dep,
content,
dep.version_range,
));
}
#[test]
fn test_dependency_version_range_is_literal_rejects_embedded_interpolation() {
let content = r#"x = "1.0-${suffix}""#;
let dep = MockDep {
name: PackageName::new("x"),
version_req: VersionReq::new("1.0-${suffix}"),
version_range: Range::new(Position::new(0, 5), Position::new(0, 18)), name_range: Range::new(Position::new(0, 0), Position::new(0, 1)),
};
assert!(!dependency_version_range_is_literal(
&dep,
content,
dep.version_range,
));
}
#[test]
fn test_dependency_version_range_is_literal_admits_existence_wildcard() {
let content = r#"x = "*""#;
let dep = MockDep {
name: PackageName::new("x"),
version_req: VersionReq::new("*"),
version_range: Range::new(Position::new(0, 5), Position::new(0, 6)), name_range: Range::new(Position::new(0, 0), Position::new(0, 1)),
};
assert!(dependency_version_range_is_literal(
&dep,
content,
dep.version_range,
));
}
#[test]
fn test_dependency_version_range_is_literal_rejects_multi_char_alias_despite_wildcard_exemption()
{
let content = "ref: *pinned-anchor";
let dep = MockDep {
name: PackageName::new("job"),
version_req: VersionReq::new("1.0.0"),
version_range: Range::new(Position::new(0, 5), Position::new(0, 19)), name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
};
assert!(!dependency_version_range_is_literal(
&dep,
content,
dep.version_range,
));
}
#[test]
fn test_dependency_version_range_is_literal_rejects_gradle_mid_string_variable() {
let content = r#"implementation "com.example:lib:1.0.$patch""#;
let dep = MockDep {
name: PackageName::new("com.example:lib"),
version_req: VersionReq::new("1.0.$patch"), version_range: Range::new(Position::new(0, 32), Position::new(0, 42)), name_range: Range::new(Position::new(0, 0), Position::new(0, 0)),
};
assert!(!dependency_version_range_is_literal(
&dep,
content,
dep.version_range,
));
}
#[test]
fn test_dependency_version_range_is_literal_admits_empty_slice_with_no_requirement_at_all() {
let content = "<version></version>";
let dep = MockSyntheticRangeDep {
name: PackageName::new("com.example:foo"),
};
let empty_range = Range::new(Position::new(0, 9), Position::new(0, 9));
assert!(dep.version_requirement().is_none());
assert!(dependency_version_range_is_literal(
&dep,
content,
empty_range
));
}
#[test]
fn test_dependency_version_range_is_literal_rejects_default_range_with_no_requirement_at_all() {
let content = r#"version.ref = "gu""#;
let dep = MockSyntheticRangeDep {
name: PackageName::new("com.example:guava"),
};
assert!(dep.version_requirement().is_none());
assert!(!dependency_version_range_is_literal(
&dep,
content,
Range::default(),
));
}
#[test]
fn test_dependency_version_range_is_literal_rejects_default_range_with_nonempty_requirement() {
let content = r#"version.ref = "guavaVersion""#;
let dep = MockDep {
name: PackageName::new("com.example:guava"),
version_req: VersionReq::new("32.0.1"), version_range: Range::default(), name_range: Range::default(),
};
assert!(!dependency_version_range_is_literal(
&dep,
content,
dep.version_range,
));
}
#[test]
fn test_dependency_outcomes_prunes_entry_once_all_channels_cleared() {
let mut outcomes = DependencyOutcomes::new()
.with_yanked("pkg", ("1.0.0".into(), RemovalStatus::Yanked))
.with_deprecation(
"pkg",
Deprecation {
reason: None,
replacement: None,
},
)
.with_fetch_failure("pkg", FetchFailure::Transient);
assert_eq!(outcomes.len(), 1);
outcomes.clear_yanked("pkg");
assert!(
outcomes.get("pkg").is_some(),
"entry must survive while other channels are still set"
);
outcomes.clear_deprecation("pkg");
assert!(
outcomes.get("pkg").is_some(),
"entry must survive while the fetch-failure channel is still set"
);
outcomes.clear_fetch_failure("pkg");
assert!(
outcomes.is_empty(),
"entry must be pruned once its last channel is cleared, not left as a vacuous Some"
);
assert_eq!(outcomes.len(), 0);
}
#[test]
fn test_dependency_outcomes_clear_on_unset_channel_is_a_no_op_for_others() {
let mut outcomes = DependencyOutcomes::new()
.with_yanked("pkg", ("1.0.0".into(), RemovalStatus::AdvisoryDeprecated));
outcomes.clear_deprecation("pkg");
outcomes.clear_fetch_failure("pkg");
assert!(
outcomes.yanked("pkg").is_some(),
"clearing unset channels must not touch the still-set yanked channel"
);
assert_eq!(outcomes.len(), 1);
}
#[test]
fn test_dependency_outcomes_remove_drops_entry_with_multiple_channels_set() {
let mut outcomes = DependencyOutcomes::new()
.with_yanked("pkg", ("1.0.0".into(), RemovalStatus::Yanked))
.with_fetch_failure("pkg", FetchFailure::Transient);
outcomes.remove("pkg");
assert!(outcomes.get("pkg").is_none());
assert!(outcomes.is_empty());
}
#[test]
fn test_dependency_outcomes_clear_all_fetch_failures() {
let mut outcomes = DependencyOutcomes::new()
.with_fetch_failure("only-failure", FetchFailure::Transient)
.with_yanked("mixed", ("1.0.0".into(), RemovalStatus::Yanked))
.with_fetch_failure("mixed", FetchFailure::Transient);
outcomes.clear_all_fetch_failures();
assert!(
outcomes.get("only-failure").is_none(),
"an entry whose only channel was fetch-failure must be pruned"
);
assert!(outcomes.fetch_failure("mixed").is_none());
assert!(
outcomes.yanked("mixed").is_some(),
"clearing fetch-failure must not touch a mixed entry's other channels"
);
}
#[test]
fn test_dependency_outcomes_clear_all_fetch_failures_on_empty_map() {
let mut outcomes = DependencyOutcomes::new();
outcomes.clear_all_fetch_failures();
assert!(outcomes.is_empty());
}
#[test]
fn test_dependency_outcomes_set_fetch_failure_if_absent_does_not_clobber_existing() {
let mut outcomes = DependencyOutcomes::new().with_fetch_failure(
"pkg",
FetchFailure::Actionable("set GITHUB_TOKEN".to_string()),
);
outcomes.set_fetch_failure_if_absent("pkg".to_string(), FetchFailure::NotAttempted);
assert_eq!(
outcomes.fetch_failure("pkg"),
Some(&FetchFailure::Actionable("set GITHUB_TOKEN".to_string())),
"an existing genuine failure must survive a collided-name NotAttempted marker"
);
}
#[test]
fn test_dependency_outcomes_set_fetch_failure_if_absent_populates_when_unset() {
let mut outcomes = DependencyOutcomes::new();
outcomes.set_fetch_failure_if_absent("pkg".to_string(), FetchFailure::NotAttempted);
assert_eq!(
outcomes.fetch_failure("pkg"),
Some(&FetchFailure::NotAttempted)
);
}
#[test]
fn test_line_offset_table_line_start_crlf() {
let table = LineOffsetTable::new("a\r\nbb\r\nc");
assert_eq!(table.line_start(0), Some(0));
assert_eq!(table.line_start(1), Some(3));
assert_eq!(table.line_start(2), Some(7));
assert_eq!(table.line_start(3), None);
}
#[test]
fn test_byte_offset_to_position_clamps_to_char_boundary_instead_of_panicking() {
let content = "é";
let table = LineOffsetTable::new(content);
let pos = table.byte_offset_to_position(content, 1);
assert_eq!(pos, Position::new(0, 0));
}
#[test]
fn test_byte_offset_to_position_multi_byte_boundary_in_longer_line() {
let content = "ab é cd";
let table = LineOffsetTable::new(content);
assert!(content.is_char_boundary(3));
assert!(!content.is_char_boundary(4));
let pos = table.byte_offset_to_position(content, 4);
assert_eq!(pos, table.byte_offset_to_position(content, 3));
}
#[test]
fn test_byte_offset_to_position_ascii_and_non_ascii_lines_agree() {
let content = "abcde\nhéllo 😀\nab café end";
let table = LineOffsetTable::new(content);
assert_eq!(
table.byte_offset_to_position(content, 3),
Position::new(0, 3)
);
assert_eq!(
table.byte_offset_to_position(content, 5),
Position::new(0, 5)
);
assert_eq!(
table.byte_offset_to_position(content, 6 + 3),
Position::new(1, 2)
);
assert_eq!(
table.byte_offset_to_position(content, 6 + 11),
Position::new(1, 8)
);
assert_eq!(
table.byte_offset_to_position(content, 18 + 8),
Position::new(2, 7)
);
}
fn reference_position_to_byte_offset(
table: &LineOffsetTable,
content: &str,
position: Position,
) -> usize {
let Some(line_start) = table.line_start(position.line as usize) else {
return content.len();
};
let line_end = table
.line_start(position.line as usize + 1)
.unwrap_or(content.len());
utf16_to_byte_offset(&content[line_start..line_end], position.character)
.map_or(line_end, |offset| line_start + offset)
.min(content.len())
}
#[test]
fn test_position_to_byte_offset_matches_line_scan_reference() {
for content in [
"",
"abc",
"abc\n",
"a\r\nbb\r\nc",
"h\u{e9}llo \u{1f600}\nascii\n\u{65e5}\u{672c}\u{8a9e}x\n",
"\u{1f600}\u{1f600}",
"\n\n\u{e9}",
] {
let table = LineOffsetTable::new(content);
for line in 0..6 {
for character in 0..24 {
let position = Position::new(line, character);
assert_eq!(
table.position_to_byte_offset(content, position),
reference_position_to_byte_offset(&table, content, position),
"content {content:?} position {position:?}"
);
}
}
}
}
#[test]
fn test_position_to_byte_offset_surrogate_split_rounds_up_and_overshoot_clamps() {
let content = "a\u{1f600}b\nzz";
let table = LineOffsetTable::new(content);
assert_eq!(
table.position_to_byte_offset(content, Position::new(0, 2)),
5
);
assert_eq!(
table.position_to_byte_offset(content, Position::new(0, 4)),
6
);
assert_eq!(
table.position_to_byte_offset(content, Position::new(0, 99)),
7
);
assert_eq!(
table.position_to_byte_offset(content, Position::new(1, 99)),
content.len()
);
assert_eq!(
table.position_to_byte_offset(content, Position::new(9, 0)),
content.len()
);
}
#[test]
fn test_position_to_byte_offset_long_non_ascii_line_round_trips() {
let content = "\u{e9}\u{1f600}dep = \"1.0.0\", ".repeat(5000);
let table = LineOffsetTable::new(&content);
for (offset, _) in content.char_indices().step_by(7) {
let position = table.byte_offset_to_position(&content, offset);
assert_eq!(table.position_to_byte_offset(&content, position), offset);
}
}
#[test]
fn test_position_to_byte_offset_round_trips_across_mixed_ascii_and_non_ascii_lines() {
let content = "plain = 1\r\nh\u{e9}llo \u{1f600}\nascii again\n\u{65e5}\u{672c}\n\nend";
let table = LineOffsetTable::new(content);
let offsets: Vec<usize> = content
.char_indices()
.map(|(i, _)| i)
.chain([content.len()])
.collect();
for &offset in offsets.iter().rev().chain(offsets.iter()) {
let position = table.byte_offset_to_position(content, offset);
assert_eq!(
table.position_to_byte_offset(content, position),
offset,
"offset {offset}"
);
}
}
#[test]
fn test_byte_offset_to_position_minified_line_stays_fast() {
let mut content = String::from("{\"dependencies\":{");
for i in 0..8000 {
content.push_str(&format!("\"dep{i}\":\"1.0.{i}\","));
}
content.push_str("}}");
let table = LineOffsetTable::new(&content);
let step = (content.len() / 8000).max(1);
let start = std::time::Instant::now();
for offset in (0..content.len()).step_by(step) {
std::hint::black_box(table.byte_offset_to_position(&content, offset));
}
let elapsed = start.elapsed();
assert!(
elapsed < std::time::Duration::from_millis(100),
"8000 byte_offset_to_position calls on a minified line took {elapsed:?}, expected < 100ms"
);
}
#[test]
fn test_byte_offset_to_position_non_ascii_line_cache_stays_fast_on_a_huge_single_line() {
let filler = "x".repeat(200 * 1024);
let content = format!("{filler}\u{2014}{filler}");
let table = LineOffsetTable::new(&content);
let step = (content.len() / 5000).max(1);
let start = std::time::Instant::now();
for offset in (0..content.len()).step_by(step) {
std::hint::black_box(table.byte_offset_to_position(&content, offset));
}
assert!(
start.elapsed() < std::time::Duration::from_secs(1),
"byte_offset_to_position took {:?} for ~5000 lookups on a huge non-ASCII line, \
expected well under 1s",
start.elapsed()
);
}
#[test]
fn test_byte_offset_to_position_non_ascii_cache_matches_char_sum_result_across_lines() {
fn slow_path(content: &str, line_start: usize, offset: usize) -> u32 {
u32::try_from(
content[line_start..offset]
.chars()
.map(char::len_utf16)
.sum::<usize>(),
)
.unwrap_or(u32::MAX)
}
let content = "\u{1f680}rocket \u{2014} dash\nascii line\n\u{3000}ideographic \u{e9}nd";
let table = LineOffsetTable::new(content);
let line_starts = [
table.line_start(0).unwrap(),
table.line_start(1).unwrap(),
table.line_start(2).unwrap(),
];
let line_ends = [line_starts[1] - 1, line_starts[2] - 1, content.len()];
for (line, (&line_start, &line_end)) in line_starts.iter().zip(&line_ends).enumerate() {
let line_text = &content[line_start..line_end];
for (byte_offset, _) in line_text
.char_indices()
.chain(std::iter::once((line_text.len(), '\0')))
{
let offset = line_start + byte_offset;
assert_eq!(
table.byte_offset_to_position(content, offset).character,
slow_path(content, line_start, offset),
"line {line} byte offset {byte_offset} desynced from the char-sum walk"
);
}
}
}
#[test]
fn test_position_in_range_inside() {
let range = Range::new(Position::new(5, 10), Position::new(5, 20));
let position = Position::new(5, 15);
assert!(position_in_range(position, range));
}
#[test]
fn test_position_in_range_at_start() {
let range = Range::new(Position::new(5, 10), Position::new(5, 20));
let position = Position::new(5, 10);
assert!(position_in_range(position, range));
}
#[test]
fn test_position_in_range_at_end() {
let range = Range::new(Position::new(5, 10), Position::new(5, 20));
let position = Position::new(5, 20);
assert!(position_in_range(position, range));
}
#[test]
fn test_position_in_range_before() {
let range = Range::new(Position::new(5, 10), Position::new(5, 20));
let position = Position::new(5, 5);
assert!(!position_in_range(position, range));
}
#[test]
fn test_position_in_range_after() {
let range = Range::new(Position::new(5, 10), Position::new(5, 20));
let position = Position::new(5, 25);
assert!(!position_in_range(position, range));
}
#[test]
fn test_position_in_range_different_line_before() {
let range = Range::new(Position::new(5, 10), Position::new(5, 20));
let position = Position::new(4, 15);
assert!(!position_in_range(position, range));
}
#[test]
fn test_position_in_range_different_line_after() {
let range = Range::new(Position::new(5, 10), Position::new(5, 20));
let position = Position::new(6, 15);
assert!(!position_in_range(position, range));
}
#[test]
fn test_position_in_range_multiline() {
let range = Range::new(Position::new(5, 10), Position::new(7, 5));
let position = Position::new(6, 0);
assert!(position_in_range(position, range));
}
#[test]
fn test_escape_markdown_link_breakout_payload() {
let payload = "real-pkg](https://legit-looking-typosquat.example/download)[real-pkg";
let escaped = escape_markdown(payload);
assert_eq!(
escaped,
r"real\-pkg\]\(https\:\/\/legit\-looking\-typosquat\.example\/download\)\[real\-pkg"
);
assert!(!escaped.contains("]("));
}
#[test]
fn test_escape_markdown_backslash_and_backtick() {
assert_eq!(escape_markdown(r"a\b`c"), r"a\\b\`c");
}
#[test]
fn test_escape_markdown_autolink_angle_brackets() {
let escaped = escape_markdown("pkg <https://evil.example>");
assert_eq!(escaped, r"pkg \<https\:\/\/evil\.example\>");
assert!(!escaped.contains('<') || escaped.contains(r"\<"));
}
#[test]
fn test_escape_markdown_control_chars_become_spaces() {
assert_eq!(escape_markdown("a\nb"), "a b");
assert_eq!(escape_markdown("a\r\nb"), "a b");
assert_eq!(escape_markdown("a\tb"), "a b");
assert_eq!(escape_markdown("a\0b"), "a b");
}
#[test]
fn test_escape_markdown_newline_cannot_break_out_of_heading() {
let escaped = escape_markdown("react\n# [fake](https://evil.example)");
assert!(!escaped.contains('\n'));
}
#[test]
fn test_escape_markdown_hyphenated_name_round_trips_visually() {
assert_eq!(escape_markdown("tokio-util"), r"tokio\-util");
}
#[test]
fn test_markdown_code_span_plain_content() {
assert_eq!(markdown_code_span("1.0.0"), "`1.0.0`");
}
#[test]
fn test_markdown_code_span_widens_fence_for_embedded_backticks() {
assert_eq!(markdown_code_span("a`b"), "``a`b``");
assert_eq!(markdown_code_span("``double``"), "``` ``double`` ```");
}
#[test]
fn test_markdown_code_span_pads_when_content_starts_or_ends_with_backtick() {
let span = markdown_code_span("`leading");
assert!(span.starts_with("`` `"));
}
#[test]
fn test_markdown_code_span_replaces_control_chars() {
let span = markdown_code_span("1.0\n[evil](https://evil.example)");
assert!(!span.contains('\n'));
}
#[test]
fn test_markdown_code_span_empty_content() {
assert_eq!(markdown_code_span(""), "` `");
}
#[test]
fn test_markdown_code_span_backtick_payload_cannot_break_span() {
let payload = "1.0` <https://evil.example>` more";
let span = markdown_code_span(payload);
let opening_fence_len = span.chars().take_while(|&c| c == '`').count();
let inner = &span[opening_fence_len..span.len() - opening_fence_len];
assert!(
!inner.contains(&"`".repeat(opening_fence_len)),
"content contains a run of backticks as long as the fence: {span}"
);
}
#[test]
fn test_escape_markdown_replaces_bidi_and_invisible_characters() {
for c in [
'\u{202E}', '\u{2066}', '\u{200B}', '\u{2060}', '\u{2028}', '\u{FEFF}', '\u{FFFA}', '\u{E0041}', ] {
let escaped = escape_markdown(&format!("a{c}b"));
assert_eq!(escaped, "a b", "{c:?} must be replaced with a space");
}
}
#[test]
fn test_escape_markdown_replaces_1323_gap_characters() {
for c in [
'\u{00AD}', '\u{180E}', '\u{2061}', '\u{2062}', '\u{2063}', '\u{2064}', '\u{206A}', '\u{206B}', '\u{206C}', '\u{206D}', '\u{206E}', '\u{206F}', '\u{13430}', '\u{1343F}', '\u{1BCA0}', '\u{1BCA3}', '\u{1D173}', '\u{1D17A}', ] {
let escaped = escape_markdown(&format!("a{c}b"));
assert_eq!(escaped, "a b", "{c:?} must be replaced with a space");
}
}
#[test]
fn test_escape_markdown_now_blocks_unassigned_u2065() {
assert_eq!(escape_markdown("a\u{2065}b"), "a b");
}
#[test]
fn test_escape_markdown_still_preserves_legitimate_bidi_marks_after_1323() {
for c in ['\u{200F}', '\u{061C}', '\u{200E}', '\u{200C}', '\u{200D}'] {
let escaped = escape_markdown(&format!("a{c}b"));
assert_eq!(
escaped,
format!("a{c}b"),
"{c:?} must survive verbatim (legitimate RTL/emoji use, #1248)"
);
}
}
#[test]
fn test_escape_markdown_preserves_prefixed_format_signs() {
for c in [
'\u{0600}',
'\u{0601}',
'\u{0602}',
'\u{0603}',
'\u{0604}',
'\u{0605}',
'\u{06DD}',
'\u{070F}',
'\u{0890}',
'\u{0891}',
'\u{08E2}',
'\u{110BD}',
'\u{110CD}',
] {
let escaped = escape_markdown(&format!("a{c}b"));
assert_eq!(
escaped,
format!("a{c}b"),
"{c:?} must survive verbatim (legitimate Arabic/Syriac/Kaithi use, #1248)"
);
}
}
#[test]
fn test_is_markdown_unsafe_exempt_set_matches_sanitize_invisible_drift_guard() {
const EXEMPT_CHARS: &[char] = &[
'\u{200E}',
'\u{200F}',
'\u{061C}',
'\u{200C}',
'\u{200D}',
'\u{0600}',
'\u{0601}',
'\u{0602}',
'\u{0603}',
'\u{0604}',
'\u{0605}',
'\u{06DD}',
'\u{070F}',
'\u{0890}',
'\u{0891}',
'\u{08E2}',
'\u{110BD}',
'\u{110CD}',
];
let mut undocumented_exemptions = Vec::new();
for cp in 0..=0x0010_FFFFu32 {
let Some(c) = char::from_u32(cp) else {
continue;
};
if crate::redact::is_invisible(c)
&& !is_markdown_unsafe(c)
&& !EXEMPT_CHARS.contains(&c)
{
undocumented_exemptions.push(c);
}
}
assert!(
undocumented_exemptions.is_empty(),
"is_markdown_unsafe silently exempts characters sanitize_invisible strips, \
outside the documented #1248/#1323 exempt set: {undocumented_exemptions:?}"
);
for &c in EXEMPT_CHARS {
assert!(
crate::redact::is_invisible(c) && !is_markdown_unsafe(c),
"{c:?} is listed in the exempt set but is either not stripped by \
sanitize_invisible or is already blocked by is_markdown_unsafe — the \
exempt-set constant is stale"
);
}
}
#[test]
fn test_replace_markdown_unsafe_chars_is_idempotent() {
for c in [
'\u{202E}',
'\u{2066}',
'\u{200B}',
'\u{2060}',
'\u{2028}',
'\u{2029}',
'\u{FEFF}',
'\u{FFFA}',
'\u{E0041}',
'\n',
'\t',
] {
let input = format!("a{c}b");
let once = replace_markdown_unsafe_chars(&input);
assert_eq!(once, "a b", "{c:?} must be replaced with a single space");
let twice = replace_markdown_unsafe_chars(&once);
assert_eq!(twice, once, "{c:?} must not change on re-application");
}
assert!(
!is_markdown_unsafe(' '),
"substituted space must be stable under re-application"
);
}
#[test]
fn test_escape_markdown_preserves_legitimate_bidi_marks() {
for c in [
'\u{200F}', '\u{061C}', '\u{200E}', '\u{200D}', ] {
let escaped = escape_markdown(&format!("a{c}b"));
assert_eq!(
escaped,
format!("a{c}b"),
"{c:?} must survive verbatim (legitimate RTL/emoji use)"
);
}
}
#[test]
fn test_markdown_code_span_replaces_bidi_and_invisible_characters() {
for c in [
'\u{202E}',
'\u{2066}',
'\u{200B}',
'\u{2060}',
'\u{2029}',
'\u{FEFF}',
'\u{FFFA}',
'\u{E0041}',
] {
let span = markdown_code_span(&format!("a{c}b"));
assert_eq!(span, "`a b`", "{c:?} must be replaced with a space");
}
}
#[test]
fn test_markdown_code_span_preserves_legitimate_bidi_marks() {
for c in ['\u{200F}', '\u{061C}', '\u{200E}', '\u{200D}'] {
let span = markdown_code_span(&format!("a{c}b"));
assert_eq!(
span,
format!("`a{c}b`"),
"{c:?} must survive verbatim (legitimate RTL/emoji use)"
);
}
}
#[test]
fn test_is_same_major_minor_full_match() {
assert!(is_same_major_minor("1.2.3", "1.2.9"));
}
#[test]
fn test_is_same_major_minor_exact_match() {
assert!(is_same_major_minor("1.2.3", "1.2.3"));
}
#[test]
fn test_is_same_major_minor_major_only_match() {
assert!(is_same_major_minor("1", "1.2.3"));
assert!(is_same_major_minor("1.2.3", "1"));
}
#[test]
fn test_is_same_major_minor_no_match_different_minor() {
assert!(!is_same_major_minor("1.2.3", "1.3.0"));
}
#[test]
fn test_is_same_major_minor_no_match_different_major() {
assert!(!is_same_major_minor("1.2.3", "2.2.3"));
}
#[test]
fn test_is_same_major_minor_empty_strings() {
assert!(!is_same_major_minor("", ""));
assert!(!is_same_major_minor("1.2.3", ""));
assert!(!is_same_major_minor("", "1.2.3"));
}
#[test]
fn test_requirement_contains_template_placeholder_bare_and_braced() {
assert!(requirement_contains_template_placeholder("$REACT_VERSION"));
assert!(requirement_contains_template_placeholder(
"${REACT_VERSION}"
));
assert!(requirement_contains_template_placeholder("1.0.0-$BUILD"));
assert!(requirement_contains_template_placeholder(
"v${MAJOR}.${MINOR}"
));
}
#[test]
fn test_requirement_contains_template_placeholder_lowercase_and_mixed_case() {
assert!(requirement_contains_template_placeholder("$react_version"));
assert!(requirement_contains_template_placeholder(
"${React_Version}"
));
}
#[test]
fn test_requirement_contains_template_placeholder_ordinary_requirements_not_flagged() {
assert!(!requirement_contains_template_placeholder("1.2.3"));
assert!(!requirement_contains_template_placeholder("^1.2.3"));
assert!(!requirement_contains_template_placeholder("~1.2.3"));
assert!(!requirement_contains_template_placeholder(">=1.0.0 <2.0.0"));
assert!(!requirement_contains_template_placeholder(""));
assert!(!requirement_contains_template_placeholder("price-is-$5"));
assert!(!requirement_contains_template_placeholder("trailing-$"));
assert!(!requirement_contains_template_placeholder("empty-${}"));
assert!(!requirement_contains_template_placeholder("${123}"));
}
#[test]
fn test_requirement_contains_template_placeholder_unclosed_brace_fails_safe() {
assert!(requirement_contains_template_placeholder("${VAR"));
}
#[test]
fn test_requirement_contains_template_placeholder_dollar_paren_form() {
assert!(requirement_contains_template_placeholder(
"$(LODASH_VERSION)"
));
assert!(requirement_contains_template_placeholder(
"require golang.org/x/net $(NET_VERSION)"
));
}
#[test]
fn test_requirement_contains_template_placeholder_dollar_paren_unclosed_not_matched() {
assert!(!requirement_contains_template_placeholder("$(VAR"));
assert!(!requirement_contains_template_placeholder(
"price-is-$(five"
));
}
#[test]
fn test_requirement_contains_template_placeholder_dollar_paren_no_identifier_not_matched() {
assert!(!requirement_contains_template_placeholder("$()"));
assert!(!requirement_contains_template_placeholder("$(123)"));
assert!(!requirement_contains_template_placeholder(
"$(SERDE VERSION)"
));
}
#[test]
fn test_requirement_contains_template_placeholder_dollar_paren_hyphen_form() {
assert!(requirement_contains_template_placeholder("$(MOD-VERSION)"));
assert!(requirement_contains_template_placeholder(
"$(SERDE-VERSION)"
));
assert!(requirement_contains_template_placeholder(
"1.0.0-$(BUILD-SUFFIX)"
));
assert!(!requirement_contains_template_placeholder("$(-VERSION)"));
}
#[test]
fn test_requirement_contains_template_placeholder_dollar_paren_hyphen_edge_cases() {
assert!(requirement_contains_template_placeholder("$(VERSION-)"));
assert!(requirement_contains_template_placeholder("$(MOD--VERSION)"));
assert!(requirement_contains_template_placeholder("$(_-X)"));
assert!(!requirement_contains_template_placeholder("$(-)"));
}
#[test]
fn test_requirement_contains_template_placeholder_dollar_paren_dotted_form_not_matched() {
assert!(!requirement_contains_template_placeholder("$(A.VERSION)"));
assert!(!requirement_contains_template_placeholder(
"$(MOD.SUB-VERSION)"
));
}
#[test]
fn test_generic_template_placeholders_fixture_matches_detector() {
for &placeholder in crate::conformance::GENERIC_TEMPLATE_PLACEHOLDERS {
assert!(
requirement_contains_template_placeholder(placeholder),
"{placeholder:?} (from GENERIC_TEMPLATE_PLACEHOLDERS) must be recognized by \
requirement_contains_template_placeholder"
);
}
}
#[test]
fn test_requirement_contains_template_placeholder_mustache_form() {
assert!(requirement_contains_template_placeholder(
"{{ STD_VERSION }}"
));
assert!(requirement_contains_template_placeholder("{{STD_VERSION}}"));
assert!(requirement_contains_template_placeholder(
"{{ .NetVersion }}"
));
assert!(requirement_contains_template_placeholder(
"v1.2-{{ BUILD | default }}"
));
assert!(requirement_contains_template_placeholder("{{ VAR"));
}
#[test]
fn test_requirement_contains_template_placeholder_mustache_empty_not_flagged() {
assert!(!requirement_contains_template_placeholder("{{}}"));
assert!(!requirement_contains_template_placeholder("{{ }}"));
}
#[test]
fn test_requirement_contains_template_placeholder_jinja_statement_tag() {
assert!(requirement_contains_template_placeholder("{% if x %}"));
assert!(requirement_contains_template_placeholder("{%if x%}"));
assert!(requirement_contains_template_placeholder("{%- if x -%}"));
assert!(requirement_contains_template_placeholder(
"v1.2-{% BUILD %}"
));
assert!(requirement_contains_template_placeholder("{% VAR"));
assert!(!requirement_contains_template_placeholder("{%}"));
assert!(!requirement_contains_template_placeholder("{% %}"));
}
#[test]
fn test_requirement_contains_template_placeholder_autoconf_form() {
assert!(requirement_contains_template_placeholder(
"@PACKAGE_VERSION@"
));
assert!(requirement_contains_template_placeholder("v1.2-@BUILD@"));
assert!(!requirement_contains_template_placeholder(
"@PACKAGE_VERSION"
));
assert!(!requirement_contains_template_placeholder("me@example.com"));
}
#[test]
fn test_requirement_contains_template_placeholder_autoconf_dotted_form() {
assert!(requirement_contains_template_placeholder(
"@project.version@"
));
assert!(requirement_contains_template_placeholder(
"@PACKAGE_VERSION_MAJOR@"
));
assert!(requirement_contains_template_placeholder("@some-flag@"));
assert!(requirement_contains_template_placeholder(
"v@project.version@"
));
assert!(!requirement_contains_template_placeholder(
"@project.version"
));
}
#[test]
fn test_requirement_contains_template_placeholder_percent_form() {
assert!(requirement_contains_template_placeholder("%VERSION%"));
assert!(requirement_contains_template_placeholder("v1.2-%BUILD%"));
assert!(!requirement_contains_template_placeholder("%VERSION"));
assert!(!requirement_contains_template_placeholder("100%"));
}
#[test]
fn test_requirement_contains_template_placeholder_erb_form() {
assert!(requirement_contains_template_placeholder("<%= version %>"));
assert!(requirement_contains_template_placeholder("<%version%>"));
assert!(requirement_contains_template_placeholder("<%- version %>"));
assert!(!requirement_contains_template_placeholder("<%%>"));
assert!(!requirement_contains_template_placeholder("<% %>"));
}
#[test]
fn test_is_safe_version_string_accepts_ordinary_versions() {
assert!(is_safe_version_string("1.2.3"));
assert!(is_safe_version_string("1.2.3-beta.1+build"));
assert!(is_safe_version_string("v1.2.3"));
}
#[test]
fn test_is_safe_version_string_rejects_empty_or_whitespace() {
assert!(!is_safe_version_string(""));
assert!(!is_safe_version_string(" "));
assert!(!is_safe_version_string("\t\n"));
}
#[test]
fn test_is_safe_version_string_rejects_control_and_structural_characters() {
for bad in [
"1.2.3\n",
"1.2.3\t",
"1.2.3\"",
"1.2.3'",
"1.2.3<",
"1.2.3>",
"1.2.3&",
"1.2.3\\",
"1.0.0\", \"malicious\": \"true",
] {
assert!(
!is_safe_version_string(bad),
"expected {bad:?} to be rejected"
);
}
}
#[test]
fn test_is_safe_version_string_rejects_gradle_interpolation_payload() {
for bad in ["1.0${System.getenv(\"X\")}", "1.0$var", "${evil}"] {
assert!(
!is_safe_version_string(bad),
"expected {bad:?} to be rejected"
);
}
}
#[test]
fn test_is_safe_version_string_rejects_invisible_unicode() {
for bad in ["1.2.3\u{202E}", "1.2.3\u{200B}", "1.2.3\u{2028}"] {
assert!(
!is_safe_version_string(bad),
"expected {bad:?} to be rejected"
);
}
}
#[test]
fn test_is_safe_version_string_accepts_pep440_epoch() {
assert!(is_safe_version_string("1!2.0"));
}
#[test]
fn test_is_safe_version_string_length_cap() {
assert!(is_safe_version_string(&"1".repeat(64)));
assert!(!is_safe_version_string(&"1".repeat(65)));
}
#[test]
fn test_is_safe_maven_coordinate_segment_accepts_real_ids() {
assert!(is_safe_maven_coordinate_segment("org.apache.commons"));
assert!(is_safe_maven_coordinate_segment("commons-lang3"));
assert!(is_safe_maven_coordinate_segment("jackson-core_2.13"));
}
#[test]
fn test_is_safe_maven_coordinate_segment_rejects_empty() {
assert!(!is_safe_maven_coordinate_segment(""));
}
#[test]
fn test_is_safe_maven_coordinate_segment_rejects_xml_structural_characters() {
for bad in [
"commons</artifactId><parent>",
"commons\"",
"commons'",
"commons&",
"commons\nlang3",
"commons\tlang3",
] {
assert!(
!is_safe_maven_coordinate_segment(bad),
"expected {bad:?} to be rejected"
);
}
}
#[test]
fn test_is_safe_maven_coordinate_segment_rejects_group_artifact_colon() {
assert!(!is_safe_maven_coordinate_segment(
"org.apache.commons:commons-lang3"
));
}
#[test]
fn test_is_safe_maven_coordinate_segment_length_cap() {
assert!(is_safe_maven_coordinate_segment(&"a".repeat(128)));
assert!(!is_safe_maven_coordinate_segment(&"a".repeat(129)));
}
#[test]
fn test_is_safe_registry_url_accepts_real_urls() {
assert!(is_safe_registry_url("https://github.com/apple/swift-nio"));
assert!(is_safe_registry_url(
"https://github.com/apple/swift-nio.git"
));
assert!(is_safe_registry_url("https://github.com/apple/swift%2Dnio"));
}
#[test]
fn test_is_safe_registry_url_rejects_non_https_scheme() {
assert!(!is_safe_registry_url("http://example.com/repo"));
assert!(!is_safe_registry_url("file:///etc/passwd"));
assert!(!is_safe_registry_url("javascript:alert(1)"));
assert!(!is_safe_registry_url("ftp://example.com/repo"));
assert!(!is_safe_registry_url(""));
}
#[test]
fn test_is_safe_registry_url_rejects_swift_string_literal_breakout() {
for bad in [
"https://evil.example\", .exact(\"1.0.0\")), .package(url: \"https://real",
"https://evil.example\\",
"https://evil.example\nlet x = 1",
"https://evil.example`echo`",
"https://evil.example<script>",
] {
assert!(
!is_safe_registry_url(bad),
"expected {bad:?} to be rejected"
);
}
}
#[test]
fn test_is_safe_registry_url_length_cap() {
let prefix = "https://example.com/";
let at_cap = format!("{prefix}{}", "a".repeat(2048 - prefix.len()));
assert_eq!(at_cap.len(), 2048);
assert!(is_safe_registry_url(&at_cap));
let over_cap = format!("{at_cap}a");
assert_eq!(over_cap.len(), 2049);
assert!(!is_safe_registry_url(&over_cap));
}
#[test]
fn test_is_safe_package_name_accepts_real_names_across_ecosystems() {
for good in [
"serde", "requests", "@scope/name", "monolog/monolog", "github.com/org/repo", "path", "org.apache.commons:commons-lang3", "Newtonsoft.Json", "rails", "npm:react", "jsr:@std/fs", "github.com/foo/bar~compat", ] {
assert!(
is_safe_package_name(good),
"expected {good:?} to be accepted"
);
}
}
#[test]
fn test_is_safe_package_name_rejects_empty() {
assert!(!is_safe_package_name(""));
}
#[test]
fn test_is_safe_package_name_rejects_non_ascii() {
assert!(!is_safe_package_name("café"));
assert!(!is_safe_package_name("пакет"));
}
#[test]
fn test_is_safe_package_name_accepts_dot_dot_shapes() {
assert!(is_safe_package_name(".."));
assert!(is_safe_package_name("../.."));
}
#[test]
fn test_is_safe_package_name_rejects_structural_breakout_characters() {
for bad in [
"evil\"\nbackdoor = \"9.9.9",
"evil\", git = \"https://evil",
"evil\\",
"evil'",
"evil<script>",
"evil`echo`",
"evil\ninjected = true",
"evil\tname",
] {
assert!(
!is_safe_package_name(bad),
"expected {bad:?} to be rejected"
);
}
}
#[test]
fn test_is_safe_package_name_length_cap() {
assert!(is_safe_package_name(&"a".repeat(256)));
assert!(!is_safe_package_name(&"a".repeat(257)));
}
#[test]
fn test_is_safe_feature_name_accepts_real_names() {
for good in ["derive", "std_alloc-v2+extra"] {
assert!(
is_safe_feature_name(good),
"expected {good:?} to be accepted"
);
}
}
#[test]
fn test_is_safe_feature_name_rejects_empty() {
assert!(!is_safe_feature_name(""));
}
#[test]
fn test_is_safe_feature_name_rejects_non_ascii() {
assert!(!is_safe_feature_name("café"));
assert!(!is_safe_feature_name("evil\u{202E}reversed"));
}
#[test]
fn test_is_safe_feature_name_rejects_enable_syntax() {
for bad in ["dep:ravif", "rgb?/serde"] {
assert!(
!is_safe_feature_name(bad),
"expected {bad:?} to be rejected"
);
}
}
#[test]
fn test_is_safe_feature_name_length_cap() {
assert!(is_safe_feature_name(&"a".repeat(256)));
assert!(!is_safe_feature_name(&"a".repeat(257)));
}
#[test]
fn test_is_same_major_minor_partial_versions() {
assert!(is_same_major_minor("1.2", "1.2.3"));
assert!(is_same_major_minor("1.2.3", "1.2"));
}
#[test]
fn test_ecosystem_formatter_defaults() {
let formatter = MOCK_FORMATTER;
assert_eq!(
formatter.normalize_package_name(&pkg("test-pkg")),
"test-pkg"
);
assert_eq!(formatter.yanked_message(), "This version has been yanked");
assert_eq!(formatter.yanked_label(), "*(yanked)*");
}
#[test]
fn test_format_version_replacing_for_default_delegates_to_format_version_replacing() {
let formatter = MOCK_FORMATTER;
let dep = MockDep {
name: pkg("test-pkg"),
version_req: VersionReq::new("1.0.0"),
version_range: Range::default(),
name_range: Range::default(),
};
assert_eq!(
formatter.format_version_replacing_for(&dep, &ConcreteVersion::new("1.2.3"), "1.0.0"),
formatter.format_version_replacing(&ConcreteVersion::new("1.2.3"), "1.0.0")
);
}
#[test]
fn test_ecosystem_formatter_version_satisfies() {
let formatter = MOCK_FORMATTER;
assert!(formatter.version_satisfies_requirement(
&ConcreteVersion::new("1.2.3"),
&VersionReq::new("1.2.3")
));
assert!(formatter.version_satisfies_requirement(
&ConcreteVersion::new("1.2.3"),
&VersionReq::new("^1.2")
));
assert!(formatter.version_satisfies_requirement(
&ConcreteVersion::new("1.2.3"),
&VersionReq::new("~1.2")
));
assert!(
formatter.version_satisfies_requirement(
&ConcreteVersion::new("1.2.3"),
&VersionReq::new("1")
)
);
assert!(formatter.version_satisfies_requirement(
&ConcreteVersion::new("1.2.3"),
&VersionReq::new("1.2")
));
assert!(!formatter.version_satisfies_requirement(
&ConcreteVersion::new("1.2.3"),
&VersionReq::new("2.0.0")
));
assert!(!formatter.version_satisfies_requirement(
&ConcreteVersion::new("1.2.3"),
&VersionReq::new("1.3")
));
}
#[test]
fn test_ecosystem_formatter_custom_normalize() {
struct PyPIFormatter;
impl PackageNaming for PyPIFormatter {
fn normalize_package_name(&self, name: &PackageName) -> String {
name.as_str().to_lowercase().replace('-', "_")
}
}
impl PackageRendering for PyPIFormatter {
fn format_version_for_text_edit(&self, version: &ConcreteVersion) -> String {
format!(
">={},<{}",
version,
version.as_str().split('.').next().unwrap_or("0")
)
}
fn package_url(&self, name: &PackageName) -> String {
format!("https://pypi.org/project/{}", name.as_str())
}
}
impl RequirementResolution for PyPIFormatter {}
impl DiagnosticMessages for PyPIFormatter {}
impl DiagnosticPolicy for PyPIFormatter {}
impl SourcePolicy for PyPIFormatter {}
impl OsvNaming for PyPIFormatter {}
let formatter = PyPIFormatter;
assert_eq!(
formatter.normalize_package_name(&pkg("Test-Package")),
"test_package"
);
assert_eq!(
formatter.format_version_for_text_edit(&ConcreteVersion::new("1.2.3")),
">=1.2.3,<1"
);
assert_eq!(
formatter.package_url(&pkg("requests")),
"https://pypi.org/project/requests"
);
}
struct DepWithSource {
name: PackageName,
name_range: crate::position::Range,
source: DependencySource,
}
impl Dependency for DepWithSource {
fn name(&self) -> &PackageName {
&self.name
}
fn name_range(&self) -> crate::position::Range {
self.name_range
}
fn version_requirement(&self) -> Option<&VersionReq> {
None
}
fn version_range(&self) -> Option<crate::position::Range> {
None
}
fn source(&self) -> DependencySource {
self.source.clone()
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
fn structural_skip_entry() -> UpgradeStatus {
UpgradeStatus::StructurallyUnchecked(StructuralSkipReason::NonRegistrySource)
}
struct MockMirrorFormatter;
impl PackageNaming for MockMirrorFormatter {}
impl PackageRendering for MockMirrorFormatter {
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 MockMirrorFormatter {}
impl DiagnosticMessages for MockMirrorFormatter {}
impl DiagnosticPolicy for MockMirrorFormatter {}
impl SourcePolicy for MockMirrorFormatter {
fn source_is_public_registry_content(&self, source: &DependencySource) -> bool {
matches!(
source,
DependencySource::Registry
| DependencySource::AlternateRegistry {
mirrors_crates_io: true,
..
}
)
}
}
impl OsvNaming for MockMirrorFormatter {}
#[test]
fn structural_skip_same_source_stays_not_applicable() {
let dep = DepWithSource {
name: pkg("vendored-lib"),
name_range: crate::position::Range::default(),
source: DependencySource::Path {
path: "../vendored-lib".to_string(),
},
};
let formatter = crate::test_util::StubFormatter::new();
let mut latest_status = LatestStatusMap::new();
latest_status.insert(
crate::test_util::vuln_key("vendored-lib"),
structural_skip_entry(),
);
assert_eq!(
latest_verdict(
Some(&latest_status),
&dep,
None,
"vendored-lib",
"1.0.0",
&formatter
),
LatestVerdict::NotApplicable
);
let mut candidate_status = CandidateStatusMap::new();
candidate_status.insert(
crate::test_util::vuln_key("vendored-lib"),
CandidateStatuses::Structural(StructuralSkipReason::NonRegistrySource),
);
assert_eq!(
candidate_verdict(
Some(&candidate_status),
&dep,
None,
"vendored-lib",
"1.0.0",
&formatter
),
LatestVerdict::NotApplicable
);
}
#[test]
fn structural_skip_source_changed_to_registry_is_not_stale_safe() {
let dep = DepWithSource {
name: pkg("vendored-lib"),
name_range: crate::position::Range::default(),
source: DependencySource::Registry,
};
let formatter = crate::test_util::StubFormatter::new();
let mut latest_status = LatestStatusMap::new();
latest_status.insert(
crate::test_util::vuln_key("vendored-lib"),
structural_skip_entry(),
);
assert_eq!(
latest_verdict(
Some(&latest_status),
&dep,
None,
"vendored-lib",
"2.0.0",
&formatter
),
LatestVerdict::Unverified
);
let mut candidate_status = CandidateStatusMap::new();
candidate_status.insert(
crate::test_util::vuln_key("vendored-lib"),
CandidateStatuses::Structural(StructuralSkipReason::NonRegistrySource),
);
assert_eq!(
candidate_verdict(
Some(&candidate_status),
&dep,
None,
"vendored-lib",
"2.0.0",
&formatter
),
LatestVerdict::Unverified
);
}
#[test]
fn structural_skip_source_changed_to_mirrored_alternate_registry_is_not_stale_safe() {
let dep = DepWithSource {
name: pkg("vendored-lib"),
name_range: crate::position::Range::default(),
source: DependencySource::AlternateRegistry {
index: "https://crates-mirror.example.com/index".to_string(),
mirrors_crates_io: true,
},
};
let formatter = MockMirrorFormatter;
let mut latest_status = LatestStatusMap::new();
latest_status.insert(
crate::test_util::vuln_key("vendored-lib"),
structural_skip_entry(),
);
assert_eq!(
latest_verdict(
Some(&latest_status),
&dep,
None,
"vendored-lib",
"2.0.0",
&formatter
),
LatestVerdict::Unverified
);
let mut candidate_status = CandidateStatusMap::new();
candidate_status.insert(
crate::test_util::vuln_key("vendored-lib"),
CandidateStatuses::Structural(StructuralSkipReason::NonRegistrySource),
);
assert_eq!(
candidate_verdict(
Some(&candidate_status),
&dep,
None,
"vendored-lib",
"2.0.0",
&formatter
),
LatestVerdict::Unverified
);
}
#[test]
fn unmappable_name_resolves_to_not_applicable_for_latest_and_candidate() {
let dep = DepWithSource {
name: pkg("jsr-pinned"),
name_range: crate::position::Range::default(),
source: DependencySource::Registry,
};
let formatter = crate::test_util::StubFormatter::new();
let mut latest_status = LatestStatusMap::new();
latest_status.insert(
crate::test_util::vuln_key("jsr-pinned"),
UpgradeStatus::CandidateUnverified {
version: ConcreteVersion::new("1.0.0"),
reason: SkipReason::UnmappableName,
},
);
assert_eq!(
latest_verdict(
Some(&latest_status),
&dep,
None,
"jsr-pinned",
"1.0.0",
&formatter
),
LatestVerdict::NotApplicable
);
let mut candidate_status = CandidateStatusMap::new();
candidate_status.insert(
crate::test_util::vuln_key("jsr-pinned"),
CandidateStatuses::Structural(StructuralSkipReason::UnmappableName),
);
assert_eq!(
candidate_verdict(
Some(&candidate_status),
&dep,
None,
"jsr-pinned",
"1.0.0",
&formatter
),
LatestVerdict::NotApplicable
);
}
mod fallback_edit_excludes_newer_tests {
use super::*;
use crate::ParseResult;
use crate::edit::{ManifestEdit, ManifestReparse};
use crate::position::Position;
struct SemverMatcher(semver::VersionReq);
impl RequirementMatcher for SemverMatcher {
fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
semver::Version::parse(version.as_str())
.ok()
.map(|v| self.0.matches(&v))
}
fn strict_prerelease_exclusion(&self) -> bool {
true
}
}
struct SemverFormatter;
impl PackageNaming for SemverFormatter {}
impl PackageRendering for SemverFormatter {
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 SemverFormatter {
fn compile_bounded_requirement(
&self,
requirement: BoundedVersionReq<'_>,
) -> Option<Box<dyn RequirementMatcher>> {
semver::VersionReq::parse(requirement.as_str())
.ok()
.map(|req| Box::new(SemverMatcher(req)) as Box<dyn RequirementMatcher>)
}
}
impl DiagnosticMessages for SemverFormatter {}
impl DiagnosticPolicy for SemverFormatter {}
impl SourcePolicy for SemverFormatter {}
impl OsvNaming for SemverFormatter {}
struct SelectivelyUncompilableFormatter;
impl PackageNaming for SelectivelyUncompilableFormatter {}
impl PackageRendering for SelectivelyUncompilableFormatter {
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 SelectivelyUncompilableFormatter {
fn compile_bounded_requirement(
&self,
requirement: BoundedVersionReq<'_>,
) -> Option<Box<dyn RequirementMatcher>> {
if requirement.as_str() == "uncompilable" {
return None;
}
semver::VersionReq::parse(requirement.as_str())
.ok()
.map(|req| Box::new(SemverMatcher(req)) as Box<dyn RequirementMatcher>)
}
}
impl DiagnosticMessages for SelectivelyUncompilableFormatter {}
impl DiagnosticPolicy for SelectivelyUncompilableFormatter {}
impl SourcePolicy for SelectivelyUncompilableFormatter {}
impl OsvNaming for SelectivelyUncompilableFormatter {}
struct NoCompileFormatter;
impl PackageNaming for NoCompileFormatter {}
impl PackageRendering for NoCompileFormatter {
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 NoCompileFormatter {}
impl DiagnosticMessages for NoCompileFormatter {}
impl DiagnosticPolicy for NoCompileFormatter {}
impl SourcePolicy for NoCompileFormatter {}
impl OsvNaming for NoCompileFormatter {}
struct SemverBackedFormatter;
impl PackageNaming for SemverBackedFormatter {}
impl PackageRendering for SemverBackedFormatter {
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 SemverBackedFormatter {
fn compile_bounded_requirement(
&self,
requirement: BoundedVersionReq<'_>,
) -> Option<Box<dyn RequirementMatcher>> {
semver::VersionReq::parse(requirement.as_str())
.ok()
.map(|req| Box::new(SemverMatcher(req)) as Box<dyn RequirementMatcher>)
}
}
impl DiagnosticMessages for SemverBackedFormatter {}
impl DiagnosticPolicy for SemverBackedFormatter {}
impl SourcePolicy for SemverBackedFormatter {}
impl OsvNaming for SemverBackedFormatter {}
struct NugetFloorMatcher(semver::Version);
impl RequirementMatcher for NugetFloorMatcher {
fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
semver::Version::parse(version.as_str())
.ok()
.map(|v| v >= self.0)
}
fn strict_prerelease_exclusion(&self) -> bool {
false
}
}
struct NugetFloorFormatter;
impl PackageNaming for NugetFloorFormatter {}
impl PackageRendering for NugetFloorFormatter {
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 NugetFloorFormatter {
fn compile_bounded_requirement(
&self,
requirement: BoundedVersionReq<'_>,
) -> Option<Box<dyn RequirementMatcher>> {
semver::Version::parse(requirement.as_str())
.ok()
.map(|floor| Box::new(NugetFloorMatcher(floor)) as Box<dyn RequirementMatcher>)
}
fn is_bounded_requirement_up_to_date(
&self,
requirement: BoundedVersionReq<'_>,
latest: &ConcreteVersion,
) -> bool {
semver::Version::parse(requirement.as_str())
.ok()
.zip(semver::Version::parse(latest.as_str()).ok())
.is_some_and(|(floor, latest)| floor >= latest)
}
fn bounded_requirement_already_resolves_to(
&self,
requirement: BoundedVersionReq<'_>,
target: &ConcreteVersion,
) -> bool {
semver::Version::parse(requirement.as_str())
.ok()
.zip(semver::Version::parse(target.as_str()).ok())
.is_some_and(|(floor, target)| floor == target)
}
}
impl DiagnosticMessages for NugetFloorFormatter {}
impl DiagnosticPolicy for NugetFloorFormatter {}
impl SourcePolicy for NugetFloorFormatter {}
impl OsvNaming for NugetFloorFormatter {}
struct ExclusionMatcher {
range: semver::VersionReq,
excluded: String,
}
impl RequirementMatcher for ExclusionMatcher {
fn matches(&self, version: &ConcreteVersion) -> Option<bool> {
if version.as_str() == self.excluded {
return Some(false);
}
semver::Version::parse(version.as_str())
.ok()
.map(|v| self.range.matches(&v))
}
fn strict_prerelease_exclusion(&self) -> bool {
true
}
fn explicitly_excludes(&self, version: &ConcreteVersion) -> bool {
version.as_str() == self.excluded
}
}
struct ExclusionFormatter;
impl PackageNaming for ExclusionFormatter {}
impl PackageRendering for ExclusionFormatter {
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 ExclusionFormatter {
fn compile_bounded_requirement(
&self,
requirement: BoundedVersionReq<'_>,
) -> Option<Box<dyn RequirementMatcher>> {
let (range, excluded) = requirement.as_str().split_once(",!=")?;
let range = semver::VersionReq::parse(range).ok()?;
Some(Box::new(ExclusionMatcher {
range,
excluded: excluded.to_string(),
}) as Box<dyn RequirementMatcher>)
}
fn is_bounded_requirement_up_to_date(
&self,
_requirement: BoundedVersionReq<'_>,
_latest: &ConcreteVersion,
) -> bool {
false
}
}
impl DiagnosticMessages for ExclusionFormatter {}
impl DiagnosticPolicy for ExclusionFormatter {}
impl SourcePolicy for ExclusionFormatter {}
impl OsvNaming for ExclusionFormatter {}
struct StubDep {
name: PackageName,
requirement: Option<VersionReq>,
version_start: Position,
}
impl Dependency for StubDep {
fn name(&self) -> &PackageName {
&self.name
}
fn name_range(&self) -> Range {
Range::default()
}
fn version_requirement(&self) -> Option<&VersionReq> {
self.requirement.as_ref()
}
fn version_range(&self) -> Option<Range> {
Some(Range::new(self.version_start, self.version_start))
}
fn source(&self) -> crate::parser::DependencySource {
crate::parser::DependencySource::Registry
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
struct StubParseResult {
deps: Vec<StubDep>,
uri: url::Url,
}
impl ParseResult for StubParseResult {
fn dependencies(&self) -> Vec<&dyn Dependency> {
self.deps.iter().map(|d| d as &dyn Dependency).collect()
}
fn workspace_root(&self) -> Option<&std::path::Path> {
None
}
fn uri(&self) -> &url::Url {
&self.uri
}
fn as_any(&self) -> &dyn std::any::Any {
self
}
}
fn dep(requirement: &str) -> StubDep {
StubDep {
name: pkg("pkg"),
requirement: Some(VersionReq::new(requirement)),
version_start: Position::new(0, 0),
}
}
fn edit() -> ManifestEdit {
ManifestEdit {
range: Range::new(Position::new(0, 0), Position::new(0, 0)),
new_text: "x".to_string(),
}
}
fn versions(strs: &[&str]) -> Vec<ConcreteVersion> {
strs.iter().map(|s| ConcreteVersion::new(*s)).collect()
}
fn reparsed_to(requirement: &str) -> impl ManifestReparse {
let requirement = requirement.to_string();
move |_content: &str| -> Option<Box<dyn ParseResult>> {
Some(Box::new(StubParseResult {
deps: vec![StubDep {
name: pkg("pkg"),
requirement: Some(VersionReq::new(requirement.as_str())),
version_start: Position::new(0, 0),
}],
uri: crate::test_util::test_uri("/test/manifest.toml"),
}))
}
}
#[test]
fn original_oversized_a0_pre() {
let oversized = "1".repeat(MAX_REQUIREMENT_LEN + 1);
let verdict = fallback_edit_excludes_newer(
&SemverBackedFormatter,
&reparsed_to("1.1.0"),
"content",
&dep(&oversized),
&edit(),
&ConcreteVersion::new("1.1.0"),
&versions(&["1.1.0"]),
);
assert_eq!(
verdict,
FallbackEditVerdict::Rejected(FallbackEditRejection::OriginalOversized)
);
}
#[test]
fn original_uncompilable_a0() {
let verdict = fallback_edit_excludes_newer(
&NoCompileFormatter,
&reparsed_to("1.1.0"),
"content",
&dep("^1.0"),
&edit(),
&ConcreteVersion::new("1.1.0"),
&versions(&["1.1.0"]),
);
assert_eq!(
verdict,
FallbackEditVerdict::Rejected(FallbackEditRejection::OriginalUncompilable)
);
}
#[test]
fn original_already_up_to_date_c0() {
let verdict = fallback_edit_excludes_newer(
&NugetFloorFormatter,
&reparsed_to("1.9.0"),
"content",
&dep("2.0.0"),
&edit(),
&ConcreteVersion::new("1.9.0"),
&versions(&["1.9.0"]),
);
assert_eq!(
verdict,
FallbackEditVerdict::Rejected(FallbackEditRejection::OriginalAlreadyUpToDate)
);
}
#[test]
fn original_resolves_past_fallback_d0() {
let verdict = fallback_edit_excludes_newer(
&SemverFormatter,
&reparsed_to("1.9.0"),
"content",
&dep("^2.0"),
&edit(),
&ConcreteVersion::new("1.9.0"),
&versions(&["2.0.0", "1.9.0"]),
);
assert_eq!(
verdict,
FallbackEditVerdict::Rejected(FallbackEditRejection::OriginalResolvesPastFallback)
);
}
#[test]
fn original_excludes_fallback_via_not_equal() {
let verdict = fallback_edit_excludes_newer(
&ExclusionFormatter,
&reparsed_to("1.5.0"),
"content",
&dep(">=1.0.0,<2.0.0,!=1.5.0"),
&edit(),
&ConcreteVersion::new("1.5.0"),
&versions(&["1.6.0", "1.5.0", "1.0.0"]),
);
assert_eq!(
verdict,
FallbackEditVerdict::Rejected(FallbackEditRejection::OriginalExcludesFallback)
);
}
#[test]
fn original_excludes_fallback_via_not_equal_is_caught_even_when_vacuous() {
let verdict = fallback_edit_excludes_newer(
&ExclusionFormatter,
&reparsed_to("1.5.0"),
"content",
&dep(">=1.0.0,<2.0.0,!=1.5.0"),
&edit(),
&ConcreteVersion::new("1.5.0"),
&versions(&["2.0.0", "1.5.0", "1.0.0"]),
);
assert_eq!(
verdict,
FallbackEditVerdict::Rejected(FallbackEditRejection::OriginalExcludesFallback)
);
}
#[test]
fn ceiling_exceeding_fallback_is_not_excluded_via_not_equal_check() {
let verdict = fallback_edit_excludes_newer(
&SemverFormatter,
&reparsed_to("^2.5.0"),
"content",
&dep("^1.0"),
&edit(),
&ConcreteVersion::new("2.5.0"),
&versions(&["3.0.0", "2.5.0", "1.0.0"]),
);
assert_eq!(verdict, FallbackEditVerdict::Writable);
}
#[test]
fn reparse_failed() {
let reparse = |_content: &str| -> Option<Box<dyn ParseResult>> { None };
let verdict = fallback_edit_excludes_newer(
&SemverFormatter,
&reparse,
"content",
&dep("1.0"),
&edit(),
&ConcreteVersion::new("1.5.0"),
&versions(&["1.5.0"]),
);
assert_eq!(
verdict,
FallbackEditVerdict::Rejected(FallbackEditRejection::ReparseFailed)
);
}
#[test]
fn occurrence_not_unique() {
let reparse = |_content: &str| -> Option<Box<dyn ParseResult>> {
Some(Box::new(StubParseResult {
deps: vec![
StubDep {
name: pkg("pkg"),
requirement: Some(VersionReq::new("1.5.0")),
version_start: Position::new(0, 0),
},
StubDep {
name: pkg("pkg"),
requirement: Some(VersionReq::new("1.5.0")),
version_start: Position::new(0, 0),
},
],
uri: crate::test_util::test_uri("/test/manifest.toml"),
}) as Box<dyn ParseResult>)
};
let verdict = fallback_edit_excludes_newer(
&SemverFormatter,
&reparse,
"content",
&dep("1.0"),
&edit(),
&ConcreteVersion::new("1.5.0"),
&versions(&["1.5.0"]),
);
assert_eq!(
verdict,
FallbackEditVerdict::Rejected(FallbackEditRejection::OccurrenceNotUnique)
);
}
#[test]
fn edited_oversized_a1_pre() {
let oversized = "1".repeat(MAX_REQUIREMENT_LEN + 1);
let verdict = fallback_edit_excludes_newer(
&SemverBackedFormatter,
&reparsed_to(&oversized),
"content",
&dep("1.0"),
&edit(),
&ConcreteVersion::new("1.5.0"),
&versions(&["1.5.0"]),
);
assert_eq!(
verdict,
FallbackEditVerdict::Rejected(FallbackEditRejection::EditedOversized)
);
}
#[test]
fn edited_uncompilable_a1() {
let verdict = fallback_edit_excludes_newer(
&SelectivelyUncompilableFormatter,
&reparsed_to("uncompilable"),
"content",
&dep("1.0"),
&edit(),
&ConcreteVersion::new("1.5.0"),
&versions(&["1.5.0"]),
);
assert_eq!(
verdict,
FallbackEditVerdict::Rejected(FallbackEditRejection::EditedUncompilable)
);
}
#[test]
fn edited_excludes_fallback_b1() {
let verdict = fallback_edit_excludes_newer(
&SemverFormatter,
&reparsed_to("^5"),
"content",
&dep("1.0"),
&edit(),
&ConcreteVersion::new("1.5.0"),
&versions(&["1.5.0"]),
);
assert_eq!(
verdict,
FallbackEditVerdict::Rejected(FallbackEditRejection::EditedExcludesFallback)
);
}
#[test]
fn edited_admits_newer_d1() {
let verdict = fallback_edit_excludes_newer(
&SemverFormatter,
&reparsed_to("2.0.1"),
"content",
&dep("2.0.0"),
&edit(),
&ConcreteVersion::new("2.0.1"),
&versions(&["2.1.0", "2.0.1"]),
);
assert_eq!(
verdict,
FallbackEditVerdict::Rejected(FallbackEditRejection::EditedAdmitsNewer)
);
}
#[test]
fn writable() {
let verdict = fallback_edit_excludes_newer(
&SemverFormatter,
&reparsed_to("^2.5.0"),
"content",
&dep("2.0"),
&edit(),
&ConcreteVersion::new("2.5.0"),
&versions(&["3.0.0", "2.5.0"]),
);
assert_eq!(verdict, FallbackEditVerdict::Writable);
}
#[test]
fn nuget_located_floor_loosening_is_writable() {
let verdict = fallback_edit_excludes_newer(
&NugetFloorFormatter,
&reparsed_to("1.1.0"),
"content",
&dep("1.0.0"),
&edit(),
&ConcreteVersion::new("1.1.0"),
&versions(&["1.2.0", "1.1.0", "1.0.0"]),
);
assert_eq!(verdict, FallbackEditVerdict::Writable);
}
#[test]
fn candidate_span_mismatch_is_rejected() {
let mismatched_candidate = ManifestEdit {
range: Range::new(Position::new(1, 0), Position::new(1, 0)),
new_text: "1.1.0".to_string(),
};
let verdict = fallback_edit_excludes_newer(
&NugetFloorFormatter,
&reparsed_to("1.1.0"),
"content",
&dep("1.0.0"),
&mismatched_candidate,
&ConcreteVersion::new("1.1.0"),
&versions(&["1.2.0", "1.1.0"]),
);
assert_eq!(
verdict,
FallbackEditVerdict::Rejected(FallbackEditRejection::CandidateSpanMismatch)
);
}
}
}