Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Summary

This proposal adds a new configuration option to cargo allowing 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.

An example configuration would be:

[registry]
global-min-publish-age = "14 days"

Motivation

There are a couple of reasons why one may wish not to use the most recent version of a package:

Some supply chain attacks are found by automated scanners on newly published package versions. Recent supply chain attacks on the NPM ecosystem have drawn attention to the value of waiting on these automated scanners. For more background on version maturity requirements as a risk mitigation, see We should all be using dependency cooldowns and Dependency cooldowns, redux.

There would be value in a gradual roll out scheme for the ecosystem. New versions can introduce inadvertent breaking changes, bugs, or security vulnerabilities. Having everyone discover these problems at once leads to a wider, costlier disruption to the ecosystem. Some maintainers are fine being on the bleeding edge, taking on those costs, and acting as a canary for the ecosystem. Those who are more risk averse can choose how much stagnation they are willing to accept for others to discover these problems and get them worked out. Maintainers may even want to blend these in one project: keep risks down for local development while CI has a dependency version canary job to identify future problems and track their status. Granted, this only helps if the problems are discovered by yourself or others. Any fixes will also be subject to the minimum-release age but at least these will be available to upgrade to so long as there is an exception mechanism.

Allowing maintainers to encourage a certain degree of maturity for dependency versions can help these use cases.

Note that this is not a full solution to compromised dependencies. It can increase the protection against certain types of “supply chain” attacks, but not all of them. As such, using this feature should not be relied upon for security by itself.

Guide-level explanation

The registry.global-min-publish-age configuration option1 for Cargo can be used to specify a minimum age for published versions to use. When set, Cargo treats versions with a publish time (“pubtime”) newer than that duration: Cargo will not use a too-new version unless it is already recorded in Cargo.lock, and will generate an error if there are no longer any compatible versions.

For example, in your <repo>/.cargo/config.toml, you may have:

[registry]
global-min-publish-age = "14 days"

Running cargo update will look something like:

$ cargo update
Updating index
 Locking 1 package to recent Rust 1.60 compatible version
  Adding some-package v1.2.3 (available: v1.3.0, published 2 days ago)

While a CI job runs:

env:
  CARGO_RESOLVER_INCOMPATIBLE_RUST_VERSIONS: allow
  CARGO_RESOLVER_INCOMPATIBLE_PUBLISH_AGE: allow
steps:
  - uses: actions/checkout@v4
  - run: rustup update stable && rustup default stable
  - run: cargo update --verbose
  - run: cargo build --verbose
  - run: cargo test --verbose

This will mean that:

  • Locally, cargo update will only select versions older than the minimum publish age, e.g., some-package@1.2.3
  • This CI job will verify the latest versions of your dependencies, e.g., some-package@1.3.0

Per-registry configuration

It is also possible to configure the min-publish-age per cargo registry. registries.<name>.min-publish-age sets the minimum publish age for the <name> registry. And registry.min-publish-age sets it for crates.io.

For example:

[registries.my-org]
index = "https://my.org"
min-publish-age = "0" # this registry is fully trusted

[registry]
# Default for any registry without a specific value
global-min-publish-age = "14 days"
# Value to use for crates.io
min-publish-age = "5 days"

This will use a minimum publish age of

  • 5 days for crates.io
  • no minimum for my-org
  • 14 days for any other registry.

When no version matches

If no version of a dependency satisfies both the version requirement and the minimum publish age, the resolve will error, similar to when all matching versions are yanked:

$ cargo update
error: failed to select a version for the requirement `some-package = "^1.3"`
  version 1.3.0 is too new (published 2 days ago, minimum age 14 days)
help: to preserve the min-publish-age, downgrade the version requirement to `"1.1"`
help: to use `"1.3"` anyways, re-resolve with `CARGO_RESOLVER_INCOMPATIBLE_PUBLISH_AGE=allow`

Using newer versions

In some cases, it may be desirable to use a version that is newer than the minimum publish age. For example, some-package from earlier has a fix for a vulnerability in v1.3.0.

The CARGO_RESOLVER_INCOMPATIBLE_PUBLISH_AGE=allow environment variable can temporarily disable the check:

