Skip to content

Commit 6e53bd7

Browse files
build(cargo): add a check that aligned workspaces lock shared crates like the root
`cargo --locked` only checks a lockfile against its own manifest. A workspace outside the root that builds root crates through path dependencies, such as the testkit suites building `openshell-sdk`, has its own Cargo.lock, so the two lockfiles can resolve a shared crate to different versions without any check failing. That is how aws-lc-sys 0.46 was picked up in #4629, which does not link against the AWS-LC that Nix provides. check_cargo_lockfile_alignment.py fails when an aligned workspace locks a registry crate that the root lockfile also contains at a version the root lockfile does not have. With --fix it re-locks the workspace starting from the root lockfile. tests/testkit is the first aligned workspace. The script and its tests come from #4629 (a37ebe9). Co-authored-by: Evan Lezar <elezar@nvidia.com> Signed-off-by: Matthew Grossman <mgrossman@nvidia.com>
1 parent 01aa719 commit 6e53bd7

3 files changed

Lines changed: 260 additions & 4 deletions

File tree

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
#!/usr/bin/env python3
2+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
3+
# SPDX-License-Identifier: Apache-2.0
4+
5+
# /// script
6+
# requires-python = ">=3.11"
7+
# ///
8+
9+
"""Check that aligned Cargo workspaces lock shared crates like the root does.
10+
11+
Some workspaces outside the root build root crates through path
12+
dependencies, for example the testkit suites build `openshell-sdk`. Each
13+
workspace has its own Cargo.lock, and `cargo --locked` only checks a lockfile
14+
against its own manifest, so nothing stops the two lockfiles from resolving
15+
the same crate to different versions. The root's versions are the ones CI and
16+
the Nix toolchain are built for; a newer `aws-lc-sys`, for example, does not
17+
link against the AWS-LC that Nix provides.
18+
19+
This check fails when an aligned workspace locks a registry crate that the
20+
root lockfile also contains at a version the root lockfile does not have.
21+
With `--fix`, it re-locks each aligned workspace starting from the root
22+
lockfile.
23+
"""
24+
25+
from __future__ import annotations
26+
27+
import argparse
28+
import re
29+
import shutil
30+
import subprocess
31+
import sys
32+
import tomllib
33+
from dataclasses import dataclass
34+
from pathlib import Path
35+
36+
# Workspaces whose shared crates must match the root lockfile. Add a
37+
# workspace here once its lockfile has been aligned with `--fix`.
38+
ALIGNED_WORKSPACES = (
39+
"tests/testkit",
40+
)
41+
42+
43+
@dataclass(frozen=True)
44+
class Mismatch:
45+
workspace: str
46+
name: str
47+
version: str
48+
root_versions: tuple[str, ...]
49+
50+
def target(self) -> str | None:
51+
"""The root version semver-compatible with the locked one, if any."""
52+
key = compat_key(self.version)
53+
candidates = [v for v in self.root_versions if compat_key(v) == key]
54+
return max(candidates, key=version_tuple) if candidates else None
55+
56+
57+
def version_tuple(version: str) -> tuple[int, ...]:
58+
core = re.split(r"[-+]", version, maxsplit=1)[0]
59+
return tuple(int(part) for part in core.split("."))
60+
61+
62+
def compat_key(version: str) -> tuple[int, ...]:
63+
"""Cargo's caret-compatibility class: the leftmost non-zero component."""
64+
major, minor, patch = (*version_tuple(version), 0, 0, 0)[:3]
65+
if major:
66+
return (major,)
67+
if minor:
68+
return (0, minor)
69+
return (0, 0, patch)
70+
71+
72+
def registry_versions(lockfile: Path) -> dict[str, set[str]]:
73+
"""Versions of registry (non-path) packages in a lockfile, by name."""
74+
data = tomllib.loads(lockfile.read_text())
75+
versions: dict[str, set[str]] = {}
76+
for package in data.get("package", []):
77+
if "source" in package:
78+
versions.setdefault(package["name"], set()).add(package["version"])
79+
return versions
80+
81+
82+
def mismatches(root: Path, workspace: str) -> list[Mismatch]:
83+
root_versions = registry_versions(root / "Cargo.lock")
84+
found = []
85+
for name, versions in sorted(
86+
registry_versions(root / workspace / "Cargo.lock").items()
87+
):
88+
shared = root_versions.get(name)
89+
if shared is None:
90+
continue
91+
for version in sorted(versions - shared, key=version_tuple):
92+
found.append(
93+
Mismatch(
94+
workspace, name, version, tuple(sorted(shared, key=version_tuple))
95+
)
96+
)
97+
return found
98+
99+
100+
def fix(root: Path, workspace: str) -> list[Mismatch]:
101+
"""Re-lock the workspace starting from the root lockfile.
102+
103+
Coupled crates such as the `futures-*` family pin each other, so they
104+
cannot be moved one at a time. Seeding the workspace lockfile with the
105+
root's lets Cargo keep every root version that still fits, add only the
106+
crates the root does not have, and drop the rest. Returns what is still
107+
misaligned afterwards.
108+
"""
109+
shutil.copyfile(root / "Cargo.lock", root / workspace / "Cargo.lock")
110+
subprocess.run(
111+
[
112+
"cargo",
113+
"metadata",
114+
"--format-version",
115+
"1",
116+
"--manifest-path",
117+
str(root / workspace / "Cargo.toml"),
118+
],
119+
check=True,
120+
stdout=subprocess.DEVNULL,
121+
)
122+
return mismatches(root, workspace)
123+
124+
125+
def report(problems: list[Mismatch]) -> None:
126+
for m in problems:
127+
target = m.target()
128+
hint = (
129+
f"; root has {target}"
130+
if target
131+
else f"; root has {', '.join(m.root_versions)}"
132+
)
133+
print(
134+
f"error: {m.workspace}: {m.name} {m.version} is not in the root Cargo.lock{hint}",
135+
file=sys.stderr,
136+
)
137+
138+
139+
def main(argv: list[str] | None = None) -> int:
140+
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
141+
parser.add_argument(
142+
"--fix", action="store_true", help="update aligned lockfiles to root versions"
143+
)
144+
args = parser.parse_args(argv)
145+
146+
root = Path(
147+
subprocess.run(
148+
["git", "rev-parse", "--show-toplevel"],
149+
check=True,
150+
capture_output=True,
151+
text=True,
152+
).stdout.strip()
153+
)
154+
problems: list[Mismatch] = []
155+
for workspace in ALIGNED_WORKSPACES:
156+
print(f"Checking {workspace}/Cargo.lock against Cargo.lock")
157+
problems += fix(root, workspace) if args.fix else mismatches(root, workspace)
158+
159+
if problems:
160+
report(problems)
161+
if not args.fix:
162+
print(
163+
"Run `uv run tasks/scripts/check_cargo_lockfile_alignment.py --fix` to align them.",
164+
file=sys.stderr,
165+
)
166+
return 1
167+
return 0
168+
169+
170+
if __name__ == "__main__":
171+
sys.exit(main())
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Tests for the Cargo lockfile alignment check."""
5+
6+
from __future__ import annotations
7+
8+
import importlib.util
9+
import sys
10+
from pathlib import Path
11+
12+
_SPEC = importlib.util.spec_from_file_location(
13+
"check_cargo_lockfile_alignment",
14+
Path(__file__).with_name("check_cargo_lockfile_alignment.py"),
15+
)
16+
assert _SPEC and _SPEC.loader
17+
alignment = importlib.util.module_from_spec(_SPEC)
18+
sys.modules[_SPEC.name] = alignment
19+
_SPEC.loader.exec_module(alignment)
20+
21+
REGISTRY = 'source = "registry+https://github.com/rust-lang/crates.io-index"'
22+
23+
24+
def lockfile(path: Path, packages: list[tuple[str, str, bool]]) -> None:
25+
"""Write a lockfile of (name, version, from_registry) packages."""
26+
entries = []
27+
for name, version, from_registry in packages:
28+
entry = f'[[package]]\nname = "{name}"\nversion = "{version}"\n'
29+
if from_registry:
30+
entry += REGISTRY + "\n"
31+
entries.append(entry)
32+
path.parent.mkdir(parents=True, exist_ok=True)
33+
path.write_text("version = 4\n\n" + "\n".join(entries))
34+
35+
36+
def test_compat_key_follows_cargo_caret_rules() -> None:
37+
assert alignment.compat_key("1.52.1") == (1,)
38+
assert alignment.compat_key("0.44.0") == (0, 44)
39+
assert alignment.compat_key("0.0.3") == (0, 0, 3)
40+
assert alignment.compat_key("1.0.4+wasi-0.2.12") == (1,)
41+
42+
43+
def test_reports_shared_registry_crates_at_other_versions(tmp_path: Path) -> None:
44+
lockfile(
45+
tmp_path / "Cargo.lock",
46+
[
47+
("tokio", "1.52.1", True),
48+
("rand", "0.8.6", True),
49+
("rand", "0.9.4", True),
50+
("openshell-sdk", "0.0.0", False),
51+
],
52+
)
53+
lockfile(
54+
tmp_path / "ws" / "Cargo.lock",
55+
[
56+
("tokio", "1.53.1", True),
57+
("rand", "0.9.5", True),
58+
("rand", "0.8.6", True),
59+
("only-here", "2.0.0", True),
60+
("openshell-sdk", "0.0.0", False),
61+
],
62+
)
63+
64+
found = alignment.mismatches(tmp_path, "ws")
65+
66+
assert [(m.name, m.version, m.target()) for m in found] == [
67+
("rand", "0.9.5", "0.9.4"),
68+
("tokio", "1.53.1", "1.52.1"),
69+
]
70+
71+
72+
def test_no_target_without_a_compatible_root_version(tmp_path: Path) -> None:
73+
lockfile(tmp_path / "Cargo.lock", [("syn", "1.0.109", True)])
74+
lockfile(tmp_path / "ws" / "Cargo.lock", [("syn", "2.0.119", True)])
75+
76+
(found,) = alignment.mismatches(tmp_path, "ws")
77+
78+
assert found.target() is None
79+
80+
81+
def test_aligned_lockfiles_pass(tmp_path: Path) -> None:
82+
lockfile(tmp_path / "Cargo.lock", [("tokio", "1.52.1", True)])
83+
lockfile(tmp_path / "ws" / "Cargo.lock", [("tokio", "1.52.1", True)])
84+
85+
assert alignment.mismatches(tmp_path, "ws") == []

‎tests/testkit/Cargo.lock‎

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)