PSA: Please specify precise dependency versions in Cargo.toml

I think it's more nuanced than that. Cargo cares about major versions (they affect dependency resolution). In contrast, Cargo treats minor and patch versions essentially the same. That is, although semver spec assigns different meanings to patch and minor, in the context of Cargo there's nothing which takes advantages of that. This is further exacerbated by the fact that many crates are pre 1.0, and minor&patch are physically merged into one number for those.

In other words, the difference between major and minor is technical, the difference between minor and patch is purely social.

7 Likes

Hey thank you ! I'm one of those who uses "1" or "0.1" without never throwing much thought about it. I'll change that.

4 Likes

My immediate reaction was to strongly disagree, provided you follow through and actually test with the minimal versions. So I contemplated a more nuanced recommendation: include the full latest version number, or test the minimal versions you use; but then I realised that writing the full version number doesn’t actually solve the problem of overly-broad dependency specifications in the long term, because it’s fairly easy to accidentally update your lockfile, after which you can encounter the problem all over again.

And so I say: if you’re not deliberately testing on minimal versions, all approaches other than exact version dependencies (like serde = "=1.0.130") are Wrong™.

It’s then a question of relative harms, and yeah, a patch-level minimum that at least matches at the time you first add the dependency is probably less harmful than an overly-wide spec that you’ve never tested.

But I think it’s important to realise that what you’re recommending only mitigates a bad practice, it doesn’t fix it. Only testing fixes it, because the bad practice is not actually the broad version specifications, but rather failing to test your minimum versions—for that is the cause of both of the problems you’re pointing out.

So now my inclination is towards this style of recommendation: test on minimal versions, but if you’re not willing to do that, include a patch number and never ever run cargo update, which achieves a result almost as good. I just don’t like your framing of it which is closer to “actively remove old versions even though they work fine”.

This suggests to me that there may be a need for improvements in testing minimum versions (of Rust and of dependencies; I would also note the pain that is the absence of consideration of MSRV in version resolution). You know how cargo publish checks everything builds unless you pass --no-verify? Well, what if it were to do a second round of building, on minimal versions of the crate dependencies (even if not of Rust)? This would seem a fairly easy fix of the whole problem, and I don’t think it should be controversial, either.

(I write all this as one who recently published a crate release depending on hashbrown >= 0.1.1, <0.13, testing at both ends of the range, and feeling good about the whole thing.)

7 Likes

I agree with the problem, but not the solution. You're absolutely right that crates specifying a version range (which is anything except =major.minor.patch, which is rarely used) should make sure that their project is actually compatible with the range that they've declared.

I know people use vague versions with good intentions to give flexibility to downstream users, but this backfires! Your crate may get a 5-year-old version of a dependency you've never tested. If you want to be maximally flexible with the versions you specify, please actually test them!

Absolutely! This seems like the right solution to the problem; I did not know about minimal-versions until now, but I'll definitely add a step to my CI pipelines that use this to verify that my version ranges are correct.

Vague versions also create a second, counter-intuitive problem. You may be specifying very old dependency versions thinking of users of old Rust versions that can't upgrade their dependencies. The problem is that keeping less-than-latest dependencies in Cargo is hard. Without an existing carefully-preserved antique lockfile, it's a game of whack-a-mole of locking down versions of a dependency of a dependency. People who had to do it rightfully hate it. It would be sooo much easier with the -Z minimal-versions option, but ironically, the imprecise versions break this feature! Vague versions are not helping old Rust users: they're breaking the best tool they could have.

I don't even understand what is being said here. What's the second problem? That pinning old versions in an application is difficult? A dependency using a general version range doesn't cause this problem, its just a general Cargo problem. The only difference is that users don't even have the option of doing this if they really want to, if everyone uses too-specific of version ranges. I understand that this is a problem; I don't understand how this a problem with vague versions.

There's also a problem here not being mentioned should you choose to swing the other way of using precise dependency versions: API compatibility. When Cargo resolves a project's dependencies, it will do so in one of two methods:

  • For each crate, try to find a crate version that meets the version ranges of all crates in the project.
  • If the combined version ranges is not solveable with a single crate, attempt to solve it with multiple versions.

Now this is actually a feature (I talk about it in more depth here) but this can actually cause problems if a particular dependency being solved is actually part of an API being shared between crates, and this actually can come into play for projects that maintain an MSRV.