$ CARGO_RESOLVER_INCOMPATIBLE_PUBLISH_AGE=allow cargo update clap --precise 4.5.3
Updating clap 4.3.0 -> 4.5.3 (published 2 days ago, minimum age 14 days)
Updating clap_derive 4.3.0 -> 4.5.3 (published 2 days ago, minimum age 14 days)
Updating clap_builder 4.3.0 -> 4.5.3 (published 2 days ago, minimum age 14 days)

Once the versions are recorded in Cargo.lock, subsequent resolves will keep them.

Reference-level explanation

This RFC adds a few new configuration options to cargo configuration.

Added to Configuration Format

[resolver]
incompatible-publish-age = "deny" # Specifies how resolver reacts to these

[registries.<name>]
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

Added to [resolver]

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

See the resolver chapter for more details.

Added to [registries]

registries.min-publish-age

  • Type: String
  • Default: none
  • Environment: CARGO_REGISTRIES_<name>_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

Added to [registry]

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.<name>.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

“Pubtime-incompatible versions” as a sibling section to Yanked 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.

Applicability

The minimum publish age check applies to the following dependency sources:

  • Dependencies fetched from a registry that publishes pubtime, such as crates.io.
  • Does not apply to git or path dependencies, in part because there is not always an obvious publish time, or a way to find alternative versions.
  • Does not apply to registries that don’t set pubtime, as there is no reliable way to know when the version was published.
  • For source replacement, registry mirrors are expected to preserve pubtime from the index to be applicable. Local registries and directory (vendored) sources typically don’t have pubtime, so the check does not apply.
  • cargo install is subject to min-publish-age and will not select pubtime-incompatible versions. resolver.incompatible-publish-age cannot override this as the [resolver] table does not apply to cargo install.

Drawbacks

Slower problem discovery

The biggest drawback is that if this is widely used, it could potentially lead to it taking longer for problems to be discovered after a version is published. However, most likely, there will be a spread of values used, depending on risk tolerance, and hopefully the result is actually that there will be a more gradual rollout in most cases.

Also, even if all users of a crate set a minimum publish age there is still value in a delay, because it provides time for automated security scanners, and human reviewers to review the changes before the new version is pulled in by updates. And in the case of a malicious release made using compromised credentials, it gives the actual developer time to realize their credentials have been compromised and yank the version before it is widely used.

On a related note, delayed discovery might make authors feel it is “too late” to yank because wall-clock time has passed. However, the minimum publish age also means most consumers haven’t resolved the new version yet, so a yank remains low-disruption for longer than elapsed time alone would suggest. The practical yank window is shaped more by adoption than by the calendar.

Disjoint resolver config values

resolver.incompatible-publish-age supports allow/deny while resolver.incompatible-rust-versions supports allow/fallback, which may be confusing. See Starting with deny for why the value sets differ.

Rationale and Alternatives

Configuration Locations

The locations and names of the configuration options in this proposal were chosen to be consistent with existing Cargo options, as described in Related Options in Cargo.

Configuration Names

The term “publish” was used rather than “package”, “version”, or “release” to make it clear that this only applies to crates that are published in a registry.

publish is redundant with this being in the registry table. This helps with the above disambiguation and for clarity in discussing this as a shorthand.

cooldown was avoided due to the term generally referring to throttling while we are looking for a certain maturity.

Starting with deny

resolver.incompatible-publish-age starts with allow and deny.

Unlike resolver.incompatible-rust-versions which starts with fallback, deny is viable here because pubtime data is exhaustive. crates.io sets it for every version once backfilled, so there are no gaps that would cause spurious errors.

A fallback option would deprioritize too-new versions but still allow them as a last resort. This is deferred because it opens the yank attack vector: a malicious actor with the right permissions could publish a malicious version and yank the safe versions. It then forces the resolver to fall back to the malicious too-new version. deny prevents this by erroring instead of falling back.

fallback may be useful in the future for risk-tolerant workflows that prefer a degraded resolve over an error, particularly when combined with other tools that validate pubtime-incompatible versions. It would also help with cargo update --precise for packages with transitive dependencies. For example, CARGO_RESOLVER_INCOMPATIBLE_PUBLISH_AGE=fallback cargo update clap --precise 4.5.3 would pull in only the necessary too-new transitive dependencies rather than disabling the check entirely with allow.

