pub mod ignore;
pub mod security;
use deps_core::ConcreteVersion;
use deps_core::PackageName;
use deps_core::VersionReq;
use deps_core::edit::{
EditSpan, ManifestEdit, ManifestReparse, UnplannableReason, UpdateCandidate, UpdateKind,
apply_edits, classify_update, collect_update_candidates, dedup_overlapping_edits,
};
use deps_core::lsp_helpers::{
CooldownDisposition, EcosystemFormatter, FallbackEditVerdict, LatestVerdict, PackageVersions,
cooldown_disposition, fallback_edit_excludes_newer, latest_verdict,
};
use std::collections::HashMap;
use crate::analyze::ManifestAnalysis;
use ignore::IgnoreRules;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CurrentVersion {
Resolved(ConcreteVersion),
Declared(VersionReq),
Unknown,
}
impl From<Option<ConcreteVersion>> for CurrentVersion {
fn from(value: Option<ConcreteVersion>) -> Self {
match value {
Some(version) => Self::Resolved(version),
None => Self::Unknown,
}
}
}
impl CurrentVersion {
#[must_use]
pub fn update_kind_to(&self, target: &ConcreteVersion) -> UpdateKind {
match self {
Self::Resolved(version) => classify_update(version.as_str(), target.as_str()),
Self::Declared(_) | Self::Unknown => UpdateKind::Unknown,
}
}
pub(crate) fn render_text(&self) -> String {
match self {
Self::Resolved(version) => version.to_string(),
Self::Declared(req) => req.as_str().to_string(),
Self::Unknown => String::new(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct UpdatePlan {
pub items: Vec<PlannedUpdateItem>,
}
impl UpdatePlan {
fn applied_edits(&self) -> Vec<ManifestEdit> {
self.items
.iter()
.filter_map(|item| match &item.outcome {
Outcome::Applied { edit, .. } => Some(edit.clone()),
_ => None,
})
.collect()
}
}
#[derive(Debug, Clone)]
pub struct PlannedUpdateItem {
pub name: String,
pub current: CurrentVersion,
pub outcome: Outcome,
pub advisory_ids: Vec<String>,
pub ignore_rule_overridden: bool,
pub gossip_excluded_version: Option<deps_core::ConcreteVersion>,
pub cooldown_fallback: Option<CooldownFallbackNote>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CooldownFallbackNote {
AppliedInsteadOf(deps_core::ConcreteVersion),
Blocked {
version: deps_core::ConcreteVersion,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Outcome {
Applied {
edit: ManifestEdit,
target: ConcreteVersion,
},
Skipped {
reason: SkipReason,
target: Option<ConcreteVersion>,
},
RequiresLockfileUpdate {
target: ConcreteVersion,
},
Unfixable(UnfixableReason),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SkipReason {
IgnoreRule,
NotRequested,
NotSafelyEditable(deps_core::edit::UnplannableReason),
OverlapsAnotherEdit,
WithinFreshnessCooldown,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UnfixableReason {
NoVerifiedFix,
FetchFailedOrAbsent,
Yanked {
target: ConcreteVersion,
},
UnsupportedRequirementShape {
target: ConcreteVersion,
},
OversizedRequirement {
target: ConcreteVersion,
},
}
impl Outcome {
#[must_use]
pub const fn wire_token(&self) -> &'static str {
match self {
Self::Applied { .. } => "applied",
Self::Skipped { .. } => "skipped",
Self::RequiresLockfileUpdate { .. } => "requires-lockfile-update",
Self::Unfixable(_) => "unfixable",
}
}
#[must_use]
pub const fn target(&self) -> Option<&ConcreteVersion> {
match self {
Self::Applied { target, .. } | Self::RequiresLockfileUpdate { target } => Some(target),
Self::Skipped { target, .. } => target.as_ref(),
Self::Unfixable(
UnfixableReason::Yanked { target }
| UnfixableReason::UnsupportedRequirementShape { target }
| UnfixableReason::OversizedRequirement { target },
) => Some(target),
Self::Unfixable(
UnfixableReason::NoVerifiedFix | UnfixableReason::FetchFailedOrAbsent,
) => None,
}
}
}
impl PlannedUpdateItem {
#[must_use]
pub fn new(
name: String,
current: CurrentVersion,
outcome: Outcome,
advisory_ids: Vec<String>,
ignore_rule_overridden: bool,
gossip_excluded_version: Option<deps_core::ConcreteVersion>,
cooldown_fallback: Option<CooldownFallbackNote>,
) -> Self {
Self {
name,
current,
outcome,
advisory_ids,
ignore_rule_overridden,
gossip_excluded_version,
cooldown_fallback,
}
}
#[must_use]
pub const fn target(&self) -> Option<&ConcreteVersion> {
self.outcome.target()
}
#[must_use]
pub fn reason(&self) -> String {
let base = match &self.outcome {
Outcome::Applied { .. } => "update applied",
Outcome::Skipped {
reason: SkipReason::IgnoreRule,
..
} => "matched an [update].ignore rule",
Outcome::Skipped {
reason: SkipReason::NotRequested,
..
} => "not named by --package",
Outcome::Skipped {
reason:
SkipReason::NotSafelyEditable(
deps_core::edit::UnplannableReason::UnsafeLatestVersion,
),
..
} => "the registry-reported latest version failed a safety check",
Outcome::Skipped {
reason:
SkipReason::NotSafelyEditable(deps_core::edit::UnplannableReason::NonLiteralSpan),
..
} => {
"the declared version is not a plain literal (e.g. a property reference or variable) and cannot be safely rewritten"
}
Outcome::Skipped {
reason:
SkipReason::NotSafelyEditable(deps_core::edit::UnplannableReason::NoOpRewrite),
..
} => "no single unambiguous rewrite exists for this dependency's requirement syntax",
Outcome::Skipped {
reason:
SkipReason::NotSafelyEditable(
deps_core::edit::UnplannableReason::LatestFlaggedByOsv,
),
..
} => {
if matches!(
self.cooldown_fallback,
Some(CooldownFallbackNote::Blocked { .. })
) {
"the freshness-cooldown fallback candidate is flagged by OSV.dev — refusing to write it"
} else {
"the registry's latest version is flagged by OSV.dev — refusing to write it"
}
}
Outcome::Skipped {
reason:
SkipReason::NotSafelyEditable(deps_core::edit::UnplannableReason::LatestUnverified),
..
} => {
if matches!(
self.cooldown_fallback,
Some(CooldownFallbackNote::Blocked { .. })
) {
"the freshness-cooldown fallback candidate could not be verified against OSV.dev — refusing to write it"
} else {
"the registry's latest version could not be verified against OSV.dev — refusing to write it"
}
}
Outcome::Skipped {
reason: SkipReason::OverlapsAnotherEdit,
..
} => "this edit's span overlapped another item's and was dropped",
Outcome::Skipped {
reason: SkipReason::WithinFreshnessCooldown,
..
} => "the selected update target was published within the freshness cooldown window",
Outcome::RequiresLockfileUpdate { .. } => {
"declared requirement already admits the fix target; regenerate the lock file (see #1116)"
}
Outcome::Unfixable(UnfixableReason::NoVerifiedFix) => {
"no independently-verified fix target is available"
}
Outcome::Unfixable(UnfixableReason::FetchFailedOrAbsent) => {
"registry fetch for this dependency failed or returned no data"
}
Outcome::Unfixable(UnfixableReason::Yanked { .. }) => "the fix target is yanked",
Outcome::Unfixable(UnfixableReason::UnsupportedRequirementShape { .. }) => {
"the declared requirement's syntax has no safe single-value rewrite and does not already admit the fix target — manual edit required"
}
Outcome::Unfixable(UnfixableReason::OversizedRequirement { .. }) => {
"the declared requirement is too large to safely evaluate; treating as unfixable — manual edit required"
}
};
let mut reason = base.to_string();
if self.ignore_rule_overridden {
reason.push_str(" (a matching [update].ignore rule was overridden by --security-only)");
}
if self.gossip_excluded_version.is_some() {
reason.push_str(
" (a newer version was excluded from this pick by an active GOSSIP cooldown finding)",
);
}
match &self.cooldown_fallback {
Some(CooldownFallbackNote::AppliedInsteadOf(latest)) => {
reason.push_str(&format!(
" (targeted a cooldown-cleared fallback instead of {latest}, which is still \
within its freshness cooldown window)"
));
}
Some(CooldownFallbackNote::Blocked { version }) => {
reason.push_str(&format!(" (candidate: {version})"));
}
None => {}
}
reason
}
}
#[must_use]
pub fn is_requested(
package_filter: &[String],
normalized_name: &str,
formatter: &dyn EcosystemFormatter,
) -> bool {
package_filter.is_empty()
|| package_filter.iter().any(|name| {
formatter.normalize_package_name(&PackageName::new(name.clone())) == normalized_name
})
}
fn cached_package_versions<'a>(
analysis: &'a ManifestAnalysis,
normalized_name: &str,
raw_name: &str,
) -> Option<&'a deps_core::lsp_helpers::PackageVersions> {
analysis
.cached_versions
.get(normalized_name)
.or_else(|| analysis.cached_versions.get(raw_name))
}
fn gossip_excluded_version(
analysis: &ManifestAnalysis,
normalized_name: &str,
raw_name: &str,
) -> Option<deps_core::ConcreteVersion> {
cached_package_versions(analysis, normalized_name, raw_name)
.and_then(|v| v.gossip_excluded_version.clone())
}
fn resolve_cooldown_fallback_view(
analysis: &ManifestAnalysis,
freshness: deps_core::FreshnessSettings,
now: deps_core::PublishTime,
) -> Option<HashMap<PackageName, PackageVersions>> {
if let Some(view) = analysis.cooldown_fallback_view.as_ref() {
return Some(view.clone());
}
let view = crate::analyze::cooldown_fallback_view(
&analysis.cached_versions,
Some(&analysis.gossip_findings),
freshness,
now,
);
view.iter()
.any(|(name, v)| {
analysis
.cached_versions
.get(name)
.is_none_or(|c| c.latest != v.latest)
})
.then_some(view)
}
#[must_use]
#[expect(
clippy::too_many_arguments,
reason = "spec 076 T004 adds `reparse` (FR-024) to the existing FR-007 planner surface; \
grouping into a struct would only move, not reduce, churn (mirrors \
resolve_occurrence's own identical rationale)"
)]
pub fn plan_updates(
analysis: &ManifestAnalysis,
content: &str,
formatter: &dyn EcosystemFormatter,
reparse: &dyn ManifestReparse,
package_filter: &[String],
ignore_rules: &IgnoreRules,
freshness: deps_core::FreshnessSettings,
now: deps_core::PublishTime,
) -> UpdatePlan {
let latest_candidates = collect_update_candidates(
analysis.parse_result.as_ref(),
content,
analysis.version_data(),
formatter,
);
let fallback_view = resolve_cooldown_fallback_view(analysis, freshness, now);
let empty_fallback_status = deps_core::osv::LatestStatusMap::new();
let fallback_candidates = match fallback_view.as_ref() {
Some(view) => {
let mut fallback_version_data =
deps_core::VersionData::new(view, &analysis.resolved_versions)
.with_resolved_version_candidates(&analysis.resolved_version_candidates)
.with_ecosystem(analysis.ecosystem_id);
fallback_version_data = match analysis.fallback_status.as_ref() {
Some(fallback_status) => fallback_version_data.with_latest_status(fallback_status),
None if analysis.latest_status.is_some() => {
fallback_version_data.with_latest_status(&empty_fallback_status)
}
None => fallback_version_data,
};
collect_update_candidates(
analysis.parse_result.as_ref(),
content,
fallback_version_data,
formatter,
)
}
None => Vec::new(),
};
let mut deps_by_name_range: HashMap<
(String, deps_core::position::Range),
Vec<&dyn deps_core::Dependency>,
> = HashMap::new();
for dep in analysis.parse_result.dependencies() {
deps_by_name_range
.entry((
formatter.normalize_package_name(dep.name()),
dep.name_range(),
))
.or_default()
.push(dep);
}
let occurrence_key = |normalized_name: &str, name_range: deps_core::position::Range| {
let version_range = match deps_by_name_range
.get(&(normalized_name.to_string(), name_range))
.map(Vec::as_slice)
{
Some([dep]) => dep.version_range(),
_ => None,
};
(normalized_name.to_string(), name_range, version_range)
};
let mut fallback_by_key: HashMap<OccurrenceKey, UpdateCandidate> = fallback_candidates
.into_iter()
.filter(|c| {
let (normalized_name, name_range) = candidate_identity(c);
deps_by_name_range
.get(&(normalized_name, name_range))
.is_some_and(|deps| deps.len() == 1)
})
.map(|c| {
let (normalized_name, name_range) = candidate_identity(&c);
(occurrence_key(&normalized_name, name_range), c)
})
.collect();
let dep_by_key: HashMap<OccurrenceKey, &dyn deps_core::Dependency> = deps_by_name_range
.iter()
.filter_map(
|(&(ref normalized_name, name_range), deps)| match deps.as_slice() {
[dep] => Some((occurrence_key(normalized_name, name_range), *dep)),
_ => None,
},
)
.collect();
let vuln_keys = deps_core::osv::vulnerability_keys(
analysis.parse_result.as_ref(),
&analysis.resolved_versions,
Some(&analysis.resolved_version_candidates),
formatter,
analysis.ecosystem_id,
);
let mut items: Vec<PlannedUpdateItem> = latest_candidates
.into_iter()
.map(|latest_candidate| {
let (normalized_name, name_range) = candidate_identity(&latest_candidate);
let key = occurrence_key(&normalized_name, name_range);
resolve_occurrence(
latest_candidate,
key,
&mut fallback_by_key,
&dep_by_key,
analysis,
content,
formatter,
reparse,
package_filter,
ignore_rules,
freshness,
&vuln_keys,
now,
)
})
.collect();
dedup_applied_items(&mut items);
UpdatePlan { items }
}
type OccurrenceKey = (
String,
deps_core::position::Range,
Option<deps_core::position::Range>,
);
fn candidate_identity(candidate: &UpdateCandidate) -> (String, deps_core::position::Range) {
match candidate {
UpdateCandidate::Planned(p) => (p.normalized_name.clone(), p.name_range),
UpdateCandidate::Unplannable {
normalized_name,
name_range,
..
} => (normalized_name.clone(), *name_range),
}
}
fn latest_current_target(candidate: &UpdateCandidate) -> (CurrentVersion, Option<ConcreteVersion>) {
match candidate {
UpdateCandidate::Planned(p) => (
CurrentVersion::from(p.current.clone()),
Some(p.target.clone()),
),
UpdateCandidate::Unplannable { .. } => (CurrentVersion::Unknown, None),
}
}
fn resolve_from_latest(
latest_candidate: UpdateCandidate,
ignore_rules: &IgnoreRules,
) -> (CurrentVersion, Outcome, Vec<String>) {
match latest_candidate {
UpdateCandidate::Planned(p) => {
let target = p.target.clone();
let current = CurrentVersion::from(p.current);
let kind = current.update_kind_to(&p.target);
if let Some(reason) = ignore_rules.skip_reason(&p.normalized_name, kind) {
(
current,
Outcome::Skipped {
reason,
target: Some(target),
},
Vec::new(),
)
} else {
(
current,
Outcome::Applied {
edit: p.edit,
target,
},
Vec::new(),
)
}
}
UpdateCandidate::Unplannable {
normalized_name,
reason,
..
} => {
let outcome = if let Some(rule_reason) =
ignore_rules.skip_reason(&normalized_name, UpdateKind::Unknown)
{
Outcome::Skipped {
reason: rule_reason,
target: None,
}
} else {
Outcome::Skipped {
reason: SkipReason::NotSafelyEditable(reason),
target: None,
}
};
(CurrentVersion::Unknown, outcome, Vec::new())
}
}
}
fn osv_advisory_ids(
status: Option<&deps_core::osv::LatestStatusMap>,
dep: Option<&dyn deps_core::Dependency>,
vuln_keys: &deps_core::osv::VulnKeys,
normalized_name: &str,
version: &str,
formatter: &dyn EcosystemFormatter,
) -> Vec<String> {
let Some(dep) = dep else {
return Vec::new();
};
match latest_verdict(
status,
dep,
Some(vuln_keys),
normalized_name,
version,
formatter,
) {
LatestVerdict::Flagged { advisory_ids, .. } => advisory_ids,
LatestVerdict::Unverified | LatestVerdict::Verified | LatestVerdict::NotApplicable => {
Vec::new()
}
}
}
#[expect(
clippy::too_many_arguments,
reason = "every parameter is either the two views' shared lookup data or a planning \
knob already threaded through plan_updates; grouping into a struct would only \
move, not reduce, churn (mirrors deps-engine::classify::fetch's identical call)"
)]
fn resolve_occurrence(
latest_candidate: UpdateCandidate,
key: OccurrenceKey,
fallback_by_key: &mut HashMap<OccurrenceKey, UpdateCandidate>,
dep_by_key: &HashMap<OccurrenceKey, &dyn deps_core::Dependency>,
analysis: &ManifestAnalysis,
content: &str,
formatter: &dyn EcosystemFormatter,
reparse: &dyn ManifestReparse,
package_filter: &[String],
ignore_rules: &IgnoreRules,
freshness: deps_core::FreshnessSettings,
vuln_keys: &deps_core::osv::VulnKeys,
now: deps_core::PublishTime,
) -> PlannedUpdateItem {
let (name, normalized_name) = match &latest_candidate {
UpdateCandidate::Planned(p) => (p.name.clone(), p.normalized_name.clone()),
UpdateCandidate::Unplannable {
name,
normalized_name,
..
} => (name.clone(), normalized_name.clone()),
};
let gossip_excluded = gossip_excluded_version(analysis, &normalized_name, &name);
let fallback_candidate = fallback_by_key.remove(&key);
let build = |current: CurrentVersion,
outcome: Outcome,
advisory_ids: Vec<String>,
cooldown_fallback: Option<CooldownFallbackNote>| {
PlannedUpdateItem::new(
name.clone(),
current,
outcome,
advisory_ids,
false,
gossip_excluded.clone(),
cooldown_fallback,
)
};
if !is_requested(package_filter, &normalized_name, formatter) {
let (current, target) = latest_current_target(&latest_candidate);
return build(
current,
Outcome::Skipped {
reason: SkipReason::NotRequested,
target,
},
Vec::new(),
None,
);
}
let package_versions = cached_package_versions(analysis, &normalized_name, &name);
let gossip_name = PackageName::new(name.clone());
let disposition = package_versions.map_or(CooldownDisposition::NotEvaluated, |versions| {
cooldown_disposition(
versions,
&gossip_name,
freshness,
Some(&analysis.gossip_findings),
now,
)
});
let latest_is_osv_unplannable = matches!(
&latest_candidate,
UpdateCandidate::Unplannable {
reason: UnplannableReason::LatestFlaggedByOsv | UnplannableReason::LatestUnverified,
..
}
);
let never_demoted = |latest_candidate: UpdateCandidate| {
let (current, outcome, advisory_ids) = resolve_from_latest(latest_candidate, ignore_rules);
build(current, outcome, advisory_ids, None)
};
let cooldown_skip_from_latest = |latest_candidate: &UpdateCandidate| {
let (current, target) = latest_current_target(latest_candidate);
build(
current,
Outcome::Skipped {
reason: SkipReason::WithinFreshnessCooldown,
target,
},
Vec::new(),
None,
)
};
match disposition {
CooldownDisposition::NotEvaluated | CooldownDisposition::Cleared => {
let (current, outcome, advisory_ids) =
resolve_from_latest(latest_candidate, ignore_rules);
build(current, outcome, advisory_ids, None)
}
CooldownDisposition::Blocked { fallback: None, .. } => {
if latest_is_osv_unplannable {
never_demoted(latest_candidate)
} else {
cooldown_skip_from_latest(&latest_candidate)
}
}
CooldownDisposition::Blocked {
by: _,
fallback: Some(fallback),
} => {
let latest_version = package_versions.map(|v| v.latest.clone());
let available: &[deps_core::ConcreteVersion] =
package_versions.map_or(&[], |v| &v.available);
match fallback_candidate {
None => {
if latest_is_osv_unplannable {
never_demoted(latest_candidate)
} else {
cooldown_skip_from_latest(&latest_candidate)
}
}
Some(UpdateCandidate::Planned(fb)) => {
if fb.target != fallback.version {
return if latest_is_osv_unplannable {
never_demoted(latest_candidate)
} else {
cooldown_skip_from_latest(&latest_candidate)
};
}
let requirement_ok = dep_by_key.get(&key).copied().is_some_and(|dep| {
let verdict = fallback_edit_excludes_newer(
formatter, reparse, content, dep, &fb.edit, &fb.target, available,
);
if let FallbackEditVerdict::Rejected(reason) = verdict {
tracing::debug!(
package = %normalized_name,
fallback = %fb.target,
?reason,
"fallback edit rejected by fallback_edit_excludes_newer"
);
}
matches!(verdict, FallbackEditVerdict::Writable)
});
if requirement_ok {
let current = CurrentVersion::from(fb.current);
let kind = current.update_kind_to(&fb.target);
if let Some(reason) = ignore_rules.skip_reason(&fb.normalized_name, kind) {
build(
current,
Outcome::Skipped {
reason,
target: Some(fb.target.clone()),
},
Vec::new(),
None,
)
} else if latest_is_osv_unplannable {
let advisory_ids =
latest_version.as_ref().map_or_else(Vec::new, |latest| {
osv_advisory_ids(
analysis.latest_status.as_ref(),
dep_by_key.get(&key).copied(),
vuln_keys,
&normalized_name,
latest.as_str(),
formatter,
)
});
build(
current,
Outcome::Applied {
edit: fb.edit,
target: fb.target,
},
advisory_ids,
latest_version.map(CooldownFallbackNote::AppliedInsteadOf),
)
} else {
build(
current,
Outcome::Applied {
edit: fb.edit,
target: fb.target,
},
Vec::new(),
latest_version.map(CooldownFallbackNote::AppliedInsteadOf),
)
}
} else {
if latest_is_osv_unplannable {
never_demoted(latest_candidate)
} else {
cooldown_skip_from_latest(&latest_candidate)
}
}
}
Some(UpdateCandidate::Unplannable {
reason: fb_reason, ..
}) => {
if latest_is_osv_unplannable {
never_demoted(latest_candidate)
} else if let Some(rule_reason) =
ignore_rules.skip_reason(&normalized_name, UpdateKind::Unknown)
{
build(
CurrentVersion::Unknown,
Outcome::Skipped {
reason: rule_reason,
target: None,
},
Vec::new(),
None,
)
} else if matches!(
fb_reason,
UnplannableReason::LatestFlaggedByOsv | UnplannableReason::LatestUnverified
) {
let advisory_ids = osv_advisory_ids(
analysis.fallback_status.as_ref(),
dep_by_key.get(&key).copied(),
vuln_keys,
&normalized_name,
fallback.version.as_str(),
formatter,
);
let (current, _) = latest_current_target(&latest_candidate);
build(
current,
Outcome::Skipped {
reason: SkipReason::NotSafelyEditable(fb_reason),
target: Some(fallback.version.clone()),
},
advisory_ids,
Some(CooldownFallbackNote::Blocked {
version: fallback.version.clone(),
}),
)
} else {
cooldown_skip_from_latest(&latest_candidate)
}
}
}
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum ApplyError {
#[error("{path} changed on disk since it was read; aborting without writing")]
StaleManifest {
path: std::path::PathBuf,
},
#[error("failed to re-read {path}: {source}")]
Read {
path: std::path::PathBuf,
#[source]
source: std::io::Error,
},
#[error("failed to write {path}: {source}")]
Write {
path: std::path::PathBuf,
#[source]
source: std::io::Error,
},
}
pub fn dedup_applied_items(items: &mut [PlannedUpdateItem]) {
struct Indexed {
index: usize,
edit: ManifestEdit,
}
impl EditSpan for Indexed {
fn start(&self) -> (u32, u32) {
self.edit.start()
}
fn end(&self) -> (u32, u32) {
self.edit.end()
}
}
let indexed: Vec<Indexed> = items
.iter()
.enumerate()
.filter_map(|(index, item)| match &item.outcome {
Outcome::Applied { edit, .. } => Some(Indexed {
index,
edit: edit.clone(),
}),
_ => None,
})
.collect();
let kept_indices: std::collections::HashSet<usize> =
dedup_overlapping_edits(indexed, "deps-cli update dedup_applied_items")
.into_iter()
.map(|indexed| indexed.index)
.collect();
for (index, item) in items.iter_mut().enumerate() {
if !kept_indices.contains(&index)
&& let Outcome::Applied { target, .. } = &item.outcome
{
item.outcome = Outcome::Skipped {
reason: SkipReason::OverlapsAnotherEdit,
target: Some(target.clone()),
};
}
}
}
pub fn apply_plan(
plan: &UpdatePlan,
path: &std::path::Path,
original_content: &str,
dry_run: crate::format::DryRun,
) -> Result<(), ApplyError> {
let edits = dedup_overlapping_edits(plan.applied_edits(), "deps-cli update");
let new_content = apply_edits(original_content, &edits);
let reread = deps_core::fs_probe::read_to_string_capped(path, crate::MAX_MANIFEST_FILE_SIZE)
.map_err(|source| ApplyError::Read {
path: path.to_path_buf(),
source,
})?;
if reread.as_deref() != Some(original_content) {
return Err(ApplyError::StaleManifest {
path: path.to_path_buf(),
});
}
if dry_run == crate::format::DryRun::Yes || new_content == original_content {
return Ok(());
}
deps_core::fs_probe::write_atomic(path, &new_content).map_err(|source| ApplyError::Write {
path: path.to_path_buf(),
source,
})
}
#[cfg(test)]
mod tests {
use super::*;
use deps_core::licenses::LicensePolicy;
use deps_core::parser::DependencySource;
use deps_core::position::{Position, Range};
use deps_core::{Dependency, EcosystemId, PackageVersions, ParseResult};
use std::any::Any;
use std::collections::{HashMap, HashSet};
const STUB_FORMATTER: deps_core::test_util::StubFormatter =
deps_core::test_util::StubFormatter::new().with_package_url_prefix("");
#[test]
fn test_current_version_declared_update_kind_is_always_unknown() {
let declared = CurrentVersion::Declared(deps_core::VersionReq::new("^1.0"));
assert_eq!(
declared.update_kind_to(&ConcreteVersion::from("2.0.0")),
UpdateKind::Unknown
);
}
struct TestDep {
name: PackageName,
version_req: deps_core::VersionReq,
version_range: Range,
}
impl Dependency for TestDep {
fn name(&self) -> &PackageName {
&self.name
}
fn name_range(&self) -> Range {
Range::default()
}
fn version_requirement(&self) -> Option<&deps_core::VersionReq> {
Some(&self.version_req)
}
fn version_range(&self) -> Option<Range> {
Some(self.version_range)
}
fn source(&self) -> DependencySource {
DependencySource::Registry
}
fn as_any(&self) -> &dyn Any {
self
}
}
struct TestParseResult {
deps: Vec<TestDep>,
uri: url::Url,
}
impl ParseResult for TestParseResult {
fn dependencies(&self) -> Vec<&dyn Dependency> {
self.deps.iter().map(|d| d as &dyn Dependency).collect()
}
fn workspace_root(&self) -> Option<&std::path::Path> {
None
}
fn uri(&self) -> &url::Url {
&self.uri
}
fn as_any(&self) -> &dyn Any {
self
}
}
fn test_dep(name: &str, req: &str, range: Range) -> TestDep {
TestDep {
name: PackageName::new(name),
version_req: deps_core::VersionReq::new(req),
version_range: range,
}
}
fn never_reparse(_content: &str) -> Option<Box<dyn ParseResult>> {
unreachable!("this fixture's disposition must never reach the fallback-edit re-parse phase")
}
fn reparse_quoted_deps(deps: Vec<(&'static str, Position)>) -> impl ManifestReparse {
move |edited_content: &str| -> Option<Box<dyn ParseResult>> {
let rebuilt: Vec<TestDep> = deps
.iter()
.map(|&(name, start)| {
let line = edited_content.lines().nth(start.line as usize)?;
let rest = line.get(start.character as usize..)?;
let version_len = rest.find('"')?;
let version = rest.get(..version_len)?;
Some(TestDep {
name: PackageName::new(name),
version_req: deps_core::VersionReq::new(version),
version_range: Range::new(
start,
Position::new(
start.line,
start.character + u32::try_from(version_len).ok()?,
),
),
})
})
.collect::<Option<_>>()?;
Some(Box::new(TestParseResult {
deps: rebuilt,
uri: deps_core::test_util::test_uri("/test/Cargo.toml"),
}) as Box<dyn ParseResult>)
}
}
fn plan_updates_no_cooldown(
analysis: &ManifestAnalysis,
content: &str,
formatter: &dyn EcosystemFormatter,
package_filter: &[String],
ignore_rules: &IgnoreRules,
) -> UpdatePlan {
plan_updates(
analysis,
content,
formatter,
&never_reparse,
package_filter,
ignore_rules,
deps_core::FreshnessSettings::Disabled,
deps_core::PublishTime::now(),
)
}
fn test_analysis(
deps: Vec<TestDep>,
cached: HashMap<PackageName, PackageVersions>,
) -> ManifestAnalysis {
ManifestAnalysis {
parse_result: Box::new(TestParseResult {
deps,
uri: deps_core::test_util::test_uri("/test/Cargo.toml"),
}),
uri: deps_core::test_util::test_uri("/test/Cargo.toml"),
now: deps_core::PublishTime::now(),
ecosystem_id: EcosystemId::Cargo,
cached_versions: cached,
resolved_versions: HashMap::new(),
resolved_version_candidates: HashMap::new(),
outcomes: deps_core::lsp_helpers::DependencyOutcomes::new(),
vulnerabilities: None,
latest_status: None,
fallback_status: None,
cooldown_fallback_view: None,
gossip_findings: HashMap::new(),
licenses: HashMap::new(),
license_policy: LicensePolicy::default(),
license_source: deps_core::LicenseSource::default(),
network: deps_core::NetworkMode::Online,
fetch_failed: HashSet::new(),
registry_unreachable: false,
license_fetch_incomplete: false,
}
}
fn cached(name: &str, latest: &str) -> HashMap<PackageName, PackageVersions> {
let mut map = HashMap::new();
map.insert(PackageName::new(name), PackageVersions::latest_only(latest));
map
}
#[test]
fn test_plan_updates_within_freshness_cooldown_is_skipped() {
let content = "serde = \"1.0.0\"\n";
let now = deps_core::PublishTime::from_unix_secs(10_000);
let published_at = deps_core::PublishTime::from_unix_secs(9_000); let mut versions = cached("serde", "1.2.0");
versions.insert(
PackageName::new("serde"),
PackageVersions::latest_only("1.2.0").with_published_at(published_at),
);
let analysis = test_analysis(
vec![test_dep(
"serde",
"1.0.0",
Range::new(Position::new(0, 9), Position::new(0, 14)),
)],
versions,
);
let plan = plan_updates(
&analysis,
content,
&STUB_FORMATTER,
&never_reparse,
&[],
&IgnoreRules::empty(),
deps_core::FreshnessSettings::Enabled {
cooldown: deps_core::CooldownWindow::from_secs(2_000), },
now,
);
assert_eq!(plan.items.len(), 1);
assert!(matches!(
plan.items[0].outcome,
Outcome::Skipped {
reason: SkipReason::WithinFreshnessCooldown,
..
}
));
assert_eq!(
crate::exit::update_exit_code(&plan),
crate::exit::EXIT_CLEAN,
"an automatic freshness-cooldown pause must not fail the run"
);
}
#[test]
fn test_plan_updates_freshness_disabled_ignores_cooldown() {
let content = "serde = \"1.0.0\"\n";
let now = deps_core::PublishTime::from_unix_secs(10_000);
let published_at = deps_core::PublishTime::from_unix_secs(9_000);
let mut versions = cached("serde", "1.2.0");
versions.insert(
PackageName::new("serde"),
PackageVersions::latest_only("1.2.0").with_published_at(published_at),
);
let analysis = test_analysis(
vec![test_dep(
"serde",
"1.0.0",
Range::new(Position::new(0, 9), Position::new(0, 14)),
)],
versions,
);
let plan = plan_updates(
&analysis,
content,
&STUB_FORMATTER,
&never_reparse,
&[],
&IgnoreRules::empty(),
deps_core::FreshnessSettings::Disabled,
now,
);
assert_eq!(plan.items.len(), 1);
assert!(matches!(plan.items[0].outcome, Outcome::Applied { .. }));
}
#[test]
fn test_plan_updates_surfaces_gossip_excluded_version() {
let content = "serde = \"1.0.0\"\n";
let mut versions = cached("serde", "1.2.0");
versions.insert(
PackageName::new("serde"),
PackageVersions::latest_only("1.2.0")
.with_gossip_excluded_version(deps_core::ConcreteVersion::new("2.0.0")),
);
let analysis = test_analysis(
vec![test_dep(
"serde",
"1.0.0",
Range::new(Position::new(0, 9), Position::new(0, 14)),
)],
versions,
);
let plan = plan_updates_no_cooldown(
&analysis,
content,
&STUB_FORMATTER,
&[],
&IgnoreRules::empty(),
);
assert_eq!(plan.items.len(), 1);
assert!(matches!(plan.items[0].outcome, Outcome::Applied { .. }));
assert_eq!(
plan.items[0].gossip_excluded_version,
Some(deps_core::ConcreteVersion::new("2.0.0"))
);
assert_eq!(
plan.items[0].reason(),
"update applied (a newer version was excluded from this pick by an active GOSSIP cooldown finding)"
);
}
#[test]
fn test_plan_updates_freshness_cooldown_takes_precedence_but_keeps_gossip_attribution() {
let content = "serde = \"1.0.0\"\n";
let now = deps_core::PublishTime::from_unix_secs(10_000);
let published_at = deps_core::PublishTime::from_unix_secs(9_000); let mut versions = cached("serde", "1.2.0");
versions.insert(
PackageName::new("serde"),
PackageVersions::latest_only("1.2.0")
.with_published_at(published_at)
.with_gossip_excluded_version(deps_core::ConcreteVersion::new("2.0.0")),
);
let analysis = test_analysis(
vec![test_dep(
"serde",
"1.0.0",
Range::new(Position::new(0, 9), Position::new(0, 14)),
)],
versions,
);
let plan = plan_updates(
&analysis,
content,
&STUB_FORMATTER,
&never_reparse,
&[],
&IgnoreRules::empty(),
deps_core::FreshnessSettings::Enabled {
cooldown: deps_core::CooldownWindow::from_secs(2_000), },
now,
);
assert_eq!(plan.items.len(), 1);
assert!(
matches!(
plan.items[0].outcome,
Outcome::Skipped {
reason: SkipReason::WithinFreshnessCooldown,
..
}
),
"the local cooldown skip must win the outcome decision"
);
assert_eq!(
plan.items[0].gossip_excluded_version,
Some(deps_core::ConcreteVersion::new("2.0.0")),
"the GOSSIP attribution must survive the cooldown skip, not be silently dropped"
);
let reason = plan.items[0].reason();
assert!(
reason.contains("freshness cooldown window") && reason.contains("GOSSIP cooldown"),
"got: {reason}"
);
}
#[test]
fn test_plan_updates_us001_multiple_outdated_one_up_to_date() {
let content = "serde = \"1.0.0\"\ntokio = \"1.0.0\"\nlibc = \"1.2.0\"\n";
let mut versions = cached("serde", "1.2.0");
versions.insert(
PackageName::new("tokio"),
PackageVersions::latest_only("1.3.0"),
);
versions.insert(
PackageName::new("libc"),
PackageVersions::latest_only("1.2.0"),
);
let analysis = test_analysis(
vec![
test_dep(
"serde",
"1.0.0",
Range::new(Position::new(0, 9), Position::new(0, 14)),
),
test_dep(
"tokio",
"1.0.0",
Range::new(Position::new(1, 9), Position::new(1, 14)),
),
test_dep(
"libc",
"1.2.0",
Range::new(Position::new(2, 8), Position::new(2, 13)),
),
],
versions,
);
let plan = plan_updates_no_cooldown(
&analysis,
content,
&STUB_FORMATTER,
&[],
&IgnoreRules::empty(),
);
let applied: Vec<&str> = plan
.items
.iter()
.filter(|i| matches!(i.outcome, Outcome::Applied { .. }))
.map(|i| i.name.as_str())
.collect();
assert_eq!(
applied.len(),
2,
"serde and tokio are outdated, libc is not: {applied:?}"
);
assert!(applied.contains(&"serde"));
assert!(applied.contains(&"tokio"));
assert!(
!plan.items.iter().any(|i| i.name == "libc"),
"an up-to-date dependency must never appear as a candidate at all"
);
}
#[test]
fn test_plan_updates_refuses_a_flagged_latest() {
use deps_core::osv::{Capped, LatestStatusMap, UpgradeStatus, VulnSeverity};
let content = "serde = \"1.0.0\"\n";
let versions = cached("serde", "1.2.0");
let mut analysis = test_analysis(
vec![test_dep(
"serde",
"1.0.0",
Range::new(Position::new(0, 9), Position::new(0, 14)),
)],
versions,
);
let mut latest_status = LatestStatusMap::new();
latest_status.insert(
deps_core::test_util::vuln_key("serde"),
UpgradeStatus::CandidateVulnerable {
version: ConcreteVersion::new("1.2.0"),
advisory_ids: Capped::new(vec!["MAL-2026-00001".to_string()], 1),
worst_severity: Some(VulnSeverity::Malicious),
},
);
analysis.latest_status = Some(latest_status);
let plan = plan_updates_no_cooldown(
&analysis,
content,
&STUB_FORMATTER,
&[],
&IgnoreRules::empty(),
);
assert_eq!(plan.items.len(), 1);
assert!(
matches!(
plan.items[0].outcome,
Outcome::Skipped {
reason: SkipReason::NotSafelyEditable(
deps_core::edit::UnplannableReason::LatestFlaggedByOsv
),
..
}
),
"got: {:?}",
plan.items[0].outcome
);
assert_eq!(
crate::exit::update_exit_code(&plan),
crate::exit::EXIT_POLICY_VIOLATION,
"a flagged latest must never exit clean"
);
}
#[test]
fn test_plan_updates_flagged_latest_is_never_masked_by_cooldown() {
use deps_core::osv::{Capped, LatestStatusMap, UpgradeStatus, VulnSeverity};
let content = "serde = \"1.0.0\"\n";
let now = deps_core::PublishTime::from_unix_secs(10_000);
let published_at = deps_core::PublishTime::from_unix_secs(9_000); let mut versions = cached("serde", "1.2.0");
versions.insert(
PackageName::new("serde"),
PackageVersions::latest_only("1.2.0").with_published_at(published_at),
);
let mut analysis = test_analysis(
vec![test_dep(
"serde",
"1.0.0",
Range::new(Position::new(0, 9), Position::new(0, 14)),
)],
versions,
);
let mut latest_status = LatestStatusMap::new();
latest_status.insert(
deps_core::test_util::vuln_key("serde"),
UpgradeStatus::CandidateVulnerable {
version: ConcreteVersion::new("1.2.0"),
advisory_ids: Capped::new(vec!["MAL-2026-00001".to_string()], 1),
worst_severity: Some(VulnSeverity::Malicious),
},
);
analysis.latest_status = Some(latest_status);
let plan = plan_updates(
&analysis,
content,
&STUB_FORMATTER,
&never_reparse,
&[],
&IgnoreRules::empty(),
deps_core::FreshnessSettings::Enabled {
cooldown: deps_core::CooldownWindow::from_secs(2_000),
},
now,
);
assert_eq!(plan.items.len(), 1);
assert!(
matches!(
plan.items[0].outcome,
Outcome::Skipped {
reason: SkipReason::NotSafelyEditable(
deps_core::edit::UnplannableReason::LatestFlaggedByOsv
),
..
}
),
"got: {:?}",
plan.items[0].outcome
);
assert_eq!(
plan.items[0].target(),
None,
"an Unplannable candidate has no concrete target"
);
assert_eq!(
crate::exit::update_exit_code(&plan),
crate::exit::EXIT_POLICY_VIOLATION,
"a flagged latest must never exit clean, even when also within cooldown"
);
}
#[test]
fn test_plan_updates_unverified_latest_is_never_masked_by_cooldown() {
use deps_core::osv::LatestStatusMap;
let content = "serde = \"1.0.0\"\n";
let now = deps_core::PublishTime::from_unix_secs(10_000);
let published_at = deps_core::PublishTime::from_unix_secs(9_000); let mut versions = cached("serde", "1.2.0");
versions.insert(
PackageName::new("serde"),
PackageVersions::latest_only("1.2.0").with_published_at(published_at),
);
let mut analysis = test_analysis(
vec![test_dep(
"serde",
"1.0.0",
Range::new(Position::new(0, 9), Position::new(0, 14)),
)],
versions,
);
analysis.latest_status = Some(LatestStatusMap::new());
let plan = plan_updates(
&analysis,
content,
&STUB_FORMATTER,
&never_reparse,
&[],
&IgnoreRules::empty(),
deps_core::FreshnessSettings::Enabled {
cooldown: deps_core::CooldownWindow::from_secs(2_000),
},
now,
);
assert_eq!(plan.items.len(), 1);
assert!(
matches!(
plan.items[0].outcome,
Outcome::Skipped {
reason: SkipReason::NotSafelyEditable(
deps_core::edit::UnplannableReason::LatestUnverified
),
..
}
),
"got: {:?}",
plan.items[0].outcome
);
assert_eq!(
crate::exit::update_exit_code(&plan),
crate::exit::EXIT_POLICY_VIOLATION,
"an unverified latest must never exit clean, even when also within cooldown"
);
}
#[test]
fn test_plan_updates_unplannable_candidate_within_cooldown_is_cooldown_skip_not_unsafe() {
let content = "tokio = \"weird\"\n"; let now = deps_core::PublishTime::from_unix_secs(10_000);
let published_at = deps_core::PublishTime::from_unix_secs(9_000);
let versions_map = {
let mut map = cached("tokio", "2.0.0");
map.insert(
PackageName::new("tokio"),
PackageVersions::latest_only("2.0.0").with_published_at(published_at),
);
map
};
let analysis = test_analysis(
vec![test_dep(
"tokio",
"1.0.0",
Range::new(Position::new(0, 9), Position::new(0, 14)),
)],
versions_map,
);
let plan = plan_updates(
&analysis,
content,
&STUB_FORMATTER,
&never_reparse,
&[],
&IgnoreRules::empty(),
deps_core::FreshnessSettings::Enabled {
cooldown: deps_core::CooldownWindow::from_secs(2_000),
},
now,
);
assert_eq!(plan.items.len(), 1);
assert!(
matches!(
plan.items[0].outcome,
Outcome::Skipped {
reason: SkipReason::WithinFreshnessCooldown,
..
}
),
"got: {:?}",
plan.items[0].outcome
);
assert_eq!(
crate::exit::update_exit_code(&plan),
crate::exit::EXIT_CLEAN,
"a cooldown-driven pause must exit clean even when the candidate was also unplannable"
);
}
#[test]
fn test_plan_updates_refuses_an_unverified_latest() {
use deps_core::osv::LatestStatusMap;
let content = "serde = \"1.0.0\"\n";
let versions = cached("serde", "1.2.0");
let mut analysis = test_analysis(
vec![test_dep(
"serde",
"1.0.0",
Range::new(Position::new(0, 9), Position::new(0, 14)),
)],
versions,
);
analysis.latest_status = Some(LatestStatusMap::new());
let plan = plan_updates_no_cooldown(
&analysis,
content,
&STUB_FORMATTER,
&[],
&IgnoreRules::empty(),
);
assert_eq!(plan.items.len(), 1);
assert!(
matches!(
plan.items[0].outcome,
Outcome::Skipped {
reason: SkipReason::NotSafelyEditable(
deps_core::edit::UnplannableReason::LatestUnverified
),
..
}
),
"got: {:?}",
plan.items[0].outcome
);
assert_eq!(
crate::exit::update_exit_code(&plan),
crate::exit::EXIT_POLICY_VIOLATION,
"an unverified latest must never exit clean"
);
}
#[test]
fn test_plan_updates_us002_package_filter_narrows_to_one() {
let content = "serde = \"1.0.0\"\ntokio = \"1.0.0\"\n";
let mut versions = cached("serde", "1.2.0");
versions.insert(
PackageName::new("tokio"),
PackageVersions::latest_only("1.3.0"),
);
let analysis = test_analysis(
vec![
test_dep(
"serde",
"1.0.0",
Range::new(Position::new(0, 9), Position::new(0, 14)),
),
test_dep(
"tokio",
"1.0.0",
Range::new(Position::new(1, 9), Position::new(1, 14)),
),
],
versions,
);
let plan = plan_updates_no_cooldown(
&analysis,
content,
&STUB_FORMATTER,
&["serde".to_string()],
&IgnoreRules::empty(),
);
let serde_item = plan.items.iter().find(|i| i.name == "serde").unwrap();
assert!(matches!(serde_item.outcome, Outcome::Applied { .. }));
let tokio_item = plan.items.iter().find(|i| i.name == "tokio").unwrap();
assert!(matches!(
tokio_item.outcome,
Outcome::Skipped {
reason: SkipReason::NotRequested,
..
}
));
}
#[test]
fn test_plan_updates_us004_ignore_rule_skips_major_bump() {
let content = "tokio = \"1.0.0\"\n";
let versions = cached("tokio", "2.0.0");
let analysis = test_analysis(
vec![test_dep(
"tokio",
"1.0.0",
Range::new(Position::new(0, 9), Position::new(0, 14)),
)],
versions,
);
let ignore_rules = crate::update::ignore::IgnoreRules::new(
vec![crate::config::IgnoreRule {
name: "tokio".to_string(),
update_types: Some(vec![crate::config::UpdateTypeToken::Major]),
}],
&STUB_FORMATTER,
);
let plan =
plan_updates_no_cooldown(&analysis, content, &STUB_FORMATTER, &[], &ignore_rules);
assert_eq!(plan.items.len(), 1);
assert!(matches!(
plan.items[0].outcome,
Outcome::Skipped {
reason: SkipReason::IgnoreRule,
..
}
));
}
#[test]
fn test_plan_updates_unplannable_candidate_still_honors_ignore_rule() {
let content = "tokio = \"weird\"\n";
let versions = cached("tokio", "2.0.0");
let analysis = test_analysis(
vec![test_dep(
"tokio",
"1.0.0",
Range::new(Position::new(0, 9), Position::new(0, 14)),
)],
versions,
);
let ignore_rules = crate::update::ignore::IgnoreRules::new(
vec![crate::config::IgnoreRule {
name: "tokio".to_string(),
update_types: None,
}],
&STUB_FORMATTER,
);
let plan =
plan_updates_no_cooldown(&analysis, content, &STUB_FORMATTER, &[], &ignore_rules);
assert_eq!(plan.items.len(), 1);
assert!(
matches!(
plan.items[0].outcome,
Outcome::Skipped {
reason: SkipReason::IgnoreRule,
..
}
),
"got {:?}",
plan.items[0].outcome
);
}
#[test]
fn test_plan_updates_unplannable_candidate_without_ignore_rule_is_not_safely_editable() {
let content = "tokio = \"weird\"\n";
let versions = cached("tokio", "2.0.0");
let analysis = test_analysis(
vec![test_dep(
"tokio",
"1.0.0",
Range::new(Position::new(0, 9), Position::new(0, 14)),
)],
versions,
);
let plan = plan_updates_no_cooldown(
&analysis,
content,
&STUB_FORMATTER,
&[],
&IgnoreRules::empty(),
);
assert_eq!(plan.items.len(), 1);
assert!(matches!(
plan.items[0].outcome,
Outcome::Skipped {
reason: SkipReason::NotSafelyEditable(
deps_core::edit::UnplannableReason::NonLiteralSpan
),
..
}
));
}
#[test]
fn test_plan_updates_fr007_no_config_no_ignore_rules_loaded() {
let content = "tokio = \"1.0.0\"\n";
let versions = cached("tokio", "2.0.0");
let analysis = test_analysis(
vec![test_dep(
"tokio",
"1.0.0",
Range::new(Position::new(0, 9), Position::new(0, 14)),
)],
versions,
);
let plan = plan_updates_no_cooldown(
&analysis,
content,
&STUB_FORMATTER,
&[],
&IgnoreRules::empty(),
);
assert_eq!(plan.items.len(), 1);
assert!(matches!(plan.items[0].outcome, Outcome::Applied { .. }));
}
#[test]
fn test_is_requested_empty_filter_matches_everything() {
assert!(is_requested(&[], "serde", &STUB_FORMATTER));
assert!(is_requested(
&["serde".to_string()],
"serde",
&STUB_FORMATTER
));
assert!(!is_requested(
&["tokio".to_string()],
"serde",
&STUB_FORMATTER
));
}
#[test]
fn test_apply_plan_stale_manifest_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("Cargo.toml");
std::fs::write(&path, "serde = \"1.0.0\"\n").unwrap();
std::fs::write(&path, "serde = \"1.0.1\"\n").unwrap();
let plan = UpdatePlan {
items: vec![PlannedUpdateItem {
name: "serde".to_string(),
current: CurrentVersion::Resolved(ConcreteVersion::from("1.0.0")),
outcome: Outcome::Applied {
edit: ManifestEdit {
range: Range::new(Position::new(0, 9), Position::new(0, 14)),
new_text: "1.2.0".to_string(),
},
target: ConcreteVersion::from("1.2.0"),
},
advisory_ids: Vec::new(),
ignore_rule_overridden: false,
gossip_excluded_version: None,
cooldown_fallback: None,
}],
};
let result = apply_plan(
&plan,
&path,
"serde = \"1.0.0\"\n",
crate::format::DryRun::No,
);
assert!(matches!(result, Err(ApplyError::StaleManifest { .. })));
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"serde = \"1.0.1\"\n"
);
}
#[test]
fn test_apply_plan_dry_run_does_not_write() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("Cargo.toml");
std::fs::write(&path, "serde = \"1.0.0\"\n").unwrap();
let plan = UpdatePlan {
items: vec![PlannedUpdateItem {
name: "serde".to_string(),
current: CurrentVersion::Resolved(ConcreteVersion::from("1.0.0")),
outcome: Outcome::Applied {
edit: ManifestEdit {
range: Range::new(Position::new(0, 9), Position::new(0, 14)),
new_text: "1.2.0".to_string(),
},
target: ConcreteVersion::from("1.2.0"),
},
advisory_ids: Vec::new(),
ignore_rule_overridden: false,
gossip_excluded_version: None,
cooldown_fallback: None,
}],
};
apply_plan(
&plan,
&path,
"serde = \"1.0.0\"\n",
crate::format::DryRun::Yes,
)
.unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"serde = \"1.0.0\"\n"
);
}
#[test]
fn test_apply_plan_writes_applied_edits() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("Cargo.toml");
std::fs::write(&path, "serde = \"1.0.0\"\n").unwrap();
let plan = UpdatePlan {
items: vec![PlannedUpdateItem {
name: "serde".to_string(),
current: CurrentVersion::Resolved(ConcreteVersion::from("1.0.0")),
outcome: Outcome::Applied {
edit: ManifestEdit {
range: Range::new(Position::new(0, 9), Position::new(0, 14)),
new_text: "1.2.0".to_string(),
},
target: ConcreteVersion::from("1.2.0"),
},
advisory_ids: Vec::new(),
ignore_rule_overridden: false,
gossip_excluded_version: None,
cooldown_fallback: None,
}],
};
apply_plan(
&plan,
&path,
"serde = \"1.0.0\"\n",
crate::format::DryRun::No,
)
.unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"serde = \"1.2.0\"\n"
);
}
#[test]
fn test_apply_plan_skipped_items_are_not_written() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("Cargo.toml");
std::fs::write(&path, "serde = \"1.0.0\"\n").unwrap();
let plan = UpdatePlan {
items: vec![PlannedUpdateItem {
name: "serde".to_string(),
current: CurrentVersion::Resolved(ConcreteVersion::from("1.0.0")),
outcome: Outcome::Skipped {
reason: SkipReason::IgnoreRule,
target: Some(ConcreteVersion::from("1.2.0")),
},
advisory_ids: Vec::new(),
ignore_rule_overridden: false,
gossip_excluded_version: None,
cooldown_fallback: None,
}],
};
apply_plan(
&plan,
&path,
"serde = \"1.0.0\"\n",
crate::format::DryRun::No,
)
.unwrap();
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
"serde = \"1.0.0\"\n"
);
}
fn applied_item(name: &str, range: Range) -> PlannedUpdateItem {
PlannedUpdateItem {
name: name.to_string(),
current: CurrentVersion::Resolved(ConcreteVersion::from("1.0.0")),
outcome: Outcome::Applied {
edit: ManifestEdit {
range,
new_text: "1.2.0".to_string(),
},
target: ConcreteVersion::from("1.2.0"),
},
advisory_ids: Vec::new(),
ignore_rule_overridden: false,
gossip_excluded_version: None,
cooldown_fallback: None,
}
}
#[test]
fn test_dedup_applied_items_keeps_non_overlapping_edits_applied() {
let mut items = vec![
applied_item(
"serde",
Range::new(Position::new(0, 9), Position::new(0, 14)),
),
applied_item(
"tokio",
Range::new(Position::new(1, 9), Position::new(1, 14)),
),
];
dedup_applied_items(&mut items);
assert!(
items
.iter()
.all(|i| matches!(i.outcome, Outcome::Applied { .. }))
);
}
#[test]
fn test_dedup_applied_items_demotes_the_later_overlap() {
let mut items = vec![
applied_item(
"serde",
Range::new(Position::new(0, 9), Position::new(0, 14)),
),
applied_item(
"serde",
Range::new(Position::new(0, 11), Position::new(0, 16)),
),
];
dedup_applied_items(&mut items);
assert!(matches!(items[0].outcome, Outcome::Applied { .. }));
assert!(matches!(
items[1].outcome,
Outcome::Skipped {
reason: SkipReason::OverlapsAnotherEdit,
..
}
));
assert_eq!(
items[1].target(),
Some(&ConcreteVersion::from("1.2.0")),
"a demoted item must keep its target, not silently drop it (#1615)"
);
}
const FALLBACK_FORMATTER: deps_core::test_util::StubFormatter =
deps_core::test_util::StubFormatter::new().with_manifest_requirement_as_resolved_version();
struct RealSemverFormatter;
impl deps_core::lsp_helpers::PackageNaming for RealSemverFormatter {}
impl deps_core::lsp_helpers::PackageRendering for RealSemverFormatter {
fn format_version_for_text_edit(&self, v: &deps_core::ConcreteVersion) -> String {
v.to_string()
}
fn package_url(&self, name: &PackageName) -> String {
name.as_str().to_string()
}
}
impl deps_core::lsp_helpers::RequirementResolution for RealSemverFormatter {
fn compile_bounded_requirement(
&self,
requirement: deps_core::lsp_helpers::BoundedVersionReq<'_>,
) -> Option<Box<dyn deps_core::lsp_helpers::RequirementMatcher>> {
let requirement = requirement.get();
deps_core::lsp_helpers::compile_semver_requirement(requirement)
}
}
impl deps_core::lsp_helpers::DiagnosticMessages for RealSemverFormatter {}
impl deps_core::lsp_helpers::DiagnosticPolicy for RealSemverFormatter {}
impl deps_core::lsp_helpers::SourcePolicy for RealSemverFormatter {}
impl deps_core::lsp_helpers::OsvNaming for RealSemverFormatter {}
#[test]
fn test_fallback_edit_excludes_newer_rejects_a1_repro_downgrade() {
let dep = test_dep(
"pkg",
">=3.0.0",
Range::new(Position::new(0, 0), Position::new(0, 5)),
);
let edit = ManifestEdit {
range: Range::new(Position::new(0, 0), Position::new(0, 5)),
new_text: "2.9.0".to_string(),
};
let available_with_newer_match: Vec<deps_core::ConcreteVersion> =
vec!["3.2.0".into(), "2.9.0".into()];
assert_eq!(
deps_core::lsp_helpers::fallback_edit_excludes_newer(
&RealSemverFormatter,
&never_reparse,
"pkg = \">=3.0.0\"\n",
&dep,
&edit,
&deps_core::ConcreteVersion::new("2.9.0"),
&available_with_newer_match,
),
deps_core::lsp_helpers::FallbackEditVerdict::Rejected(
deps_core::lsp_helpers::FallbackEditRejection::OriginalResolvesPastFallback
),
"2.9.0 is a downgrade relative to what >=3.0.0 already resolves to (3.2.0) and must \
never be treated as a usable fallback"
);
}
#[test]
fn test_fallback_edit_excludes_newer_rejects_auto_follow_after_d0_passes_1564() {
let dep = test_dep(
"pkg",
"0.22.6",
Range::new(Position::new(0, 7), Position::new(0, 13)),
);
let edit = ManifestEdit {
range: Range::new(Position::new(0, 7), Position::new(0, 13)),
new_text: "0.22.7".to_string(),
};
let available: Vec<deps_core::ConcreteVersion> =
vec!["0.22.8".into(), "0.22.7".into(), "0.22.6".into()];
let reparse = reparse_quoted_deps(vec![("pkg", Position::new(0, 7))]);
let verdict = deps_core::lsp_helpers::fallback_edit_excludes_newer(
&RealSemverFormatter,
&reparse,
"pkg = \"0.22.6\"\n",
&dep,
&edit,
&deps_core::ConcreteVersion::new("0.22.7"),
&available,
);
assert_eq!(
verdict,
deps_core::lsp_helpers::FallbackEditVerdict::Rejected(
deps_core::lsp_helpers::FallbackEditRejection::EditedAdmitsNewer
),
"d0 passes (0.22.6's own floor is not newer than 0.22.7), but the written 0.22.7 \
caret still auto-follows into the fresh 0.22.8 — d1 must reject: {verdict:?}"
);
}
#[test]
fn test_fallback_edit_excludes_newer_fails_closed_on_unlisted_pin_1561() {
let dep = test_dep(
"pkg",
"=1.5.0",
Range::new(Position::new(0, 7), Position::new(0, 14)),
);
let edit = ManifestEdit {
range: Range::new(Position::new(0, 7), Position::new(0, 14)),
new_text: "1.4.0".to_string(),
};
let available: Vec<deps_core::ConcreteVersion> =
vec!["2.0.0".into(), "1.4.0".into(), "1.0.0".into()];
let verdict = deps_core::lsp_helpers::fallback_edit_excludes_newer(
&RealSemverFormatter,
&never_reparse,
"pkg = \"=1.5.0\"\n",
&dep,
&edit,
&deps_core::ConcreteVersion::new("1.4.0"),
&available,
);
assert_eq!(
verdict,
deps_core::lsp_helpers::FallbackEditVerdict::Rejected(
deps_core::lsp_helpers::FallbackEditRejection::OriginalResolvesPastFallback
),
"the declared pin 1.5.0 is unlisted; nothing evidences that 1.4.0 is not a \
downgrade below it, so this must fail closed: {verdict:?}"
);
}
#[test]
fn test_fallback_edit_excludes_newer_fails_closed_on_unlisted_fallback() {
let dep = test_dep(
"pkg",
"0.22.6",
Range::new(Position::new(0, 7), Position::new(0, 13)),
);
let edit = ManifestEdit {
range: Range::new(Position::new(0, 7), Position::new(0, 13)),
new_text: "0.22.7".to_string(),
};
let available: Vec<deps_core::ConcreteVersion> = vec!["0.22.8".into(), "0.22.6".into()];
let verdict = deps_core::lsp_helpers::fallback_edit_excludes_newer(
&RealSemverFormatter,
&never_reparse,
"pkg = \"0.22.6\"\n",
&dep,
&edit,
&deps_core::ConcreteVersion::new("0.22.7"),
&available,
);
assert_eq!(
verdict,
deps_core::lsp_helpers::FallbackEditVerdict::Rejected(
deps_core::lsp_helpers::FallbackEditRejection::FallbackUnlisted
),
"got: {verdict:?}"
);
}
fn fallback_scenario_analysis(
fallback_version: &str,
) -> (
ManifestAnalysis,
deps_core::FreshnessSettings,
deps_core::PublishTime,
) {
let now = deps_core::PublishTime::from_unix_secs(10_000);
let published_at = deps_core::PublishTime::from_unix_secs(9_900); let mut versions = cached("pkg", "2.0.0");
versions.insert(
PackageName::new("pkg"),
PackageVersions::new(
deps_core::ConcreteVersion::new("2.0.0"),
std::sync::Arc::from(vec![
deps_core::ConcreteVersion::new("2.0.0"),
deps_core::ConcreteVersion::new("1.1.0"),
deps_core::ConcreteVersion::new("1.0.0"),
]),
)
.with_published_at(published_at)
.with_cooldown_fallback(deps_core::lsp_helpers::CooldownFallback::new(
fallback_version.into(),
deps_core::PublishTime::from_unix_secs(1_000),
)),
);
let analysis = test_analysis(
vec![test_dep(
"pkg",
"=1.0.0",
Range::new(Position::new(0, 7), Position::new(0, 13)),
)],
versions,
);
let freshness = deps_core::FreshnessSettings::Enabled {
cooldown: deps_core::CooldownWindow::from_secs(1_000),
};
(analysis, freshness, now)
}
fn real_semver_scenario(
req: &str,
available: &[&str],
fallback_version: &str,
) -> (
ManifestAnalysis,
deps_core::FreshnessSettings,
deps_core::PublishTime,
) {
let now = deps_core::PublishTime::from_unix_secs(10_000);
let published_at = deps_core::PublishTime::from_unix_secs(9_900); let latest = available.first().copied().expect("at least one version");
let available_arc: std::sync::Arc<[deps_core::ConcreteVersion]> =
available.iter().map(|v| (*v).into()).collect();
let mut versions = HashMap::new();
versions.insert(
PackageName::new("pkg"),
PackageVersions::new(latest.into(), available_arc)
.with_published_at(published_at)
.with_cooldown_fallback(deps_core::lsp_helpers::CooldownFallback::new(
fallback_version.into(),
deps_core::PublishTime::from_unix_secs(1_000),
)),
);
let end = 7 + u32::try_from(req.len()).expect("short test literal");
let analysis = test_analysis(
vec![test_dep(
"pkg",
req,
Range::new(Position::new(0, 7), Position::new(0, end)),
)],
versions,
);
let freshness = deps_core::FreshnessSettings::Enabled {
cooldown: deps_core::CooldownWindow::from_secs(1_000),
};
(analysis, freshness, now)
}
#[test]
fn test_plan_updates_real_semver_formatter_rejects_in_range_caret_admission() {
let content = "pkg = \"=1.0.0\"\n";
let (analysis, freshness, now) =
real_semver_scenario("=1.0.0", &["1.2.0", "1.1.0", "1.0.0"], "1.1.0");
let reparse = reparse_quoted_deps(vec![("pkg", Position::new(0, 7))]);
let plan = plan_updates(
&analysis,
content,
&RealSemverFormatter,
&reparse,
&[],
&IgnoreRules::empty(),
freshness,
now,
);
assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
assert!(
matches!(
plan.items[0].outcome,
Outcome::Skipped {
reason: SkipReason::WithinFreshnessCooldown,
..
}
),
"got: {:?}",
plan.items[0].outcome
);
let dep = test_dep(
"pkg",
"=1.0.0",
Range::new(Position::new(0, 7), Position::new(0, 13)),
);
let candidate = ManifestEdit {
range: Range::new(Position::new(0, 7), Position::new(0, 13)),
new_text: "1.1.0".to_string(),
};
let verdict = deps_core::lsp_helpers::fallback_edit_excludes_newer(
&RealSemverFormatter,
&reparse,
content,
&dep,
&candidate,
&deps_core::ConcreteVersion::new("1.1.0"),
&[
deps_core::ConcreteVersion::new("1.2.0"),
deps_core::ConcreteVersion::new("1.1.0"),
deps_core::ConcreteVersion::new("1.0.0"),
],
);
assert_eq!(
verdict,
deps_core::lsp_helpers::FallbackEditVerdict::Rejected(
deps_core::lsp_helpers::FallbackEditRejection::EditedAdmitsNewer
)
);
}
#[test]
fn test_plan_updates_real_semver_formatter_rejects_a1_repro_end_to_end() {
let content = "pkg = \">=3.0.0\"\n";
let (analysis, freshness, now) =
real_semver_scenario(">=3.0.0", &["3.2.0", "2.9.0"], "2.9.0");
let plan = plan_updates(
&analysis,
content,
&RealSemverFormatter,
&never_reparse,
&[],
&IgnoreRules::empty(),
freshness,
now,
);
assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
assert!(
matches!(
plan.items[0].outcome,
Outcome::Skipped {
reason: SkipReason::WithinFreshnessCooldown,
..
}
),
"got: {:?}",
plan.items[0].outcome
);
assert!(plan.items[0].cooldown_fallback.is_none());
}
#[test]
fn test_plan_updates_real_semver_formatter_rejects_permissive_range_1564() {
let content = "pkg = \"0.22.6\"\n";
let (analysis, freshness, now) =
real_semver_scenario("0.22.6", &["0.22.8", "0.22.7", "0.22.6"], "0.22.7");
let reparse = reparse_quoted_deps(vec![("pkg", Position::new(0, 7))]);
let plan = plan_updates(
&analysis,
content,
&RealSemverFormatter,
&reparse,
&[],
&IgnoreRules::empty(),
freshness,
now,
);
assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
assert!(
matches!(
plan.items[0].outcome,
Outcome::Skipped {
reason: SkipReason::WithinFreshnessCooldown,
..
}
),
"got: {:?}",
plan.items[0].outcome
);
assert!(plan.items[0].cooldown_fallback.is_none());
}
#[test]
fn test_plan_updates_fails_closed_on_unlisted_pin_downgrade_1561() {
let content = "pkg = \"=1.5.0\"\n";
let (analysis, freshness, now) =
real_semver_scenario("=1.5.0", &["2.0.0", "1.4.0", "1.0.0"], "1.4.0");
let plan = plan_updates(
&analysis,
content,
&RealSemverFormatter,
&never_reparse,
&[],
&IgnoreRules::empty(),
freshness,
now,
);
assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
assert!(
matches!(
plan.items[0].outcome,
Outcome::Skipped {
reason: SkipReason::WithinFreshnessCooldown,
..
}
),
"1.4.0 is a downgrade below the unlisted declared pin 1.5.0 and must never be \
applied: {:?}",
plan.items[0]
);
assert_eq!(
plan.items[0].target(),
Some(&ConcreteVersion::from("2.0.0")),
"the rejected fallback must never leak into the reported target either"
);
assert!(plan.items[0].cooldown_fallback.is_none());
}
#[test]
fn test_plan_updates_flagged_latest_with_verified_fallback_applies_fallback() {
use deps_core::osv::{Capped, LatestStatusMap, UpgradeStatus, VulnSeverity};
let content = "pkg = \"=1.0.0\"\n";
let (mut analysis, freshness, now) = fallback_scenario_analysis("1.1.0");
let mut latest_status = LatestStatusMap::new();
latest_status.insert(
deps_core::test_util::vuln_key("pkg"),
UpgradeStatus::CandidateVulnerable {
version: ConcreteVersion::new("2.0.0"),
advisory_ids: Capped::new(vec!["GHSA-xxxx".to_string()], 1),
worst_severity: Some(VulnSeverity::High),
},
);
analysis.latest_status = Some(latest_status);
let mut fallback_status = LatestStatusMap::new();
fallback_status.insert(
deps_core::test_util::vuln_key("pkg"),
UpgradeStatus::CandidateClean {
version: ConcreteVersion::new("1.1.0"),
},
);
analysis.fallback_status = Some(fallback_status);
let reparse = reparse_quoted_deps(vec![("pkg", Position::new(0, 7))]);
let plan = plan_updates(
&analysis,
content,
&RealSemverFormatter,
&reparse,
&[],
&IgnoreRules::empty(),
freshness,
now,
);
assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
assert!(
matches!(plan.items[0].outcome, Outcome::Applied { .. }),
"got: {:?}",
plan.items[0].outcome
);
assert_eq!(
plan.items[0].target(),
Some(&ConcreteVersion::from("1.1.0"))
);
assert!(
!plan.items[0].advisory_ids.is_empty(),
"the flagged-latest attribution must be retained: {:?}",
plan.items[0]
);
assert_eq!(
plan.items[0].cooldown_fallback,
Some(CooldownFallbackNote::AppliedInsteadOf("2.0.0".into()))
);
}
#[test]
fn test_plan_updates_latest_status_without_fallback_status_treats_fallback_as_unverified() {
use deps_core::osv::{LatestStatusMap, UpgradeStatus};
let content = "pkg = \"1.0.0\"\n";
let (mut analysis, freshness, now) = fallback_scenario_analysis("1.1.0");
let mut latest_status = LatestStatusMap::new();
latest_status.insert(
deps_core::test_util::vuln_key("pkg"),
UpgradeStatus::CandidateClean {
version: ConcreteVersion::new("1.2.0"),
},
);
analysis.latest_status = Some(latest_status);
let plan = plan_updates(
&analysis,
content,
&FALLBACK_FORMATTER,
&never_reparse,
&[],
&IgnoreRules::empty(),
freshness,
now,
);
assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
assert!(
matches!(
plan.items[0].outcome,
Outcome::Skipped {
reason: SkipReason::NotSafelyEditable(
deps_core::edit::UnplannableReason::LatestUnverified
),
..
}
),
"got: {:?}",
plan.items[0].outcome
);
}
#[test]
fn test_plan_updates_fb_target_fallback_version_divergence_fails_closed() {
let content = "pkg = \"=1.0.0\"\n";
let (mut analysis, freshness, now) = fallback_scenario_analysis("1.1.0");
let mut divergent_view = HashMap::new();
divergent_view.insert(
PackageName::new("pkg"),
PackageVersions::latest_only("1.9.0"),
);
analysis.cooldown_fallback_view = Some(divergent_view);
let plan = plan_updates(
&analysis,
content,
&FALLBACK_FORMATTER,
&never_reparse,
&[],
&IgnoreRules::empty(),
freshness,
now,
);
assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
assert!(
matches!(
plan.items[0].outcome,
Outcome::Skipped {
reason: SkipReason::WithinFreshnessCooldown,
..
}
),
"a divergence between the fallback view's own planned target (1.9.0) and \
`cooldown_disposition`'s independently computed pick (1.1.0) must never be \
silently written: {:?}",
plan.items[0]
);
assert_eq!(
plan.items[0].target(),
Some(&ConcreteVersion::from("2.0.0")),
"must fall back to the real (unmodified) latest, never either divergent fallback \
value"
);
assert!(plan.items[0].cooldown_fallback.is_none());
}
#[test]
fn test_plan_updates_flagged_fallback_blocks_and_exits_nonzero() {
use deps_core::osv::{Capped, LatestStatusMap, UpgradeStatus, VulnSeverity};
let content = "pkg = \"=1.0.0\"\n";
let (mut analysis, freshness, now) = fallback_scenario_analysis("1.1.0");
let mut fallback_status = LatestStatusMap::new();
fallback_status.insert(
deps_core::test_util::vuln_key("pkg"),
UpgradeStatus::CandidateVulnerable {
version: ConcreteVersion::new("1.1.0"),
advisory_ids: Capped::new(vec!["GHSA-yyyy".to_string()], 1),
worst_severity: Some(VulnSeverity::High),
},
);
analysis.fallback_status = Some(fallback_status);
let plan = plan_updates(
&analysis,
content,
&FALLBACK_FORMATTER,
&never_reparse,
&[],
&IgnoreRules::empty(),
freshness,
now,
);
assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
assert!(
matches!(
plan.items[0].outcome,
Outcome::Skipped {
reason: SkipReason::NotSafelyEditable(
deps_core::edit::UnplannableReason::LatestFlaggedByOsv
),
..
}
),
"got: {:?}",
plan.items[0].outcome
);
assert_eq!(
plan.items[0].target(),
Some(&ConcreteVersion::from("1.1.0")),
"must name the blocked fallback version, not latest"
);
assert!(!plan.items[0].advisory_ids.is_empty());
assert_eq!(
plan.items[0].cooldown_fallback,
Some(CooldownFallbackNote::Blocked {
version: "1.1.0".into()
})
);
assert_eq!(
crate::exit::update_exit_code(&plan),
crate::exit::EXIT_POLICY_VIOLATION
);
}
#[test]
fn test_plan_updates_unverified_fallback_blocks_and_exits_nonzero() {
use deps_core::osv::LatestStatusMap;
let content = "pkg = \"=1.0.0\"\n";
let (mut analysis, freshness, now) = fallback_scenario_analysis("1.1.0");
analysis.fallback_status = Some(LatestStatusMap::new());
let plan = plan_updates(
&analysis,
content,
&FALLBACK_FORMATTER,
&never_reparse,
&[],
&IgnoreRules::empty(),
freshness,
now,
);
assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
assert!(
matches!(
plan.items[0].outcome,
Outcome::Skipped {
reason: SkipReason::NotSafelyEditable(
deps_core::edit::UnplannableReason::LatestUnverified
),
..
}
),
"got: {:?}",
plan.items[0].outcome
);
assert_eq!(
plan.items[0].target(),
Some(&ConcreteVersion::from("1.1.0"))
);
assert_eq!(
crate::exit::update_exit_code(&plan),
crate::exit::EXIT_POLICY_VIOLATION
);
}
#[test]
fn test_plan_updates_both_latest_and_fallback_osv_blocked_defers_to_latest() {
use deps_core::osv::{Capped, LatestStatusMap, UpgradeStatus, VulnSeverity};
let content = "pkg = \"=1.0.0\"\n";
let (mut analysis, freshness, now) = fallback_scenario_analysis("1.1.0");
let flagged = |version: &str| {
let mut status = LatestStatusMap::new();
status.insert(
deps_core::test_util::vuln_key("pkg"),
UpgradeStatus::CandidateVulnerable {
version: deps_core::ConcreteVersion::new(version),
advisory_ids: Capped::new(vec!["GHSA-x".to_string()], 1),
worst_severity: Some(VulnSeverity::High),
},
);
status
};
analysis.latest_status = Some(flagged("2.0.0"));
analysis.fallback_status = Some(flagged("1.1.0"));
let plan = plan_updates(
&analysis,
content,
&FALLBACK_FORMATTER,
&never_reparse,
&[],
&IgnoreRules::empty(),
freshness,
now,
);
assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
assert!(
matches!(
plan.items[0].outcome,
Outcome::Skipped {
reason: SkipReason::NotSafelyEditable(
deps_core::edit::UnplannableReason::LatestFlaggedByOsv
),
..
}
),
"got: {:?}",
plan.items[0].outcome
);
assert!(
plan.items[0].cooldown_fallback.is_none(),
"row 8 defers entirely to latest's own attribution, no fallback note"
);
assert_eq!(
crate::exit::update_exit_code(&plan),
crate::exit::EXIT_POLICY_VIOLATION
);
}
#[test]
fn test_plan_updates_fallback_blocked_by_non_osv_reason_is_cooldown_skip() {
let content = "pkg = \"=1.0.0\"\n";
let (analysis, freshness, now) = fallback_scenario_analysis("1.1.0 unsafe");
let plan = plan_updates(
&analysis,
content,
&FALLBACK_FORMATTER,
&never_reparse,
&[],
&IgnoreRules::empty(),
freshness,
now,
);
assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
assert!(
matches!(
plan.items[0].outcome,
Outcome::Skipped {
reason: SkipReason::WithinFreshnessCooldown,
..
}
),
"a fallback blocked by a non-OSV structural reason is a routine cooldown skip, not \
an exit-1 safety refusal: {:?}",
plan.items[0].outcome
);
assert_eq!(
crate::exit::update_exit_code(&plan),
crate::exit::EXIT_CLEAN
);
}
#[tokio::test]
async fn test_plan_updates_row7_honors_ignore_rule_instead_of_applying() {
use deps_core::osv::{Capped, LatestStatusMap, UpgradeStatus, VulnSeverity};
let content = "pkg = \"=1.0.0\"\n";
let (mut analysis, freshness, now) = fallback_scenario_analysis("1.1.0");
let mut latest_status = LatestStatusMap::new();
latest_status.insert(
deps_core::test_util::vuln_key("pkg"),
UpgradeStatus::CandidateVulnerable {
version: ConcreteVersion::new("2.0.0"),
advisory_ids: Capped::new(vec!["GHSA-x".to_string()], 1),
worst_severity: Some(VulnSeverity::High),
},
);
analysis.latest_status = Some(latest_status);
let ignore_pkg = IgnoreRules::new(
vec![crate::config::IgnoreRule {
name: "pkg".to_string(),
update_types: None,
}],
&RealSemverFormatter,
);
let reparse = reparse_quoted_deps(vec![("pkg", Position::new(0, 7))]);
let plan = plan_updates(
&analysis,
content,
&RealSemverFormatter,
&reparse,
&[],
&ignore_pkg,
freshness,
now,
);
assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
assert!(
matches!(
plan.items[0].outcome,
Outcome::Skipped {
reason: SkipReason::IgnoreRule,
..
}
),
"an ignored package's row-7 fallback must not be applied: {:?}",
plan.items[0].outcome
);
assert_eq!(
crate::exit::update_exit_code(&plan),
crate::exit::EXIT_CLEAN
);
}
#[tokio::test]
async fn test_plan_updates_row9_honors_ignore_rule_instead_of_applying() {
let content = "pkg = \"=1.0.0\"\n";
let (analysis, freshness, now) = fallback_scenario_analysis("1.1.0");
let ignore_pkg = IgnoreRules::new(
vec![crate::config::IgnoreRule {
name: "pkg".to_string(),
update_types: None,
}],
&RealSemverFormatter,
);
let reparse = reparse_quoted_deps(vec![("pkg", Position::new(0, 7))]);
let plan = plan_updates(
&analysis,
content,
&RealSemverFormatter,
&reparse,
&[],
&ignore_pkg,
freshness,
now,
);
assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
assert!(
matches!(
plan.items[0].outcome,
Outcome::Skipped {
reason: SkipReason::IgnoreRule,
..
}
),
"an ignored package's row-9 fallback must not be applied: {:?}",
plan.items[0].outcome
);
assert_eq!(
crate::exit::update_exit_code(&plan),
crate::exit::EXIT_CLEAN
);
}
#[test]
fn test_plan_updates_row10_honors_ignore_rule_instead_of_exiting_nonzero() {
use deps_core::osv::{Capped, LatestStatusMap, UpgradeStatus, VulnSeverity};
let content = "pkg = \"=1.0.0\"\n";
let (mut analysis, freshness, now) = fallback_scenario_analysis("1.1.0");
let mut fallback_status = LatestStatusMap::new();
fallback_status.insert(
deps_core::test_util::vuln_key("pkg"),
UpgradeStatus::CandidateVulnerable {
version: ConcreteVersion::new("1.1.0"),
advisory_ids: Capped::new(vec!["GHSA-x".to_string()], 1),
worst_severity: Some(VulnSeverity::High),
},
);
analysis.fallback_status = Some(fallback_status);
let ignore_pkg = IgnoreRules::new(
vec![crate::config::IgnoreRule {
name: "pkg".to_string(),
update_types: None,
}],
&FALLBACK_FORMATTER,
);
let plan = plan_updates(
&analysis,
content,
&FALLBACK_FORMATTER,
&never_reparse,
&[],
&ignore_pkg,
freshness,
now,
);
assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
assert!(
matches!(
plan.items[0].outcome,
Outcome::Skipped {
reason: SkipReason::IgnoreRule,
..
}
),
"an ignored package's row-10 blocked fallback must exit clean, not policy-violation: {:?}",
plan.items[0].outcome
);
assert_eq!(
plan.items[0].target(),
None,
"the ignore-rule skip of an OSV-blocked fallback has no concrete target"
);
assert_eq!(
crate::exit::update_exit_code(&plan),
crate::exit::EXIT_CLEAN
);
}
#[test]
fn test_plan_updates_gossip_lookup_uses_raw_name_not_normalized() {
let lowercase_formatter: deps_core::test_util::StubFormatter =
deps_core::test_util::StubFormatter::new().with_lowercase_names();
let content = "Django = \"1.0.0\"\n";
let now = deps_core::PublishTime::from_unix_secs(10_000);
let old_published_at = deps_core::PublishTime::from_unix_secs(10_000 - 30 * 24 * 60 * 60);
let mut versions = HashMap::new();
versions.insert(
PackageName::new("Django"),
PackageVersions::latest_only("2.0.0").with_published_at(old_published_at),
);
let mut analysis = test_analysis(
vec![test_dep(
"Django",
"1.0.0",
Range::new(Position::new(0, 10), Position::new(0, 15)),
)],
versions,
);
let mut gossip = HashMap::new();
gossip.insert(
PackageName::new("Django"), deps_core::test_util::stub_gossip_findings(
"2.0.0",
Some(deps_core::GossipCooldown::new(
deps_core::PublishTime::from_unix_secs(10_000 + 1_000),
deps_core::GossipRiskLevel::High,
)),
),
);
analysis.gossip_findings = gossip;
let plan = plan_updates(
&analysis,
content,
&lowercase_formatter,
&never_reparse,
&[],
&IgnoreRules::empty(),
deps_core::FreshnessSettings::Enabled {
cooldown: deps_core::CooldownWindow::from_secs(1_000),
},
now,
);
assert_eq!(plan.items.len(), 1, "{:?}", plan.items);
assert!(
matches!(
plan.items[0].outcome,
Outcome::Skipped {
reason: SkipReason::WithinFreshnessCooldown,
..
}
),
"the GOSSIP-Active cooldown for the raw name 'Django' must be detected even though \
normalize_package_name lowercases it to 'django': {:?}",
plan.items[0].outcome
);
}
#[test]
fn test_plan_updates_name_range_collision_does_not_swap_occurrences() {
let content = "alpha = \"=1.0.0\"\nbeta = \"=9.0.0\"\n";
let now = deps_core::PublishTime::from_unix_secs(10_000);
let published_at = deps_core::PublishTime::from_unix_secs(9_900);
let mut versions = HashMap::new();
versions.insert(
PackageName::new("alpha"),
PackageVersions::new(
deps_core::ConcreteVersion::new("2.0.0"),
std::sync::Arc::from(vec![
deps_core::ConcreteVersion::new("2.0.0"),
deps_core::ConcreteVersion::new("1.1.0"),
deps_core::ConcreteVersion::new("1.0.0"),
]),
)
.with_published_at(published_at)
.with_cooldown_fallback(deps_core::lsp_helpers::CooldownFallback::new(
"1.1.0".into(),
deps_core::PublishTime::from_unix_secs(1_000),
)),
);
versions.insert(
PackageName::new("beta"),
PackageVersions::new(
deps_core::ConcreteVersion::new("10.0.0"),
std::sync::Arc::from(vec![
deps_core::ConcreteVersion::new("10.0.0"),
deps_core::ConcreteVersion::new("9.1.0"),
deps_core::ConcreteVersion::new("9.0.0"),
]),
)
.with_published_at(published_at)
.with_cooldown_fallback(deps_core::lsp_helpers::CooldownFallback::new(
"9.1.0".into(),
deps_core::PublishTime::from_unix_secs(1_000),
)),
);
let analysis = test_analysis(
vec![
test_dep(
"alpha",
"=1.0.0",
Range::new(Position::new(0, 9), Position::new(0, 15)),
),
test_dep(
"beta",
"=9.0.0",
Range::new(Position::new(1, 8), Position::new(1, 14)),
),
],
versions,
);
let reparse = reparse_quoted_deps(vec![
("alpha", Position::new(0, 9)),
("beta", Position::new(1, 8)),
]);
let plan = plan_updates(
&analysis,
content,
&RealSemverFormatter,
&reparse,
&[],
&IgnoreRules::empty(),
deps_core::FreshnessSettings::Enabled {
cooldown: deps_core::CooldownWindow::from_secs(1_000),
},
now,
);
assert_eq!(plan.items.len(), 2, "{:?}", plan.items);
let alpha = plan
.items
.iter()
.find(|i| i.name == "alpha")
.expect("alpha item present");
let beta = plan
.items
.iter()
.find(|i| i.name == "beta")
.expect("beta item present");
assert_eq!(
alpha.target(),
Some(&ConcreteVersion::from("1.1.0")),
"alpha must get its own fallback, not beta's: {alpha:?}"
);
assert_eq!(
beta.target(),
Some(&ConcreteVersion::from("9.1.0")),
"beta must get its own fallback, not alpha's: {beta:?}"
);
assert!(matches!(alpha.outcome, Outcome::Applied { .. }));
assert!(matches!(beta.outcome, Outcome::Applied { .. }));
}
#[test]
fn test_plan_updates_true_name_range_collision_fails_closed_on_both_occurrences() {
let content = "pkg = \"1.0.0\"\npkg = \"5.0.0\"\n";
let now = deps_core::PublishTime::from_unix_secs(10_000);
let published_at = deps_core::PublishTime::from_unix_secs(9_900);
let mut versions = HashMap::new();
versions.insert(
PackageName::new("pkg"),
PackageVersions::latest_only("1.2.0")
.with_published_at(published_at)
.with_cooldown_fallback(deps_core::lsp_helpers::CooldownFallback::new(
"1.1.0".into(),
deps_core::PublishTime::from_unix_secs(1_000),
)),
);
let analysis = test_analysis(
vec![
test_dep(
"pkg",
"1.0.0",
Range::new(Position::new(0, 7), Position::new(0, 12)),
),
test_dep(
"pkg",
"5.0.0",
Range::new(Position::new(1, 7), Position::new(1, 12)),
),
],
versions,
);
let plan = plan_updates(
&analysis,
content,
&FALLBACK_FORMATTER,
&never_reparse,
&[],
&IgnoreRules::empty(),
deps_core::FreshnessSettings::Enabled {
cooldown: deps_core::CooldownWindow::from_secs(1_000),
},
now,
);
assert_eq!(plan.items.len(), 2, "{:?}", plan.items);
for item in &plan.items {
assert!(
matches!(
item.outcome,
Outcome::Skipped {
reason: SkipReason::WithinFreshnessCooldown,
..
}
),
"an unresolvable collision must fail closed, never approve a downgrade or a \
swapped write: {item:?}"
);
assert_eq!(
item.target(),
Some(&ConcreteVersion::from("1.2.0")),
"target must stay the real (unmodified) latest, never a fallback picked via a \
collided lookup: {item:?}"
);
}
}
#[test]
fn test_plan_updates_mixed_planned_unplannable_collision_fails_closed_uniformly() {
let content = "pkg = \"1.0.0\"\npkg = \"1.0.0\"\n";
let now = deps_core::PublishTime::from_unix_secs(10_000);
let published_at = deps_core::PublishTime::from_unix_secs(9_900);
let mut versions = HashMap::new();
versions.insert(
PackageName::new("pkg"),
PackageVersions::latest_only("1.2.0")
.with_published_at(published_at)
.with_cooldown_fallback(deps_core::lsp_helpers::CooldownFallback::new(
"1.1.0".into(),
deps_core::PublishTime::from_unix_secs(1_000),
)),
);
let analysis = test_analysis(
vec![
test_dep(
"pkg",
"1.0.0",
Range::new(Position::new(0, 7), Position::new(0, 12)),
),
test_dep(
"pkg",
"9.9.9",
Range::new(Position::new(1, 7), Position::new(1, 12)),
),
],
versions,
);
let plan = plan_updates(
&analysis,
content,
&FALLBACK_FORMATTER,
&never_reparse,
&[],
&IgnoreRules::empty(),
deps_core::FreshnessSettings::Enabled {
cooldown: deps_core::CooldownWindow::from_secs(1_000),
},
now,
);
assert_eq!(plan.items.len(), 2, "{:?}", plan.items);
for item in &plan.items {
assert!(
matches!(
item.outcome,
Outcome::Skipped {
reason: SkipReason::WithinFreshnessCooldown,
..
}
),
"a collision must fail closed uniformly for both occurrences, never `Applied` \
and never attributing one occurrence's structural defect to the other: {item:?}"
);
}
assert_eq!(
crate::exit::update_exit_code(&plan),
crate::exit::EXIT_CLEAN
);
}
}