use std::collections::HashMap;
use std::sync::Arc;
use tower_lsp_server::ls_types::{Position, Range, TextEdit, Uri};
use crate::osv::VulnerabilityMap;
use crate::{
ConcreteVersion, Deprecation, DepsDevClient, EcosystemId, FetchFailure, PackageName,
RemovalStatus,
};
mod code_actions;
mod code_lenses;
mod diagnostics;
mod formatter;
mod git_ref;
mod hover;
mod in_use_version;
mod inlay_hints;
#[cfg(test)]
mod test_support;
pub use code_actions::generate_code_actions;
pub use code_lenses::{
PIN_ALL_TO_SHA_COMMAND_ID, PinNoun, build_pin_all_to_sha_lens, collect_update_all_edits,
dedup_overlapping_edits, generate_code_lenses,
};
pub use diagnostics::{
DEPRECATED_DIAGNOSTIC_CODE, DiagnosticSeverities, UNSATISFIABLE_DIAGNOSTIC_CODE,
compile_requirement_unless, generate_diagnostics_from_cache, requirement_is_unsatisfiable,
truncate_for_diagnostic,
};
pub use formatter::{
DiagnosticMessages, DiagnosticPolicy, EcosystemFormatter, OsvNaming, PackageNaming,
PackageRendering, RequirementResolution, SourcePolicy,
};
pub use git_ref::{
CharOffsets, MAX_FALLBACK_SCAN_BYTES, is_full_sha, is_tag_shaped, locate_value_span,
match_v_prefix_style,
};
pub use hover::{CMD_DOT_FOOTER, generate_hover};
pub use in_use_version::{concrete_pin_version, in_use_version, is_full_semver_shape};
pub use inlay_hints::generate_inlay_hints;
pub const HOVER_RECENT_VERSIONS: usize = 8;
#[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>,
}
impl PackageVersions {
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,
}
}
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,
}
}
}
#[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
}
}
#[derive(Debug, Clone, Copy)]
pub struct VersionData<'a> {
pub cached: &'a HashMap<PackageName, PackageVersions>,
pub resolved: &'a HashMap<PackageName, ConcreteVersion>,
pub vulnerabilities: Option<&'a VulnerabilityMap>,
pub outcomes: Option<&'a DependencyOutcomes>,
pub ecosystem: Option<EcosystemId>,
pub offline: bool,
pub trust: Option<&'a Arc<DepsDevClient>>,
}
impl<'a> VersionData<'a> {
pub fn new(
cached: &'a HashMap<PackageName, PackageVersions>,
resolved: &'a HashMap<PackageName, ConcreteVersion>,
) -> Self {
Self {
cached,
resolved,
vulnerabilities: None,
outcomes: None,
ecosystem: None,
offline: false,
trust: None,
}
}
#[must_use]
pub fn with_vulnerabilities(mut self, vulnerabilities: &'a VulnerabilityMap) -> Self {
self.vulnerabilities = Some(vulnerabilities);
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_offline(mut self, offline: bool) -> Self {
self.offline = offline;
self
}
#[must_use]
pub const fn with_trust(mut self, client: &'a Arc<DepsDevClient>) -> Self {
self.trust = Some(client);
self
}
}
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 struct LineOffsetTable {
line_starts: Vec<usize>,
}
impl LineOffsetTable {
pub fn new(content: &str) -> Self {
let mut line_starts = vec![0];
for (i, c) in content.char_indices() {
if c == '\n' {
line_starts.push(i + 1);
}
}
Self { line_starts }
}
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);
let line_start = self.line_starts[line];
let character = content[line_start..offset]
.chars()
.map(|c| c.len_utf16() as u32)
.sum();
Position::new(line as u32, character)
}
pub fn position_to_byte_offset(&self, content: &str, position: Position) -> usize {
let Some(&line_start) = self.line_starts.get(position.line as usize) else {
return content.len();
};
let line_end = self
.line_starts
.get(position.line as usize + 1)
.copied()
.unwrap_or(content.len());
let line = &content[line_start..line_end];
crate::completion::utf16_to_byte_offset(line, position.character)
.map_or(line_end, |offset| line_start + offset)
.min(content.len())
}
}
pub fn escape_markdown(s: &str) -> String {
let mut escaped = String::with_capacity(s.len());
for c in s.chars() {
if c.is_control() {
escaped.push(' ');
continue;
}
if c.is_ascii_punctuation() {
escaped.push('\\');
}
escaped.push(c);
}
escaped
}
pub fn markdown_code_span(content: &str) -> String {
let sanitized: String = content
.chars()
.map(|c| if c.is_control() { ' ' } else { c })
.collect();
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,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RequirementStatus {
UpToDate,
Outdated,
Unresolved,
}
pub trait RequirementMatcher: Send + Sync {
fn matches(&self, version: &ConcreteVersion) -> Option<bool>;
}
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 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 warn_rejected_value(gate: &str, context: &str, value: &str) {
tracing::warn!(
gate,
context,
len = value.len(),
"rejected unsafe value before manifest/registry sink"
);
}
fn single_file_edit(uri: &Uri, range: Range, new_text: String) -> HashMap<Uri, Vec<TextEdit>> {
let mut edits = HashMap::new();
edits.insert(uri.clone(), vec![TextEdit { range, new_text }]);
edits
}
fn strip_whitespace(s: &str) -> String {
s.chars().filter(|c| !c.is_whitespace()).collect()
}
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("")
}
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
}
#[cfg(test)]
mod tests {
use super::*;
use crate::lsp_helpers::test_support::*;
use crate::{PackageName, VersionReq};
#[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_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_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_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_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 = MockFormatter;
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 = MockFormatter;
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 = MockFormatter;
assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "1.2.3"));
assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "^1.2"));
assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "~1.2"));
assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "1"));
assert!(formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "1.2"));
assert!(!formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "2.0.0"));
assert!(!formatter.version_satisfies_requirement(&ConcreteVersion::new("1.2.3"), "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)
}
}
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"
);
}
}