Timestamp vs duration

Some prior art:

  • exclusively use a timestamp
  • allow either a timestamp or a relative time within the same field

While a timestamp has its uses (see --publish-time), it wouldn’t be as ergonomic for this use case.

Designing the field to support both would create a trap for users trying to reproduce a problem from the past in that they are likely to set the timestamp but overlook that they need to take the existing duration into account. Even if they do remember to take the existing duration into account, it would be more convenient if they can be set separately.

Setting the timestamp to resolve to is left as a future possibility.

Per-registry configuration

Allowing the minimum age to be configurable per registry provides a simple mechanism to use different minimum ages for different sets of packages, including possibly no minimum in common situations such as using an internal registry where the crates are completely trusted.

This makes it less necessary to have more complicated configuration for rules for including and excluding sets of packages from the age policy, or setting different age policies for different packages.

Exclude list

Exclude lists tend to be used either for:

  • Forcing a specific newer version: we have this covered through CARGO_RESOLVER_INCOMPATIBLE_PUBLISH_AGE=allow combined with cargo update --precise
  • Marking a source as always trusted: we have this covered through per-registry configuration

One problem with an exclude list is that they tend to be a static solution (all versions) for a transient problem (a subset of versions). This can leave people open to an attack after a high-value upgrade. An exclude list of just names is helpful for “I have a trusted package source” scenario, but less so for “I need a security fix now”. The user must remember to remove the exclusion once it is no longer needed, or they lose protection for future versions of that package. We could make the exclude list use the Package ID Spec format and even require a full version to be specified.

Users likely will need to exclude transitive dependencies as well. For instance, to use a too-new version of clap, you may also need to exclude clap_builder, clap_derive, and clap_lex.

An exclude list can always be added in the future if a strong enough use case presents itself. By delaying, we can also take into account any future changes. For example, if the focus is on different levels of trust within the same registry, we could design a solution around registry namespacing, assuming support is added.

Using Cargo.toml and Cargo.lock (i.e. “do nothing”)

You can pin versions in your Cargo.toml but that is a manual process and doesn’t cover transitive dependencies.

Users can manage all of their direct and transitive dependencies in a Cargo.lock file but that is tedious and it is easy to overlook new entries on implicit lockfile changes.

Why not leave this to third party tools?

There are already some third party tools that fulfill this functionality to some degree. For example, dependabot and renovate can be used for updating Cargo.toml and Cargo.lock, and both support some form of minimum publish age. And the cargo-cooldown project provides an alternative to cargo update that respects a minimum publish age.

However, these tools only work for updating and adding dependencies outside of cargo itself, they do not have any impact on local changes, like directly editing Cargo.toml causing an implicit Cargo.lock update, cargo update, or cargo add.

Prior Art

“Package Managers Need to Cool Down” discusses several implementations of this in various package managers (including this RFC).

“Dependency-cooldown discussions warm up” covers the broader ecosystem debate around dependency cooldowns, including an alternative “upload queue” approach where registries delay distribution rather than consumers delay adoption.

Debian “testing”

Debian’s “testing” distribution consists of packages from unstable that have been in the “unstable” distribution for a certain minimum age (2-10 days depending on an urgency field in the package changelog), have been built for all previously supported targets, have their dependencies in testing, and don’t have any new release-critical bugs.

Users of “unstable” include early adopters who don’t mind being the canary when things break (and reporting the aforementioned bugs, release-critical or otherwise). Users of “testing” get slightly older packages and a reduced chance of release-critical bugs.

pnpm

minimumReleaseAge is a configuration option which takes a number of minutes as an argument. It then won’t update or install releases that were released less than that many minutes ago. This also applies to transitive dependencies.

minimumReleaseAgeExclude is an array of package names, or package patterns for which the minimumReleaseAge does not apply, and the newest applicable release is always used. It also allows specifying specific versions to be allowed.

Both configuration options can be set in global config, a project-specific config file, or with environment variables (for a specific invocation).

yarn