Let's say crate A uses a crate X as part of its public API, and crate B also uses crate X as part of its API, and application C uses both A and B together. Crate A seeks to maintain a strict MSRV, and thus doesn't want versions 1.4 or newer of crate X, but it is compatible with any other 1.x version, so it declares a version range on crate X as >=1.0.0, <1.4.0. However, version 1.4 was recently released, so crate B sets its version as precisely as possible on X, so it sets its version as ^1.4.2. This will now cause interop between crate A and B using types from X to fail compilation, because the only way that Cargo can solve the dependency tree for crate C is to select multiple X versions, and types from different crate versions are considered to be different types by the compiler. This could have been avoided if B was actually compatible with anything newer than, say, 1.1, and they set a more general version of ^1.1.

So in summary: Yes, it is a problem when crates aren't actually compatible with dependency versions that they claim to be, but the solution isn't to simply narrow the version range, but rather to verify that your ranges are correct, and potentially narrow or widen them with what is actually compatible. If you want to be especially generous, if it is trivial to broaden the versions your project supports with just a simple change in your code, then perhaps you should do so.

2 Likes

Because of this issue, I always publicly export any dependencies that appear in my public API. It's not a complete solution as two crates that do this might not be interoperable with each other, but it at least mitigates some of the problem.

2 Likes

First off: you'd need to test at least every semver incompatible version in the range to be sure the constraint is accurate. It's not good enough to test 0.1.1 and 0.12 if 0.8 doesn't work.

But on the other side of the coin: you'll actually only ever get 0.1.1 or 0.12.latest, due to how cargo's resolver works.

Minimal versions will get you the bottom of your range, but there's no way to get something in the middle of the range without crafting an artisanal lockfile.

3 Likes

Unfortunately it looks like minimal-versions is all-or-nothing. In an application with 132 [[package]] entries in Cargo.lock, one of the dependencies specifies its own dependency with rand = ">= 0.3.10, < 0.9". Through rand 0.3.10 I end up pulling winapi 0.0.1 which does not compile today. To go past that and find the rest of potential errors I had to manually edit Cargo.lock to not use such an old rand version.

Perhaps that library shouldn’t use such a wide range of allowed versions in its dependency specification, but it appears to be a deliberate choice so I don’t feel like filing an issue to argue against it.

2 Likes

This strict following of semver that is advocated here is not very helpful and you can still run into the issue of needing the patch specified because of bug fixes or non-API addition functionality (this still can qualify for a patch release according to the semver spec).

I've written a bit more on this topic after it came up in an Issue thread.

1 Like

Please, let's fix the crates ecosystem to work with -Z minimal-versions

I agree, but how do we do this? For example, in one of my projects if I run cargo upgrade then build with -Z minimal-versions, version 1.0.0 of the crate void is pulled in. This crate does not compile even on its own, because:

   |
14 | extern crate core;
   | ^^^^^^^^^^^^^^^^^^ `core` reimported here
   |

So what do I do here? I can cargo update -p void --precise 1.0.1 to keep moving on to figure out the versions of my direct dependencies that I need to specify in Cargo.toml. But I still can't actually build with -Z minimal-versions, because this is a version issue that's buried inside my dependency tree. As far as I can tell:

  1. We could ask maintainers to publish new versions of crates, all the way down the dependency tree. Whatever depends on void needs a new version which depends on void = "1.0.1", then whatever depends on that needs a new version... and there are 5 levels of dependencies between me and void. Once all 5 crates have published a new version, I can change my version requirement to fix this. This requires getting buy-in from every maintainer along this chain, and also waiting a long time because this is an inherently serial process.

  2. We could ask that version 1.0.0 of void be yanked. But is "I used this unstable Cargo flag and this old version of your package broke my build" a defensible reason to yank a crate? I think if we want to do this, we need to be able to make a pretty good case for yanking crates. I don't know how to make that case, maybe people in this discussion can help.

2 Likes

ehuss has commented about an alternative algorithm:

Ideally we want -Z minimal-versions to only direct dependencies which should significantly alleviate that problem, but unfortunately it is not implemented. Until then, it is not recommended to use it.

In your post you've missed the key purpose of semver-minor: downgrades.

Change in semver-major acts as a barrier when versions go up. You know you can upgrade from 1.4 to 1.5, but not to 2.0, because some public APIs in 2.0 could have been removed.

Semver-minor acts as a similar barrier when versions go down. You know you can downgrade from 1.4.5 to 1.4.0, but not to 1.2.0, because looking in this direction, some APIs could have been removed (if you add a new API in 1.5, then going from 1.5 to 1.4 removes the API).

This leaves semver-patch as the only part of the version that can be freely moved up or down. This is the important distinction. If a downgrade is not 100% safe, in the semver-breakage sense, then you can't use patch for it. That's why I mean about breaking semver. Downgrades of crates that failed to bump semver-minor correctly have the same exact type of breakage as upgrades of crates that failed to use semver-major correctly. It is not about adherence to the spec for sake of the spec, but about real compilation failures.

