diff --git a/src/bin/cargo/commands/clean.rs b/src/bin/cargo/commands/clean.rs index e9ee35fdf20..0bde5a343a7 100644 --- a/src/bin/cargo/commands/clean.rs +++ b/src/bin/cargo/commands/clean.rs @@ -1,7 +1,10 @@ use crate::command_prelude::*; use crate::util::cache_lock::CacheLockMode; +use crate::util::time_span::parse_time_span; + use cargo::core::gc::Gc; -use cargo::core::gc::{GcOpts, parse_human_size, parse_time_span}; +use cargo::core::gc::GcOpts; +use cargo::core::gc::parse_human_size; use cargo::core::global_cache_tracker::GlobalCacheTracker; use cargo::ops::CleanContext; use cargo::ops::{self, CleanOptions}; diff --git a/src/cargo/core/compiler/future_incompat.rs b/src/cargo/core/compiler/future_incompat.rs index 0df342b4b6c..d8c70ae61d2 100644 --- a/src/cargo/core/compiler/future_incompat.rs +++ b/src/cargo/core/compiler/future_incompat.rs @@ -319,7 +319,7 @@ fn get_updates(ws: &Workspace<'_>, package_ids: &BTreeSet) -> Option< let sources: HashMap<_, _> = source_ids .into_iter() .filter_map(|sid| { - let source = map.load(sid, &HashSet::new()).ok()?; + let source = map.load(sid).ok()?; Some((sid, source)) }) .collect(); diff --git a/src/cargo/core/features.rs b/src/cargo/core/features.rs index f7c8a75bea4..af8fbb8b2cf 100644 --- a/src/cargo/core/features.rs +++ b/src/cargo/core/features.rs @@ -887,6 +887,7 @@ unstable_cli_options!( gitoxide: Option = ("Use gitoxide for the given git interactions, or all of them if no argument is given"), host_config: bool = ("Enable the `[host]` section in the .cargo/config.toml file"), json_target_spec: bool = ("Enable `.json` target spec files"), + min_publish_age: bool = ("Enable the `min-publish-age` configuration for dependency version age filtering"), minimal_versions: bool = ("Resolve minimal dependency versions instead of maximum"), msrv_policy: bool = ("Enable rust-version aware policy within cargo"), mtime_on_use: bool = ("Configure Cargo to update the mtime of used files"), @@ -1431,6 +1432,7 @@ impl CliUnstable { } "host-config" => self.host_config = parse_empty(k, v)?, "json-target-spec" => self.json_target_spec = parse_empty(k, v)?, + "min-publish-age" => self.min_publish_age = parse_empty(k, v)?, "next-lockfile-bump" => self.next_lockfile_bump = parse_empty(k, v)?, "minimal-versions" => self.minimal_versions = parse_empty(k, v)?, "msrv-policy" => self.msrv_policy = parse_empty(k, v)?, diff --git a/src/cargo/core/gc.rs b/src/cargo/core/gc.rs index 324cede649b..29a3fe7a687 100644 --- a/src/cargo/core/gc.rs +++ b/src/cargo/core/gc.rs @@ -22,6 +22,7 @@ use crate::core::global_cache_tracker::{self, GlobalCacheTracker}; use crate::ops::CleanContext; use crate::util::cache_lock::{CacheLock, CacheLockMode}; +use crate::util::time_span::maybe_parse_time_span; use crate::{CargoResult, GlobalContext}; use anyhow::{Context as _, format_err}; use serde::Deserialize; @@ -372,41 +373,6 @@ fn parse_time_span_for_config(config_name: &str, span: &str) -> CargoResult Option { - let Some(right_i) = span.find(|c: char| !c.is_ascii_digit()) else { - return None; - }; - let (left, mut right) = span.split_at(right_i); - if right.starts_with(' ') { - right = &right[1..]; - } - let count: u64 = left.parse().ok()?; - let factor = match right { - "second" | "seconds" => 1, - "minute" | "minutes" => 60, - "hour" | "hours" => 60 * 60, - "day" | "days" => 24 * 60 * 60, - "week" | "weeks" => 7 * 24 * 60 * 60, - "month" | "months" => 2_629_746, // average is 30.436875 days - _ => return None, - }; - Some(Duration::from_secs(factor * count)) -} - -/// Parses a time span string. -pub fn parse_time_span(span: &str) -> CargoResult { - maybe_parse_time_span(span).ok_or_else(|| { - format_err!( - "expected a value of the form \ - \"N seconds/minutes/days/weeks/months\", got: {span:?}" - ) - }) -} - /// Parses a file size using metric or IEC units. pub fn parse_human_size(input: &str) -> CargoResult { let re = regex::Regex::new(r"(?i)^([0-9]+(\.[0-9])?) ?(b|kb|mb|gb|kib|mib|gib)?$").unwrap(); @@ -445,15 +411,6 @@ mod tests { #[test] fn time_spans() { let d = |x| Some(Duration::from_secs(x)); - assert_eq!(maybe_parse_time_span("0 seconds"), d(0)); - assert_eq!(maybe_parse_time_span("1second"), d(1)); - assert_eq!(maybe_parse_time_span("23 seconds"), d(23)); - assert_eq!(maybe_parse_time_span("5 minutes"), d(60 * 5)); - assert_eq!(maybe_parse_time_span("2 hours"), d(60 * 60 * 2)); - assert_eq!(maybe_parse_time_span("1 day"), d(60 * 60 * 24)); - assert_eq!(maybe_parse_time_span("2 weeks"), d(60 * 60 * 24 * 14)); - assert_eq!(maybe_parse_time_span("6 months"), d(2_629_746 * 6)); - assert_eq!(parse_frequency("5 seconds").unwrap(), d(5)); assert_eq!(parse_frequency("always").unwrap(), d(0)); assert_eq!(parse_frequency("never").unwrap(), None); @@ -461,20 +418,6 @@ mod tests { #[test] fn time_span_errors() { - assert_eq!(maybe_parse_time_span(""), None); - assert_eq!(maybe_parse_time_span("1"), None); - assert_eq!(maybe_parse_time_span("second"), None); - assert_eq!(maybe_parse_time_span("+2 seconds"), None); - assert_eq!(maybe_parse_time_span("day"), None); - assert_eq!(maybe_parse_time_span("-1 days"), None); - assert_eq!(maybe_parse_time_span("1.5 days"), None); - assert_eq!(maybe_parse_time_span("1 dayz"), None); - assert_eq!(maybe_parse_time_span("always"), None); - assert_eq!(maybe_parse_time_span("never"), None); - assert_eq!(maybe_parse_time_span("1 day "), None); - assert_eq!(maybe_parse_time_span(" 1 day"), None); - assert_eq!(maybe_parse_time_span("1 second"), None); - let e = parse_time_span_for_config("cache.global-clean.max-src-age", "-1 days").unwrap_err(); assert_eq!( diff --git a/src/cargo/core/registry.rs b/src/cargo/core/registry.rs index 055b3b63ff3..53836c81662 100644 --- a/src/cargo/core/registry.rs +++ b/src/cargo/core/registry.rs @@ -109,6 +109,8 @@ pub struct PackageRegistry<'gctx> { locked: LockedMap, /// Packages allowed to be used, even if they are yanked. yanked_whitelist: RefCell>, + /// Packages allowed to be used, even if they are pubtime-incompatible. + pubtime_allowlist: RefCell>, source_config: SourceConfigMap<'gctx>, /// Patches registered during calls to [`PackageRegistry::patch`]. @@ -204,6 +206,7 @@ impl<'gctx> PackageRegistry<'gctx> { source_config, locked: HashMap::new(), yanked_whitelist: RefCell::new(HashSet::new()), + pubtime_allowlist: RefCell::new(HashSet::new()), patches: HashMap::new(), patches_locked: false, patches_available: HashMap::new(), @@ -293,6 +296,15 @@ impl<'gctx> PackageRegistry<'gctx> { self.yanked_whitelist.borrow_mut().extend(pkgs); } + /// Allows a group of packages to be available to query even if they are pubtime-incompatible + pub fn add_to_pubtime_allowlist(&self, iter: impl Iterator) { + let pkgs = iter.collect::>(); + for (_, source) in self.sources.borrow().iter() { + source.add_to_pubtime_allowlist(&pkgs); + } + self.pubtime_allowlist.borrow_mut().extend(pkgs); + } + /// remove all residual state from previous lock files. pub fn clear_lock(&mut self) { trace!("clear_lock"); @@ -533,10 +545,22 @@ impl<'gctx> PackageRegistry<'gctx> { debug!("loading source {}", source_id); let source = self .source_config - .load(source_id, &self.yanked_whitelist.borrow()) + .load(source_id) .with_context(|| format!("unable to update {}", source_id))?; assert_eq!(source.source_id(), source_id); + let yanked_whitelist = self.yanked_whitelist.borrow(); + if !yanked_whitelist.is_empty() { + let pkgs: Vec<_> = yanked_whitelist.iter().copied().collect(); + source.add_to_yanked_whitelist(&pkgs); + } + + let pubtime_allowlist = self.pubtime_allowlist.borrow(); + if !pubtime_allowlist.is_empty() { + let pkgs: Vec<_> = pubtime_allowlist.iter().copied().collect(); + source.add_to_pubtime_allowlist(&pkgs); + } + if kind == Kind::Override { self.overrides.borrow_mut().push(source_id); } diff --git a/src/cargo/core/resolver/errors.rs b/src/cargo/core/resolver/errors.rs index cab65502f38..e6141b91b4c 100644 --- a/src/cargo/core/resolver/errors.rs +++ b/src/cargo/core/resolver/errors.rs @@ -291,6 +291,26 @@ pub(super) fn activation_error( summary.version() ); } + IndexSummary::TooNew(summary, age) => { + let opts = jiff::SpanRound::new() + .largest(jiff::Unit::Day) + .smallest(jiff::Unit::Minute) + .relative(jiff::SpanRelativeTo::days_are_24_hours()); + match age.round(opts) { + Ok(age) => { + let _ = writeln!( + &mut msg, + " version {} is too new (published {age:#} ago)", + summary.version(), + ); + } + Err(e) => { + tracing::warn!("failed to round `{age}`: {e}"); + let _ = + writeln!(&mut msg, " version {} is too new", summary.version()); + } + } + } } } } else if let Some(candidates) = alt_versions(registry, dep) { diff --git a/src/cargo/core/source_id.rs b/src/cargo/core/source_id.rs index dade72b5a4b..0d27e3b5441 100644 --- a/src/cargo/core/source_id.rs +++ b/src/cargo/core/source_id.rs @@ -1,5 +1,4 @@ use crate::core::GitReference; -use crate::core::PackageId; use crate::core::SourceKind; use crate::sources::registry::CRATES_IO_HTTP_INDEX; use crate::sources::source::Source; @@ -389,12 +388,9 @@ impl SourceId { /// Creates an implementation of `Source` corresponding to this ID. /// - /// * `yanked_whitelist` --- Packages allowed to be used, even if they are yanked. - pub fn load<'a>( - self, - gctx: &'a GlobalContext, - yanked_whitelist: &HashSet, - ) -> CargoResult> { + /// To allow yanked packages through queries, + /// call [`Source::add_to_yanked_whitelist`] on the returned source. + pub fn load<'a>(self, gctx: &'a GlobalContext) -> CargoResult> { trace!("loading SourceId; {}", self); match self.inner.kind { SourceKind::Git(..) => Ok(Box::new(GitSource::new(self, gctx)?)), @@ -409,21 +405,16 @@ impl SourceId { } Ok(Box::new(PathSource::new(&path, self, gctx))) } - SourceKind::Registry | SourceKind::SparseRegistry => Ok(Box::new( - RegistrySource::remote(self, yanked_whitelist, gctx)?, - )), + SourceKind::Registry | SourceKind::SparseRegistry => { + Ok(Box::new(RegistrySource::remote(self, gctx)?)) + } SourceKind::LocalRegistry => { let path = self .inner .url .to_file_path() .expect("path sources cannot be remote"); - Ok(Box::new(RegistrySource::local( - self, - &path, - yanked_whitelist, - gctx, - ))) + Ok(Box::new(RegistrySource::local(self, &path, gctx))) } SourceKind::Directory => { let path = self diff --git a/src/cargo/core/workspace.rs b/src/cargo/core/workspace.rs index 89627d64bcd..5d402a1c2c2 100644 --- a/src/cargo/core/workspace.rs +++ b/src/cargo/core/workspace.rs @@ -351,6 +351,15 @@ impl<'gctx> Workspace<'gctx> { .warn("ignoring `resolver.feature-unification` without `-Zfeature-unification`")?; }; + if !self.gctx().cli_unstable().min_publish_age { + if config.incompatible_publish_age.is_some() { + self.gctx().shell().warn( + "ignoring `resolver.incompatible-publish-age` without `-Zmin-publish-age`", + )?; + } + warn_unused_min_publish_age(self.gctx())?; + } + if let Some(lockfile_path) = config.lockfile_path { // Reserve the ability to add templates in the future. let replacements: [(&str, &str); 0] = []; @@ -2068,6 +2077,38 @@ impl WorkspaceRootConfig { } } +fn warn_unused_min_publish_age(gctx: &GlobalContext) -> CargoResult<()> { + if gctx + .get::>("registry.global-min-publish-age")? + .is_some() + { + gctx.shell() + .warn("ignoring `registry.global-min-publish-age` without `-Zmin-publish-age`")?; + } + + if gctx + .get::>("registry.min-publish-age")? + .is_some() + { + gctx.shell() + .warn("ignoring `registry.min-publish-age` without `-Zmin-publish-age`")?; + } + + if let Some(context::ConfigValue::Table(registries, _)) = gctx.values()?.get("registries") { + for (name, val) in registries { + if let context::ConfigValue::Table(val, _) = val { + if val.contains_key("min-publish-age") { + gctx.shell().warn(format!( + "ignoring `registries.{name}.min-publish-age` without `-Zmin-publish-age`" + ))?; + } + } + } + } + + Ok(()) +} + pub fn resolve_relative_path( label: &str, old_root: &Path, diff --git a/src/cargo/ops/cargo_install.rs b/src/cargo/ops/cargo_install.rs index 14c22a2201c..0cdba3aa558 100644 --- a/src/cargo/ops/cargo_install.rs +++ b/src/cargo/ops/cargo_install.rs @@ -1,4 +1,6 @@ -use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::collections::BTreeMap; +use std::collections::BTreeSet; +use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::{env, fmt, fs}; @@ -184,7 +186,7 @@ impl<'gctx> InstallablePackage<'gctx> { current_rust_version, )? } else if let Some(dep) = dep { - let mut source = map.load(source_id, &HashSet::new())?; + let mut source = map.load(source_id)?; if let Ok(Some(pkg)) = installed_exact_package( dep.clone(), &mut *source, diff --git a/src/cargo/ops/registry/mod.rs b/src/cargo/ops/registry/mod.rs index 62320da2527..cc63efb2df7 100644 --- a/src/cargo/ops/registry/mod.rs +++ b/src/cargo/ops/registry/mod.rs @@ -135,7 +135,7 @@ fn registry<'gctx>( auth::cache_token_from_commandline(gctx, &source_ids.original, token); } - let src = RegistrySource::remote(source_ids.replacement, &HashSet::new(), gctx)?; + let src = RegistrySource::remote(source_ids.replacement, gctx)?; let cfg = { let _lock = gctx.acquire_package_cache_lock(CacheLockMode::DownloadExclusive)?; // Only update the index if `force_update` is set. @@ -265,11 +265,9 @@ fn get_replacement_source_ids( sid: SourceId, ) -> CargoResult<(SourceId, SourceId)> { let builtin_replacement_sid = SourceConfigMap::empty(gctx)? - .load(sid, &HashSet::new())? - .replaced_source_id(); - let replacement_sid = SourceConfigMap::new(gctx)? - .load(sid, &HashSet::new())? + .load(sid)? .replaced_source_id(); + let replacement_sid = SourceConfigMap::new(gctx)?.load(sid)?.replaced_source_id(); Ok((builtin_replacement_sid, replacement_sid)) } @@ -279,7 +277,7 @@ fn is_replacement_for_package_source( package_source_id: SourceId, ) -> CargoResult { let pkg_source_replacement_sid = SourceConfigMap::new(gctx)? - .load(package_source_id, &HashSet::new())? + .load(package_source_id)? .replaced_source_id(); Ok(pkg_source_replacement_sid == sid) } diff --git a/src/cargo/ops/registry/publish.rs b/src/cargo/ops/registry/publish.rs index 48908d02e73..599b3be5453 100644 --- a/src/cargo/ops/registry/publish.rs +++ b/src/cargo/ops/registry/publish.rs @@ -5,7 +5,6 @@ use std::collections::BTreeMap; use std::collections::BTreeSet; use std::collections::HashMap; -use std::collections::HashSet; use std::fs::File; use std::io::Seek; use std::io::SeekFrom; @@ -390,7 +389,7 @@ fn wait_for_any_publish_confirmation( pkgs: &BTreeSet, timeout: Duration, ) -> CargoResult> { - let mut source = SourceConfigMap::empty(gctx)?.load(registry_src, &HashSet::new())?; + let mut source = SourceConfigMap::empty(gctx)?.load(registry_src)?; // Disable the source's built-in progress bars. Repeatedly showing a bunch // of independent progress bars can be a little confusing. There is an // overall progress bar managed here. diff --git a/src/cargo/ops/resolve.rs b/src/cargo/ops/resolve.rs index 722cefe5713..bded6a76819 100644 --- a/src/cargo/ops/resolve.rs +++ b/src/cargo/ops/resolve.rs @@ -630,6 +630,7 @@ fn register_previous_locks( // in (as we didn't accidentally lock it to an old version). let mut avoid_locking = HashSet::new(); registry.add_to_yanked_whitelist(resolve.iter().filter(keep)); + registry.add_to_pubtime_allowlist(resolve.iter()); for node in resolve.iter() { if !keep(&node) { add_deps(resolve, node, &mut avoid_locking); diff --git a/src/cargo/ops/vendor.rs b/src/cargo/ops/vendor.rs index 87080ff0862..41f352661a5 100644 --- a/src/cargo/ops/vendor.rs +++ b/src/cargo/ops/vendor.rs @@ -110,7 +110,7 @@ impl SourceReplacementCache<'_> { match self.cache.entry(id) { Entry::Occupied(e) => Ok(e.get().clone()), Entry::Vacant(e) => { - let replaced = self.map.load(id, &HashSet::new())?.replaced_source_id(); + let replaced = self.map.load(id)?.replaced_source_id(); Ok(e.insert(replaced).clone()) } } @@ -246,11 +246,11 @@ fn sync( // we'll do a direct extraction into the vendor directory. let registry = match sid.kind() { SourceKind::Registry | SourceKind::SparseRegistry => { - RegistrySource::remote(sid, &Default::default(), gctx)? + RegistrySource::remote(sid, gctx)? } SourceKind::LocalRegistry => { let path = sid.url().to_file_path().expect("local path"); - RegistrySource::local(sid, &path, &Default::default(), gctx) + RegistrySource::local(sid, &path, gctx) } _ => unreachable!("not registry source: {sid}"), }; diff --git a/src/cargo/sources/config.rs b/src/cargo/sources/config.rs index 9dbfe56f2e4..3b77ab4d5d1 100644 --- a/src/cargo/sources/config.rs +++ b/src/cargo/sources/config.rs @@ -4,15 +4,18 @@ //! structure usable by Cargo itself. Currently, this is primarily used to map //! sources to one another via the `replace-with` key in `.cargo/config`. -use crate::core::{GitReference, PackageId, SourceId}; +use std::collections::HashMap; + +use crate::core::GitReference; +use crate::core::SourceId; use crate::sources::overlay::DependencyConfusionThreatOverlaySource; use crate::sources::source::Source; use crate::sources::{CRATES_IO_REGISTRY, ReplacedSource}; use crate::util::context::{self, ConfigRelativePath, OptValue}; use crate::util::errors::CargoResult; use crate::util::{GlobalContext, IntoUrl}; + use anyhow::{Context as _, bail}; -use std::collections::{HashMap, HashSet}; use tracing::debug; use url::Url; @@ -142,17 +145,11 @@ impl<'gctx> SourceConfigMap<'gctx> { } /// Gets the [`Source`] for a given [`SourceId`]. - /// - /// * `yanked_whitelist` --- Packages allowed to be used, even if they are yanked. - pub fn load( - &self, - id: SourceId, - yanked_whitelist: &HashSet, - ) -> CargoResult> { + pub fn load(&self, id: SourceId) -> CargoResult> { debug!("loading: {}", id); let Some(mut name) = self.id2name.get(&id) else { - return self.load_overlaid(id, yanked_whitelist); + return self.load_overlaid(id); }; let mut cfg_loc = ""; let orig_name = name; @@ -177,7 +174,7 @@ impl<'gctx> SourceConfigMap<'gctx> { name = s; cfg_loc = c; } - None if id == cfg.id => return self.load_overlaid(id, yanked_whitelist), + None if id == cfg.id => return self.load_overlaid(id), None => { break cfg.id.with_precise_from(id); } @@ -194,14 +191,8 @@ impl<'gctx> SourceConfigMap<'gctx> { } }; - let new_src = self.load_overlaid( - new_id, - &yanked_whitelist - .iter() - .map(|p| p.map_source(id, new_id)) - .collect(), - )?; - let old_src = id.load(self.gctx, yanked_whitelist)?; + let new_src = self.load_overlaid(new_id)?; + let old_src = id.load(self.gctx)?; if !new_src.supports_checksums() && old_src.supports_checksums() { bail!( "\ @@ -232,14 +223,10 @@ restore the source replacement configuration to continue the build } /// Gets the [`Source`] for a given [`SourceId`] without performing any source replacement. - fn load_overlaid( - &self, - id: SourceId, - yanked_whitelist: &HashSet, - ) -> CargoResult> { - let src = id.load(self.gctx, yanked_whitelist)?; + fn load_overlaid(&self, id: SourceId) -> CargoResult> { + let src = id.load(self.gctx)?; if let Some(overlay_id) = self.overlays.get(&id) { - let overlay = overlay_id.load(self.gctx(), yanked_whitelist)?; + let overlay = overlay_id.load(self.gctx())?; Ok(Box::new(DependencyConfusionThreatOverlaySource::new( overlay, src, ))) diff --git a/src/cargo/sources/directory.rs b/src/cargo/sources/directory.rs index 4d49d581889..92b4b915134 100644 --- a/src/cargo/sources/directory.rs +++ b/src/cargo/sources/directory.rs @@ -269,6 +269,8 @@ impl<'gctx> Source for DirectorySource<'gctx> { fn add_to_yanked_whitelist(&self, _pkgs: &[PackageId]) {} + fn add_to_pubtime_allowlist(&self, _pkgs: &[PackageId]) {} + async fn is_yanked(&self, _pkg: PackageId) -> CargoResult { Ok(false) } diff --git a/src/cargo/sources/git/source.rs b/src/cargo/sources/git/source.rs index 1db73a1b9b3..65d335ad935 100644 --- a/src/cargo/sources/git/source.rs +++ b/src/cargo/sources/git/source.rs @@ -444,6 +444,8 @@ impl<'gctx> Source for GitSource<'gctx> { fn add_to_yanked_whitelist(&self, _pkgs: &[PackageId]) {} + fn add_to_pubtime_allowlist(&self, _pkgs: &[PackageId]) {} + async fn is_yanked(&self, _pkg: PackageId) -> CargoResult { Ok(false) } diff --git a/src/cargo/sources/overlay.rs b/src/cargo/sources/overlay.rs index 8df6ad7dd5d..9346680b098 100644 --- a/src/cargo/sources/overlay.rs +++ b/src/cargo/sources/overlay.rs @@ -132,6 +132,11 @@ impl<'gctx> Source for DependencyConfusionThreatOverlaySource<'gctx> { self.remote.add_to_yanked_whitelist(pkgs); } + fn add_to_pubtime_allowlist(&self, pkgs: &[crate::core::PackageId]) { + self.local.add_to_pubtime_allowlist(pkgs); + self.remote.add_to_pubtime_allowlist(pkgs); + } + async fn is_yanked(&self, pkg: crate::core::PackageId) -> crate::CargoResult { self.remote.is_yanked(pkg).await } diff --git a/src/cargo/sources/path.rs b/src/cargo/sources/path.rs index 78dbe2027ee..fad63f06516 100644 --- a/src/cargo/sources/path.rs +++ b/src/cargo/sources/path.rs @@ -203,6 +203,8 @@ impl<'gctx> Source for PathSource<'gctx> { fn add_to_yanked_whitelist(&self, _pkgs: &[PackageId]) {} + fn add_to_pubtime_allowlist(&self, _pkgs: &[PackageId]) {} + async fn is_yanked(&self, _pkg: PackageId) -> CargoResult { Ok(false) } @@ -404,6 +406,8 @@ impl<'gctx> Source for RecursivePathSource<'gctx> { fn add_to_yanked_whitelist(&self, _pkgs: &[PackageId]) {} + fn add_to_pubtime_allowlist(&self, _pkgs: &[PackageId]) {} + async fn is_yanked(&self, _pkg: PackageId) -> CargoResult { Ok(false) } diff --git a/src/cargo/sources/registry/index/mod.rs b/src/cargo/sources/registry/index/mod.rs index 2c5a09f2008..01b74a40adf 100644 --- a/src/cargo/sources/registry/index/mod.rs +++ b/src/cargo/sources/registry/index/mod.rs @@ -142,6 +142,9 @@ pub enum IndexSummary { Unsupported(Summary, u32), /// An error was encountered despite being a supported schema version Invalid(Summary), + /// Published too recently relative to the configured `min-publish-age`. + /// The span is the age of the version (`gctx.invocation_time() - pubtime`). + TooNew(Summary, jiff::Span), } impl IndexSummary { @@ -152,7 +155,8 @@ impl IndexSummary { | IndexSummary::Yanked(sum) | IndexSummary::Offline(sum) | IndexSummary::Unsupported(sum, _) - | IndexSummary::Invalid(sum) => sum, + | IndexSummary::Invalid(sum) + | IndexSummary::TooNew(sum, _) => sum, } } @@ -163,7 +167,8 @@ impl IndexSummary { | IndexSummary::Yanked(sum) | IndexSummary::Offline(sum) | IndexSummary::Unsupported(sum, _) - | IndexSummary::Invalid(sum) => sum, + | IndexSummary::Invalid(sum) + | IndexSummary::TooNew(sum, _) => sum, } } @@ -174,6 +179,7 @@ impl IndexSummary { IndexSummary::Offline(s) => IndexSummary::Offline(f(s)), IndexSummary::Unsupported(s, v) => IndexSummary::Unsupported(f(s), v.clone()), IndexSummary::Invalid(s) => IndexSummary::Invalid(f(s)), + IndexSummary::TooNew(s, t) => IndexSummary::TooNew(f(s), t), } } diff --git a/src/cargo/sources/registry/mod.rs b/src/cargo/sources/registry/mod.rs index 441278fc058..401c9be0690 100644 --- a/src/cargo/sources/registry/mod.rs +++ b/src/cargo/sources/registry/mod.rs @@ -268,6 +268,17 @@ pub struct RegistrySource<'gctx> { /// warning twice, with the assumption of (`dep.package_name()` + `--precise` /// version) being sufficient to uniquely identify the same query result. selected_precise_yanked: RefCell>, + /// The latest publish time accepted from this registry. + /// + /// Computed as `now() - min-publish-age`. + max_pubtime: Option, + /// A set of packages that should be allowed to be used, even if they are pubtime-incompatible + /// + /// This is populated from the entries in `Cargo.lock` to ensure that + /// `cargo update somepkg` won't unlock pubtime-incompat entries in `Cargo.lock`. + /// Otherwise, the resolver would think that those entries no longer exist, + /// and it would trigger updates to unrelated packages. + pubtime_allowlist: RefCell>, } /// The [`config.json`] file stored in the index. @@ -480,11 +491,8 @@ impl<'gctx> RegistrySource<'gctx> { /// Creates a [`Source`] of a "remote" registry. /// It could be either an HTTP-based [`http_remote::HttpRegistry`] or /// a Git-based [`remote::RemoteRegistry`]. - /// - /// * `yanked_whitelist` --- Packages allowed to be used, even if they are yanked. pub fn remote( source_id: SourceId, - yanked_whitelist: &HashSet, gctx: &'gctx GlobalContext, ) -> CargoResult> { assert!(source_id.is_remote_registry()); @@ -501,28 +509,20 @@ impl<'gctx> RegistrySource<'gctx> { Box::new(remote::RemoteRegistry::new(source_id, gctx, &name)) as Box<_> }; - Ok(RegistrySource::new( - source_id, - gctx, - &name, - ops, - yanked_whitelist, - )) + Ok(RegistrySource::new(source_id, gctx, &name, ops)) } /// Creates a [`Source`] of a local registry, with [`local::LocalRegistry`] under the hood. /// /// * `path` --- The root path of a local registry on the file system. - /// * `yanked_whitelist` --- Packages allowed to be used, even if they are yanked. pub fn local( source_id: SourceId, path: &Path, - yanked_whitelist: &HashSet, gctx: &'gctx GlobalContext, ) -> RegistrySource<'gctx> { let name = short_name(source_id, false); let ops = local::LocalRegistry::new(path, gctx, &name); - RegistrySource::new(source_id, gctx, &name, Box::new(ops), yanked_whitelist) + RegistrySource::new(source_id, gctx, &name, Box::new(ops)) } /// Creates a source of a registry. This is a inner helper function. @@ -530,13 +530,11 @@ impl<'gctx> RegistrySource<'gctx> { /// * `name` --- Name of a path segment which may affect where `.crate` /// tarballs, the registry index and cache are stored. Expect to be unique. /// * `ops` --- The underlying [`RegistryData`] type. - /// * `yanked_whitelist` --- Packages allowed to be used, even if they are yanked. fn new( source_id: SourceId, gctx: &'gctx GlobalContext, name: &str, ops: Box, - yanked_whitelist: &HashSet, ) -> RegistrySource<'gctx> { // Before starting to work on the registry, make sure that // `/registry` is marked as excluded from indexing and @@ -553,18 +551,36 @@ impl<'gctx> RegistrySource<'gctx> { let _ = registry_base.create_dir(); cargo_util::paths::exclude_from_backups_and_indexing(®istry_base.into_path_unlocked()); + let max_pubtime = compute_max_pubtime(source_id, gctx); + RegistrySource { name: name.into(), src_path: gctx.registry_source_path().join(name), gctx, source_id, index: index::RegistryIndex::new(source_id, ops.index_path(), gctx), - yanked_whitelist: RefCell::new(yanked_whitelist.clone()), + yanked_whitelist: RefCell::new(HashSet::new()), ops, selected_precise_yanked: RefCell::new(HashSet::new()), + max_pubtime, + pubtime_allowlist: RefCell::new(HashSet::new()), } } + /// Returns the version's pubtime age if it exceeds [`Self::max_pubtime`]. + fn is_pubtime_incompat(&self, summary: &IndexSummary) -> Option { + let IndexSummary::Candidate(_) = summary else { + return None; + }; + + let pubtime = summary.as_summary().pubtime()?; + let max_time = self.max_pubtime?; + + let exceeded = pubtime > max_time; + + exceeded.then_some(self.gctx.invocation_time() - pubtime) + } + /// Decode the [configuration](RegistryConfig) stored within the registry. /// /// This requires that the index has been at least checked out. @@ -780,10 +796,14 @@ impl<'gctx> Source for RegistrySource<'gctx> { debug!("attempting query without update"); self.index .query_inner(dep.package_name(), &req, &*self.ops, &mut |s| { - if matches!(s, IndexSummary::Candidate(_) | IndexSummary::Yanked(_)) - && dep.matches(s.as_summary()) + if matches!( + s, + IndexSummary::Candidate(_) + | IndexSummary::Yanked(_) + | IndexSummary::TooNew(_, _) + ) && dep.matches(s.as_summary()) { - // We are looking for a package from a lock file so we do not care about yank + // We are looking for a package from a lock file so we do not care about yank or age callback(s) } }) @@ -819,9 +839,13 @@ impl<'gctx> Source for RegistrySource<'gctx> { if !matched { return; } - // Next filter out all yanked packages. Some yanked packages may - // leak through if they're in a whitelist (aka if they were - // previously in `Cargo.lock` + + let s = if let Some(age) = self.is_pubtime_incompat(&s) { + IndexSummary::TooNew(s.into_summary(), age) + } else { + s + }; + match s { s @ _ if kind == QueryKind::RejectedVersions => callback(s), s @ IndexSummary::Candidate(_) => callback(s), @@ -833,6 +857,11 @@ impl<'gctx> Source for RegistrySource<'gctx> { callback(s); } } + s @ IndexSummary::TooNew(_, _) => { + if self.pubtime_allowlist.borrow().contains(&s.package_id()) { + callback(s); + } + } IndexSummary::Unsupported(summary, v) => { tracing::debug!( "unsupported schema version {} ({} {})", @@ -960,6 +989,10 @@ impl<'gctx> Source for RegistrySource<'gctx> { self.yanked_whitelist.borrow_mut().extend(pkgs); } + fn add_to_pubtime_allowlist(&self, pkgs: &[PackageId]) { + self.pubtime_allowlist.borrow_mut().extend(pkgs); + } + async fn is_yanked(&self, pkg: PackageId) -> CargoResult { self.index.is_yanked(pkg, &*self.ops).await } @@ -1117,3 +1150,65 @@ fn update_mtime_for_generated_files(pkg_root: &Path) { } } } + +/// Computes the max publish time for this registry based on config. +fn compute_max_pubtime(source_id: SourceId, gctx: &GlobalContext) -> Option { + use crate::util::context::{CargoResolverConfig, IncompatiblePublishAge}; + use crate::util::time_span::parse_time_span; + + if !gctx.cli_unstable().min_publish_age { + return None; + } + + let resolver_config = gctx.get::("resolver").ok(); + + // Priority: + // + // 1. registries..min-publish-age + // 2. registry.min-publish-age (default registry) + // 3. registry.global-min-publish-age + let min_publish_age = if let Some(name) = source_id.alt_registry_key() { + gctx.get::>(&format!("registries.{name}.min-publish-age")) + .ok() + .flatten() + } else if source_id.is_crates_io() { + gctx.get::>("registry.min-publish-age") + .ok() + .flatten() + } else { + None + }; + let min_publish_age = min_publish_age.or_else(|| { + gctx.get::>("registry.global-min-publish-age") + .ok() + .flatten() + })?; + + if resolver_config + .as_ref() + .and_then(|c| c.incompatible_publish_age.as_ref()) + == Some(&IncompatiblePublishAge::Allow) + { + return None; + } + + if min_publish_age == "0" { + return None; + } + + let duration = parse_time_span(&min_publish_age) + .map_err(|e| { + tracing::warn!(?source_id, "failed to parse min-publish-age: {e}"); + e + }) + .ok()?; + + let now = gctx.invocation_time(); + let span = jiff::Span::new().seconds(duration.as_secs() as i64); + now.checked_sub(span) + .map_err(|e| { + tracing::warn!("failed to compute max publish time for `{span}`: {e}"); + e + }) + .ok() +} diff --git a/src/cargo/sources/replaced.rs b/src/cargo/sources/replaced.rs index d0e3d5ba33c..f0ee8c6bfad 100644 --- a/src/cargo/sources/replaced.rs +++ b/src/cargo/sources/replaced.rs @@ -164,6 +164,14 @@ impl<'gctx> Source for ReplacedSource<'gctx> { self.inner.add_to_yanked_whitelist(&pkgs); } + fn add_to_pubtime_allowlist(&self, pkgs: &[PackageId]) { + let pkgs = pkgs + .iter() + .map(|id| id.with_source_id(self.replace_with)) + .collect::>(); + self.inner.add_to_pubtime_allowlist(&pkgs); + } + async fn is_yanked(&self, pkg: PackageId) -> CargoResult { self.inner.is_yanked(pkg).await } diff --git a/src/cargo/sources/source.rs b/src/cargo/sources/source.rs index 02740231a7a..f5c5d7db927 100644 --- a/src/cargo/sources/source.rs +++ b/src/cargo/sources/source.rs @@ -130,6 +130,11 @@ pub trait Source { /// sources. fn add_to_yanked_whitelist(&self, pkgs: &[PackageId]); + /// Add a number of crates that should be allowed during queries, + /// even if they are pubtime-incompatible. + /// Currently only applies to registry sources. + fn add_to_pubtime_allowlist(&self, pkgs: &[PackageId]); + /// Query if a package is yanked. Only registry sources can mark packages /// as yanked. This ignores the yanked whitelist. async fn is_yanked(&self, pkg: PackageId) -> CargoResult; @@ -242,6 +247,10 @@ impl<'a, T: Source + ?Sized + 'a> Source for &'a mut T { (**self).add_to_yanked_whitelist(pkgs); } + fn add_to_pubtime_allowlist(&self, pkgs: &[PackageId]) { + (**self).add_to_pubtime_allowlist(pkgs); + } + async fn is_yanked(&self, pkg: PackageId) -> CargoResult { (**self).is_yanked(pkg).await } diff --git a/src/cargo/util/context/mod.rs b/src/cargo/util/context/mod.rs index bcf095f0154..0dc65ae7b89 100644 --- a/src/cargo/util/context/mod.rs +++ b/src/cargo/util/context/mod.rs @@ -252,6 +252,10 @@ pub struct GlobalContext { cache_rustc_info: bool, /// Creation time of this config, used to output the total build time creation_time: Instant, + /// Wall-clock time of this cargo invocation. + /// + /// Currently used as the reference time for `min-publish-age` and `-Zbuild-analysis`. + invocation_time: jiff::Timestamp, /// Target Directory via resolved Cli parameter target_dir: Option, /// Environment variable snapshot. @@ -358,6 +362,15 @@ impl GlobalContext { _ => true, }; + #[expect( + clippy::disallowed_methods, + reason = "testing only, no reason for config support" + )] + let invocation_time = match env::var("__CARGO_TEST_INVOCATION_TIME") { + Ok(now) => now.parse().unwrap(), + Err(_) => jiff::Timestamp::now(), + }; + GlobalContext { home_path: Filesystem::new(homedir), shell: Mutex::new(shell), @@ -379,6 +392,7 @@ impl GlobalContext { crates_io_source_id: Default::default(), cache_rustc_info, creation_time: Instant::now(), + invocation_time, target_dir: None, env, updated_sources: Default::default(), @@ -2058,6 +2072,15 @@ impl GlobalContext { self.creation_time } + /// Returns the wall-clock time of this cargo invocation. + /// + /// See the [`invocation_time`] field doc for details. + /// + /// [`invocation_time`]: GlobalContext::invocation_time + pub fn invocation_time(&self) -> jiff::Timestamp { + self.invocation_time + } + /// Retrieves a config variable. /// /// This supports most serde `Deserialize` types. Examples: diff --git a/src/cargo/util/context/schema.rs b/src/cargo/util/context/schema.rs index 68d4c1ef351..4d09a2592be 100644 --- a/src/cargo/util/context/schema.rs +++ b/src/cargo/util/context/schema.rs @@ -304,6 +304,7 @@ impl BuildTargetConfig { /// ```toml /// [resolver] /// incompatible-rust-versions = "fallback" +/// incompatible-publish-age = "deny" /// feature-unification = "workspace" /// lockfile-path = "my/Cargo.lock" /// ``` @@ -311,6 +312,7 @@ impl BuildTargetConfig { #[serde(rename_all = "kebab-case")] pub struct CargoResolverConfig { pub incompatible_rust_versions: Option, + pub incompatible_publish_age: Option, pub feature_unification: Option, pub lockfile_path: Option, } @@ -322,6 +324,13 @@ pub enum IncompatibleRustVersions { Fallback, } +#[derive(Debug, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum IncompatiblePublishAge { + Allow, + Deny, +} + #[derive(Copy, Clone, Debug, Deserialize)] #[serde(rename_all = "kebab-case")] pub enum FeatureUnification { diff --git a/src/cargo/util/logger.rs b/src/cargo/util/logger.rs index 0ced79778ef..10665d2f5ec 100644 --- a/src/cargo/util/logger.rs +++ b/src/cargo/util/logger.rs @@ -124,7 +124,7 @@ impl BuildLogger { /// Generates a unique run ID. pub fn generate_run_id(ws: &Workspace<'_>) -> RunId { - RunId::new(&ws.root()) + RunId::new(&ws.root(), ws.gctx().invocation_time()) } /// Returns the run ID for this build session. @@ -166,9 +166,9 @@ pub struct RunId { impl RunId { const FORMAT: &str = "%Y%m%dT%H%M%S%3fZ"; - pub fn new(h: &H) -> RunId { + pub fn new(h: &H, timestamp: jiff::Timestamp) -> RunId { RunId { - timestamp: jiff::Timestamp::now(), + timestamp, hash: short_hash(h), } } diff --git a/src/cargo/util/mod.rs b/src/cargo/util/mod.rs index a49ed84b6dd..3254deb1f7c 100644 --- a/src/cargo/util/mod.rs +++ b/src/cargo/util/mod.rs @@ -69,6 +69,7 @@ pub mod rustc; mod semver_eval_ext; mod semver_ext; pub mod sqlite; +pub mod time_span; pub mod toml; pub mod toml_mut; mod unhashed; diff --git a/src/cargo/util/time_span.rs b/src/cargo/util/time_span.rs new file mode 100644 index 00000000000..8ef61ed1aa9 --- /dev/null +++ b/src/cargo/util/time_span.rs @@ -0,0 +1,73 @@ +use std::time::Duration; + +use crate::CargoResult; + +/// Parses a time span string. +pub fn parse_time_span(span: &str) -> CargoResult { + maybe_parse_time_span(span).ok_or_else(|| { + anyhow::format_err!( + "expected a value of the form \ + \"N seconds/minutes/hours/days/weeks/months\", got: {span:?}" + ) + }) +} + +/// Parses a time span string. +/// +/// Returns None if the value is not valid. See [`parse_time_span`] if you +/// need a variant that generates an error message. +pub fn maybe_parse_time_span(span: &str) -> Option { + let Some(right_i) = span.find(|c: char| !c.is_ascii_digit()) else { + return None; + }; + let (left, mut right) = span.split_at(right_i); + if right.starts_with(' ') { + right = &right[1..]; + } + let count: u64 = left.parse().ok()?; + let factor = match right { + "second" | "seconds" => 1, + "minute" | "minutes" => 60, + "hour" | "hours" => 60 * 60, + "day" | "days" => 24 * 60 * 60, + "week" | "weeks" => 7 * 24 * 60 * 60, + "month" | "months" => 2_629_746, // average is 30.436875 days + _ => return None, + }; + Some(Duration::from_secs(factor * count)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn time_spans() { + let d = |x| Some(Duration::from_secs(x)); + assert_eq!(maybe_parse_time_span("0 seconds"), d(0)); + assert_eq!(maybe_parse_time_span("1second"), d(1)); + assert_eq!(maybe_parse_time_span("23 seconds"), d(23)); + assert_eq!(maybe_parse_time_span("5 minutes"), d(60 * 5)); + assert_eq!(maybe_parse_time_span("2 hours"), d(60 * 60 * 2)); + assert_eq!(maybe_parse_time_span("1 day"), d(60 * 60 * 24)); + assert_eq!(maybe_parse_time_span("2 weeks"), d(60 * 60 * 24 * 14)); + assert_eq!(maybe_parse_time_span("6 months"), d(2_629_746 * 6)); + } + + #[test] + fn time_span_errors() { + assert_eq!(maybe_parse_time_span(""), None); + assert_eq!(maybe_parse_time_span("1"), None); + assert_eq!(maybe_parse_time_span("second"), None); + assert_eq!(maybe_parse_time_span("+2 seconds"), None); + assert_eq!(maybe_parse_time_span("day"), None); + assert_eq!(maybe_parse_time_span("-1 days"), None); + assert_eq!(maybe_parse_time_span("1.5 days"), None); + assert_eq!(maybe_parse_time_span("1 dayz"), None); + assert_eq!(maybe_parse_time_span("always"), None); + assert_eq!(maybe_parse_time_span("never"), None); + assert_eq!(maybe_parse_time_span("1 day "), None); + assert_eq!(maybe_parse_time_span(" 1 day"), None); + assert_eq!(maybe_parse_time_span("1 second"), None); + } +} diff --git a/src/doc/src/reference/unstable.md b/src/doc/src/reference/unstable.md index 139b7f7491e..56570b4137a 100644 --- a/src/doc/src/reference/unstable.md +++ b/src/doc/src/reference/unstable.md @@ -76,6 +76,7 @@ Each new feature described below should explain how to use it. * [public-dependency](#public-dependency) --- Allows dependencies to be classified as either public or private. * [msrv-policy](#msrv-policy) --- MSRV-aware resolver and version selection * [precise-pre-release](#precise-pre-release) --- Allows pre-release versions to be selected with `update --precise` + * [min-publish-age](#min-publish-age) --- Filters out dependency versions published more recently than a configured minimum age. * [sbom](#sbom) --- Generates SBOM pre-cursor files for compiled artifacts * [update-breaking](#update-breaking) --- Allows upgrading to breaking versions with `update --breaking` * [feature-unification](#feature-unification) --- Enable new feature unification modes in workspaces @@ -408,6 +409,121 @@ It's possible to update `my-dependency` to a pre-release with `update -Zunstable This is because `0.1.2-pre.0` is considered compatible with `0.1.1`. It would not be possible to upgrade to `0.2.0-pre.0` from `0.1.1` in the same way. +## min-publish-age + +* Tracking Issue: [#17009](https://github.com/rust-lang/cargo/issues/17009) +* RFC: [#3923](https://github.com/rust-lang/rfcs/pull/3923) + +The `-Zmin-publish-age` feature allows users to specify a minimum age for +dependency versions. When specified, Cargo won't use a version of a registry +crate that is newer than the minimum age, with a way to override for exceptions +like urgent security fixes. + +For example, in your `/.cargo/config.toml`: + +```toml +[registry] +global-min-publish-age = "14 days" +``` + +### Added to Configuration + +The following will be added to Cargo's configuration format: + +```toml +[resolver] +incompatible-publish-age = "deny" # Specifies how resolver reacts to these + +[registries.] +min-publish-age = "..." # Override `registry.global-min-publish-age` for this registry + +[registry] +min-publish-age = "..." # Override `registry.global-min-publish-age` for crates.io +global-min-publish-age = "0" # Minimum time span allowed for packages from this registry +``` + +#### `resolver.incompatible-publish-age` + +* Type: String +* Default: `"deny"` +* Environment: `CARGO_RESOLVER_INCOMPATIBLE_PUBLISH_AGE` + +When resolving the version of a dependency, +specify the behavior for versions with a `pubtime` (if present) +that is incompatible with `registry.min-publish-age`. +Values include: + +- `allow`: treat pubtime-incompatible versions like any other version +- `deny`: ignore pubtime-incompatible versions unless they already exist in the lock file + +#### `registries..min-publish-age` + +* Type: String +* Default: none +* Environment: `CARGO_REGISTRIES__MIN_PUBLISH_AGE` + +Specifies the minimum timespan since a version's `pubtime` that it may be +considered for `resolver.incompatible-publish-age` for packages from this +registry. If not set, `registry.global-min-publish-age` will be used. + +Will be ignored if the registry does not support this. + +It supports the following values: + +- An integer followed by "seconds", "minutes", "hours", "days", "weeks", or "months" +- `"0"` to allow all packages + +#### `registry.min-publish-age` + +* Type: String +* Default: none +* Environment: `CARGO_REGISTRY_MIN_PUBLISH_AGE` + +Specifies the minimum timespan since a version's `pubtime` that it may be +considered for `resolver.incompatible-publish-age` for packages from crates.io. +If not set, `registry.global-min-publish-age` will be used. + +It supports the following values: + +- An integer followed by "seconds", "minutes", "hours", "days", "weeks", or "months" +- `"0"` to allow all packages + +Generally, `"0"`, `"N days"`, and `"N weeks"` will be used. + +#### `registry.global-min-publish-age` + +* Type: String +* Default: `"0"` +* Environment: `CARGO_REGISTRY_GLOBAL_MIN_PUBLISH_AGE` + +Specifies the global minimum timespan since a version's `pubtime` that it may +be considered for `resolver.incompatible-publish-age` for packages. +If `min-publish-age` is not set for a specific registry using +`registries..min-publish-age`, Cargo will use this minimum publish age. + +It supports the following values: + +- An integer followed by "seconds", "minutes", "hours", "days", "weeks", or "months" +- `"0"` to allow all packages + +### Added to Resolver + +The following will be added to the [resolver chapter] as a sibling section to +"Yanked versions": + +> "Pubtime-incompatible versions" +> +> Versions with a publish time newer than the configured `min-publish-age` +> are considered pubtime-incompatible. +> When `resolver.incompatible-publish-age` is set to `deny`, +> the resolver will ignore these versions +> unless they already exist in the `Cargo.lock` file. +> Setting the config to `allow` would disable the check, +> which if combined with `cargo update --precise`, +> cargo would pull in a specific version and its transitive dependencies. + +[resolver chapter]: ../reference/resolver.md + ## sbom * Tracking Issue: [#13709](https://github.com/rust-lang/cargo/pull/13709) * RFC: [#3553](https://github.com/rust-lang/rfcs/pull/3553) diff --git a/tests/testsuite/cargo/z_help/stdout.term.svg b/tests/testsuite/cargo/z_help/stdout.term.svg index b5725124eba..c79aee4d39e 100644 --- a/tests/testsuite/cargo/z_help/stdout.term.svg +++ b/tests/testsuite/cargo/z_help/stdout.term.svg @@ -1,4 +1,4 @@ - +