Has a configuration setting that can be used in .yarnrc.yml named npmMinimalAgeGate that can be used to set the minimum age for installed package releases. It looks like it allows specifying units, as the example for three days is 3d, however I haven’t found any definitive description of the syntax.

As far as I can tell, there is no way to provide exclusions to this rule, or different times for different packages or repositories.

uv

The --exclude-newer option can be used to set a timestamp (using RFC 3339 format), or a duration (either “friendly” or ISO 8601 format) and won’t use releases that happened after that timestamp. There is also an --exclude-newer-package option, which allows overriding the exclude-newer time for individual packages.

Both of these settings can also be used in the uv configuration file (pyproject.toml).

pip

Pip has an --uploaded-prior-to option that only uses versions that were uploaded prior to an ISO 8601 timestamp. Can also be controlled with the PIP_UPLOADED_PRIOR_TO environment variable.

dependabot

The cooldown option provides a number of settings, including:

  • default-days – Default minimum age of release, in days
  • semver-major-days, semver-minor-days, semver-patch-days – Override the cooldown/minimum-release-age based on what kind of release it is.
  • include / exclude – a list of packages to include/exclude in the “cooldown”. Supports wildcards. exclude has higher priority than include.

“Security” updates bypass the cooldown settings.

Dependabot doesn’t support cooldown for all package managers.

This is specified in the dependabot configuration file.

renovate

The options below can be provided in global, or project-specific configuration files, as a CLI option, or as an environment variable.

minimumReleaseAge specifies a duration which all updates must be older than for renovate to create an update. It looks like the duration specification uses units (ex. “3 days”), however, again I can’t find a precise specification for the syntax.

It is possible to create separate rules with different minimumReleaseAge configurations, on a per-package basis, or across groups of packages/registries.

“Security” updates bypass the minimum release age checks.

deno

Deno supports a configuration option for minimumDependencyAge in the configuration file, or --minimum-dependency-age on the CLI. It supports an ISO-8601 duration, RFC 3339 timestamp, or an integer of minutes.

cargo-cooldown

There is an existing experimental third-party crate that provides a plugin for enforcing a cooldown: [https://github.com/dertin/cargo-cooldown]

Some precedents in Cargo

cache.auto-clean-frequency

  • “never” — Never deletes old files.
  • “always” — Checks to delete old files every time Cargo runs.
  • An integer followed by “seconds”, “minutes”, “hours”, “days”, “weeks”, or “months”

resolver.incompatible-rust-versions

  • Controls behavior in relation to your package.rust-version and those set by potential dependencies

  • Values:

  • allow: treat rust-version-incompatible versions like any other version

  • fallback: only consider rust-version-incompatible versions if no other version matched

package.resolver is only a version number. When adding incompatible-rust-version, we intentionally deferred anything being done in manifests.

[registry]

  • Set default registry
  • Sets credential providers for all registries
  • Sets crates.io values

[registries]

  • Sets registry specific values

yanked: can’t do new resolves to it but left in if already there. --precise can force it but that doesn’t apply recursively.

pre-release: requires opt-in through version requirement. Unstable support to force it with --precise but that doesn’t apply recursively.

Unresolved Questions

  • Can we, and should we, make any guarantees about security when using this feature, such as “a malicious version of a crate will not compromise the build if published within the minimum publish age window”?
  • How do we make it clear when things are held back?
  • Implementation wise, will there be much complexity in getting per registry information into VersionPreferences and using it?
  • deny precedence between this and incompatible-rust-version?
    • Both produce errors, but incompatible-rust-version will likely be evaluated first to increase the chance of builds succeeding.

Future Possibilities

  • Support fallback for resolver.incompatible-publish-age (see Starting with deny for why this is deferred).
  • Add an exclude list for min-publish-age (see Exclude list for why this is deferred).
  • When all compatible older-than-min-age versions are yanked and a newer non-yanked version exists, Cargo could alert the user that they may want to override with --precise.
  • Potentially support other source of publish time besides the pubtime field from a cargo registry.
  • A resolver.now field for setting the reference time that min-publish-age is compared against. This could be useful for offline workflows where wall-clock time keeps advancing but the registry index may be stale.

  1. As specified in .cargo/config.toml files