With semver-major the question "is it safe to upgrade?" has a gray area. Similarly semver-minor "is it safe to downgrade?" has a gray area, which is why I'm focusing on the objectively easy case: new public APIs. If you add one, someone can depend on it, and then a downgrade is clearly no longer possible.


I understand the desire of keeping semver-minor releases for important features worth announcing, but this is just a variation sentimental versioning. Before semver was accepted, major versions were used for marketing push. It seems now that semver-major got accepted for its mechanical role, the marketing role fell onto semver-minor. But semver is ruthless about this: numbers are free and plentiful, and are supposed to be bumped depending on compatibility, not for marketing.

12 Likes

I didn’t test against every version, but I did read its changelog before going with the full range, and I saw no reason to suspect problems with any intermediate version. Well, except this one: my crate’s MSRV is 1.36.0, and hashbrown 0.11.0 bumped its MSRV beyond that; people on 1.37.0–1.56.0 that want to enable hashbrown (it’s an optional dependency) when they don’t already have hashbrown in their lock file are going to have to intervene manually because of the whole problem of MSRV not influencing resolution.

I'd disagree about safe downgrades. Regardless of minor and patch (even using the semver meanings), the only way you know its safe to downgrade is to do it and run your tests.

Maybe its just been my use cases but I also don't see safe downgrades as a big enough need to have people worrying about the distinction.

3 Likes

welll... since i usually do tiny personal stuff in rust, i use to use "*" for versions.
am i the devil? :joy::joy::joy:

The advice in kornel's post applies to crates that are published to crates.io. For personal projects you should use whatever dependency version constraints are most convenient for you.

1 Like

Regardless of minor and patch (even using the semver meanings), the only way you know its safe to downgrade is to do it and run your tests.

Would you say the same about upgrades? Would you recommend people use foo = "*" version requirements, because "the only way to know it's safe to upgrade is to do it and run your tests"? foo = "1" for minimal versions is as breaking as foo = "*" for maximal versions.

2 Likes

There's two pieces missing currently that would help guide library authors to better respect semver:

  1. stable -Z minimal-versions
  2. doc annotations like #[doc(available_since = "1.8.0")] that surface in rustdoc the same way stdlib does

After that, it becomes much easier to say for certain that: yes, you should specify as low a version as is possible/reasonable because it will 1. be easy to check in CI and 2. be obvious when developing that some feature has not been around since the x.0 version.

5 Likes

I definitely agree this would be nice, but don't do it for various tooling-related reasons :

  1. Most basically, there's no tool that I know of that can find the earliest version-set my code builds/passes tests with that resolves to the same semver version. Realistically this is just a little tedious, but could be built. (Ideally, it also stops at versions with published advisories)

  2. I don't know how to find what version I now need after beginning to use an API in a crate I didn't use before. I guess 1 can solve this too, in theory.

  3. Lots of tooling and users in both the rust, and broader programming ecosystem assumes that if you put a full x.y.z number, it's out of date if it's not the latest. Drives me crazy -- Dependabot and the crates vscode plugin are both this way, and there are several more...

None of this is great. The first would be required for me in practice to switch to this, second would be required to stick with it (possibly), but the third tends to become distracting enough for me that it prevents me from being interested...

2 Likes

Thanks for the detailed post, it really helped my understanding of how cargo dependencies work. While the topic is fresh, though, I have a couple of questions:

  1. On the official cargo docs, there's info on using tilde (~) and wildcard (*) requirements. Now that I (think I) understand how cargo resolves versions (e.g. B = 1.2.0 goes to 1.2.1 and not 1.3.0, assuming both are available), aren't tilde and wildcard requirements redundant? It looks like the default x.y.z does the same thing. By contrast, caret (^) requirements have different behaviour when versions are >= 1.0.0.

(It looks like my answer is confirmed by @coderstephen but I'll put it here anyway)
2. Let's say my crate A depends on B and C, but B also depends on C. My Cargo.toml specifies C = 0.7.0 and the version of B I'm depending on specifies C = >=0.5.0, and there's a 0.8.0 available for C. Does cargo resolve 0.8.0 as version of C for B, and 0.7.0 as the version of C for A? If so, this would explain a big headache I've had in the past. I'm guessing the path of least resistance would be for B to specify C = >=0.5.0,<0.8; we shouldn't try to predict the future, and we can verify that the specified range always works for a crate. The aforementioned headache was slightly mitigated with forced cargo update -p C --precise 0.7.0 calls but this was flaky and very, very frustrating.

I never read anything like this anywhere. (I’m addressing the point that you say you can downgrade from 1.4.5 to 1.4.0.) Would you be so kind to provide a source? Preferably one that’s part of the official Rust/cargo documentation :slight_smile: