Changing Cargo semver compatibility for pre-releases

Currently, Cargo relies on the semver crate, which tries to adhere to the semver spec as much as possible. However, in the context of Rust's crate compatibility ecosystem, I personally believe the current behavior is surprising and can easily lead to broken builds where this is unexpected.

Problem

For the match expression >= 2.0.0 , here's the output of VersionReq::matches for the following versions:

  • 2.0.0-rc.0 : false
  • 2.0.0 : true
  • 3.0.0-rc.0 : false
  • 3.0.0 : true
  • 4.0.0-rc.0 : false
  • 4.0.0 : true

I feel like this sort of "surprising" behavior exists in the interpretation of these VersionReq s:

  • 2.0.0-rc.0
  • ^2.0.0-rc.0

These are interpreted as: >=2.0.0-rc.0, <3.0.0

To me falls under the "surprising behavior for consumers" category. It means anyone who uses such a requirement will be automatically opted in to upgrading to a final 2.0.0 release, which is problematic if any breaking changes occur between 2.0.0-rc.0 and the final 2.0.0 release.

These are interpreted as: >=2.0.0-rc.0, <3.0.0

Personally I got interested in this when cargo-deny reported a vulnerability in trust-dns-resolver 0.20.0-alpha.3 which actually had been solved in 0.4.3 and 0.5.0-alpha.3.

However, I also had issues where cargo update would update my workspace from trust-dns-resolver 0.20.0-alpha.3 to trust-dns-resolver 0.20.0. 0.20.0 final used tokio 1.0 whereas the alpha was still on an earlier version, so that was not a trivial update -- this is not just a vulnerability reporting issue.

Proposed solution

Cargo should consider pre-releases as incompatible with "stable" releases:

  • ^3.0.0-alpha.1 does not match 3.0.0-alpha.2
  • ^3.0.0-alpha.1 does not match 3.0.0-beta.1
  • ^3.0.0-alpha.1 does not match 3.0.0

Context

@dtolnay as current maintainer of semver has stated:

Now if Cargo were to decide to change its interpretation of pre-release reqs in a future version (via a Cargo RFC), this crate would follow suite, since the scope is explicitly to implement Cargo's interpretation of SemVer. But neither of us had the impression that was likely to happen.

Therefore I would like to bring this discussion to the Cargo team.

cc @ehuss @Eh2406

3 Likes

Is this a typo? Or is it really intended that, if I have foo="2", I might pull a pre-release?

What would be the difference between =3.0.0-alpha.1 and ^3.0.0-alpha.1?

Let's call it a thinko. Removed it for now.

I think there probably should not be a difference?

I’ve had a related grievance: one my dependencies depended on a pre-release package. That caused cargo update to break my code, as that transitive dep got changed in the next pre-release. My understanding is that this is allowed by semver — requirements are waived for pre-releases, so that this mechanism can be used to test-drive the API before committing to it. What didn’t work out well is that, as a maintainer of the leaf application with Cargo.lock, I didn’t know that my dep opted me into instability.

2 Likes

I am working on rewriting the vulnerability matching logic in rustsec crate in order to fix this. Here's my perspective.

The version requirements used e.g. by Cargo and implemented by semver crate are distinct from version ranges. It appears that for a version requirement it is a deliberate design decision not to match pre-release versions, so that a requirement such as >= 2.0.0 that did not explicitly opt in to pre-release versions doesn't receive them.

The use case for rustsec crate matching of vulnerable versions is genuinely different. We need to track version intervals e.g. from 0.6.0 to 0.6.5, but really don't care if any of the versions within that interval are pre-releases or not. From the spec this only uses the precedence rules, not the version matching logic.

This extra logic around pre-releases also unexpectedly shows up in other places, causing trouble. For example, deps.rs will report pre-release versions as out of date.

The distinction is subtle, and communicating about it is tricky. I know of at least one maintainer who was burned out on their entire project while trying to resolve this, both by communicating with the upstream (unsuccessfully) and by implementing custom logic (repeatedly broken by semver crate API changes).

The best solution I can see is semver crate implementing a range matching logic based on precedence only, since there appears to be demand for it. There even appears to be a framework for it already in place - prior to 1.0 semver crate also supported the node.js SemVer flavor.

At this point I don't really care because I'm rolling my own version matching logic regardless, but it could be useful for other consumers of the semver crate.

7 Likes

I also find it annoying that for prereleases you have to use =x.y.z as a version req to avoid breakage in cargo update. However, I've also heard from the actix(-web) devs that they intentionally didn't use exact version requirements because that would mean more work to publish a new lower-level crate prerelease (now have to re-release all the reverse deps even if there were no breaking changes that affected them).

I think nobody really wants to write >=1.0.0-beta.1 <2.0.0, so maybe an alternative could be that ^1.0.0-beta.1 keeps its current behavior, but 1.0.0-beta.1 changes to be equivalent to =1.0.0-beta.1?

3 Likes

Before I can meaningfully think about what a good behavior would be, I wonder what is a practical way to change this? Will it brake existing lockfiles? If it is a braking change how will that be rolled out?

I'm fine with the current behavior. Pre-release versions behave as if they existed in their own semver-major version, and that makes sense in practice.

Certainly it makes sense that >= 2.0.0 doesn't match 2.0.0-rc.0, because pre-release means it hasn't been released yet. In semver sort order it's true that 2.0.0-rc.0 < 2.0.0.

Pre-release versions may be unstable, and >= 2.0.0 names a stable version. It's not explicitly opting to alpha/beta versions. So even though 3.0.0-beta is a higher version, it's an unstable version. It could be breaking to opt-in users to unstable versions when they don't ask explicitly.

IMHO in this case it's just a wrong implementation in cargo-audit. Instead of matches it should probably use Ord or have its own equivalent of matches that is optimized for describing release ranges, rather than matching versions to version requirements.

2 Likes

This, I think, actually has a chance if someone were to write a good Cargo RFC.

4 Likes

Fun fact: the behavior of selectors such as ^ is not specified anywhere in the semver 2.0 spec.

It does specify precedence rules, but that's not the same as selectors and handles pre-releases very differently.

Cargo's semver "range" syntax was originally based on NPM semver ranges, though Cargo supports only a subset of the NPM syntax and there is no plan to implement the full NPM spec.

Is there a specification of the known operators and the semantics of each operator? Or rather, does this list the full range of known operators?

I have found myself reimplementing the behavior of operators because I need to turn version requirements into version ranges, and semver crate doesn't support that. A specification would be very helpful.

Yes, I believe the Cargo reference's list of operators is complete. The corresponding code is here.

Note that the special treatment of pre-release identifiers means that a VersionReq string like ">= 1.0.0" does not match versions like "2.0.0-alpha", so treating a VersionReq as a range will not precisely match the Cargo/NPM behavior. It may still be a reasonable behavior for the advisory DB, though.

Thank you!

Yes, indeed ">= 1.0.0" not matching versions like "2.0.0-alpha" is an issue for the advisory DB, but is probably reasonable behavior for Cargo. This is one of the reasons why I'm writing my own implementation of version matching.