Skip to content

Commit e3f939f

Browse files
Fix hosted/vendored cargo floor reads (#653, #651) (#1429)
* Start fix for #653, #651 Assisted-by: Claude Code:claude-opus-5-5 * Refuse hosted cargo on projects pinned below 1.68 Hosted cargo patches are served from a sparse registry, which cargo only loads from 1.68. A project that declared an older cargo in rust-version or rust-toolchain[.toml] got "success" from scan --mode hosted, and every build then failed with "usage of sparse registries requires -Z sparse-registry". Hosted scan now refuses each cargo patch there with redirect_cargo_old_cargo, writes nothing, and points at --mode vendored (#653). The declared floor now has one reader in formats/cargo, shared with the vendored multi-version warning. It also follows rust-version.workspace = true to [workspace.package], so a standard modern workspace no longer gets a false "declares no rust-version" warning when two versions of a crate are vendored (#651). Assisted-by: Claude Code:claude-opus-5-5 * Test the cargo floor through hosted scan Adds an e2e shape where rust-version = "1.67" makes scan --mode hosted refuse the cargo patch and leave the project untouched, and an engine test that the toolchain files reach the planner. Assisted-by: Claude Code:claude-opus-5-5 * Read the cargo floor the way rustup and cargo do Review follow-ups for the hosted cargo floor: - the legacy rust-toolchain file wins over rust-toolchain.toml when both exist, as it does for rustup - a dated nightly-/beta-YYYY-MM-DD channel maps to its release train, so a nightly from before sparse registries is refused too - a workspace member's rust-version counts, since one cargo builds the whole lock - the refusal names the rollback that drops pins an earlier run wrote - vendored reads the toolchain files only when Cargo.toml says nothing - the hosted candidate-file list test knows the toolchain files Assisted-by: Claude Code:claude-opus-5-5 * Don't let a blank rust-toolchain hide the floor The hosted engine records an unreadable in-memory rust-toolchain as an empty file. Because rustup prefers the legacy file, that blank placeholder hid rust-toolchain.toml's channel, and a project pinned below cargo 1.68 was still redirected. A blank legacy file now names no toolchain, so the TOML file's channel is used. Assisted-by: Claude Code:claude-opus-5-5 * Add the restored field to the cargo 1.67 refusal shape Main's #1420 added a restored field to the e2e Shape struct; the declared-cargo-floor test this branch adds predates it and no longer compiled after merging main. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 7d3086f commit e3f939f

10 files changed

Lines changed: 588 additions & 60 deletions

File tree

‎crates/socket-patch-cli/CLI_CONTRACT.md‎

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

‎crates/socket-patch-cli/src/commands/scan/hosted.rs‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4237,6 +4237,8 @@ mod tests {
42374237
"Cargo.lock",
42384238
".cargo/config.toml",
42394239
".cargo/config",
4240+
"rust-toolchain.toml",
4241+
"rust-toolchain",
42404242
"composer.lock",
42414243
"nuget.config",
42424244
"NuGet.config",

‎crates/socket-patch-cli/tests/e2e_redirect_cargo_shapes.rs‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1295,6 +1295,38 @@ async fn cargo_hosted_refuses_a_lockless_project_with_other_dependencies() {
12951295
let _ = run_shape(shape).await;
12961296
}
12971297

1298+
/// #653: the hosted redirect is a `sparse+` registry, which cargo loads
1299+
/// only from 1.68. A project whose `rust-version` declares cargo 1.67 is
1300+
/// refused, with nothing written, instead of getting pins every build on
1301+
/// its declared cargo then fails on (`usage of sparse registries requires
1302+
/// -Z sparse-registry`). (A toolchain file would make rustup fetch that
1303+
/// toolchain for the `cargo fetch` below; the planner unit tests cover it.)
1304+
#[tokio::test(flavor = "multi_thread")]
1305+
async fn cargo_hosted_refuses_a_project_declaring_cargo_below_1_68() {
1306+
let shape = Shape {
1307+
tag: "declared-cargo-1-67",
1308+
files: vec![
1309+
(
1310+
"Cargo.toml",
1311+
consumer_manifest("cfg-if = \"1.0.4\"\n").replace(
1312+
"edition = \"2018\"\n",
1313+
"edition = \"2018\"\nrust-version = \"1.67\"\n",
1314+
),
1315+
),
1316+
("src/main.rs", "fn main() {}\n".to_string()),
1317+
],
1318+
patches: vec![CFG_IF_1],
1319+
oracle: Vec::new(),
1320+
crlf: false,
1321+
lockless: false,
1322+
refused: Some("redirect_cargo_old_cargo"),
1323+
prefetch: "",
1324+
contest: None,
1325+
restored: vec![],
1326+
};
1327+
let _ = run_shape(shape).await;
1328+
}
1329+
12981330
fn local_crate(name: &str, version: &str) -> String {
12991331
format!("[package]\nname = \"{name}\"\nversion = \"{version}\"\nedition = \"2018\"\n")
13001332
}
Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
//! The lowest cargo a project TELLS us it builds on: the ONE reader of the
2+
//! project's declared floor, shared by the vendored multi-version warning
3+
//! and the hosted planner's sparse-registry gate.
4+
//!
5+
//! Read offline from the project itself, in this order: the workspace
6+
//! root's `rust-version` (`[package]`, else `[workspace.package]`, and a
7+
//! `rust-version.workspace = true` inheritor reads `[workspace.package]`),
8+
//! else the toolchain rustup picks from the root: the legacy
9+
//! `rust-toolchain` file when it exists (rustup prefers it over
10+
//! `rust-toolchain.toml`, with a warning), else `rust-toolchain.toml`'s
11+
//! `[toolchain] channel`.
12+
//!
13+
//! Everything here is pure; the callers own the reads.
14+
15+
use toml_edit::{Document, Item, Table};
16+
17+
use super::manifest::package_table;
18+
19+
/// The legacy extensionless toolchain file (a bare channel name, or the
20+
/// TOML form). rustup reads it in preference to [`TOOLCHAIN_TOML`].
21+
pub(crate) const TOOLCHAIN_LEGACY: &str = "rust-toolchain";
22+
/// The TOML toolchain file.
23+
pub(crate) const TOOLCHAIN_TOML: &str = "rust-toolchain.toml";
24+
25+
/// Days from 1970-01-01 to a proleptic Gregorian date.
26+
fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
27+
let y = if month <= 2 { year - 1 } else { year };
28+
let era = y.div_euclid(400);
29+
let yoe = y - era * 400;
30+
let doy = (153 * (month + if month > 2 { -3 } else { 9 }) + 2) / 5 + day - 1;
31+
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
32+
era * 146_097 + doe - 719_468
33+
}
34+
35+
/// The minor of a dated `nightly-YYYY-MM-DD` / `beta-YYYY-MM-DD` channel.
36+
/// Rust ships a stable release every six weeks (1.68 on 2023-03-09, 1.45
37+
/// on 2020-07-16 — the train has not slipped since 1.0), and on that day
38+
/// beta becomes the next minor and nightly the one after.
39+
fn dated_channel_minor(spec: &str) -> Option<u32> {
40+
let (ahead, date) = if let Some(date) = spec.strip_prefix("nightly-") {
41+
(2, date)
42+
} else if let Some(date) = spec.strip_prefix("beta-") {
43+
(1, date)
44+
} else {
45+
return None;
46+
};
47+
let mut parts = date.get(..10)?.split('-').map(str::parse::<i64>);
48+
let (year, month, day) = (
49+
parts.next()?.ok()?,
50+
parts.next()?.ok()?,
51+
parts.next()?.ok()?,
52+
);
53+
if !(1..=12).contains(&month) || !(1..=31).contains(&day) {
54+
return None;
55+
}
56+
let since = days_from_civil(year, month, day) - days_from_civil(2023, 3, 9);
57+
let stable = 68 + since.div_euclid(42);
58+
u32::try_from(stable + ahead).ok()
59+
}
60+
61+
/// The `1.<minor>` of a `rust-version` or toolchain channel (`1.67`,
62+
/// `1.67.1`, `1.67-x86_64-unknown-linux-gnu`, or a dated
63+
/// `nightly-2022-06-01`); `None` for a rolling channel (`stable`,
64+
/// `nightly`, …) or anything else.
65+
fn minor_of(spec: &str) -> Option<u32> {
66+
let spec = spec.trim().trim_matches('"');
67+
let Some(rest) = spec.strip_prefix("1.") else {
68+
return dated_channel_minor(spec);
69+
};
70+
rest.split(['.', '-', '+']).next()?.parse().ok()
71+
}
72+
73+
/// `[workspace.package].rust-version` of a parsed root manifest.
74+
fn workspace_rust_version(root: &Table) -> Option<&str> {
75+
root.get("workspace")
76+
.and_then(|w| w.get("package"))
77+
.and_then(|p| p.get("rust-version"))
78+
.and_then(Item::as_str)
79+
}
80+
81+
/// The package's own `rust-version`: `Ok(Some)` for a literal, `Err(())`
82+
/// for an inheritor (`rust-version.workspace = true`, or any non-string
83+
/// value), `Ok(None)` when it has none.
84+
fn own_rust_version(root: &Table) -> Result<Option<&str>, ()> {
85+
match package_table(root).and_then(|p| p.get("rust-version")) {
86+
None => Ok(None),
87+
Some(item) => item.as_str().map(Some).ok_or(()),
88+
}
89+
}
90+
91+
/// The `rust-version` the root `Cargo.toml` declares. `[package]`'s own
92+
/// string wins; an inheritor, a package without one, or a virtual
93+
/// workspace reads `[workspace.package]`.
94+
pub(crate) fn root_manifest_minor(text: &str) -> Option<u32> {
95+
let doc = Document::parse(text).ok()?;
96+
let root = doc.as_table();
97+
match own_rust_version(root) {
98+
Ok(Some(own)) => Some(own),
99+
_ => workspace_rust_version(root),
100+
}
101+
.and_then(minor_of)
102+
}
103+
104+
/// The `rust-version` a workspace member declares: its own literal, or the
105+
/// root's `[workspace.package]` value when it inherits it. A member with
106+
/// none declares nothing (cargo does not inherit implicitly).
107+
pub(crate) fn member_manifest_minor(member: &str, root: Option<&str>) -> Option<u32> {
108+
let doc = Document::parse(member).ok()?;
109+
match own_rust_version(doc.as_table()) {
110+
Ok(Some(own)) => minor_of(own),
111+
Ok(None) => None,
112+
Err(()) => {
113+
let root = Document::parse(root?).ok()?;
114+
workspace_rust_version(root.as_table()).and_then(minor_of)
115+
}
116+
}
117+
}
118+
119+
/// The `[toolchain] channel` of a TOML toolchain file.
120+
fn toolchain_channel(text: &str) -> Option<String> {
121+
let doc = Document::parse(text).ok()?;
122+
doc.get("toolchain")
123+
.and_then(|t| t.get("channel"))
124+
.and_then(Item::as_str)
125+
.map(str::to_string)
126+
}
127+
128+
/// The minor of the toolchain rustup picks from the root's toolchain files
129+
/// (`None` for a file that is absent or unreadable): the legacy file when
130+
/// it exists, else the TOML one. A blank legacy text names no toolchain
131+
/// (the hosted engine records an unreadable in-memory entry as `""`), so
132+
/// it does not hide the TOML file's channel.
133+
pub(crate) fn toolchain_minor(
134+
toolchain_toml: Option<&str>,
135+
toolchain_legacy: Option<&str>,
136+
) -> Option<u32> {
137+
if let Some(legacy) = toolchain_legacy.filter(|t| !t.trim().is_empty()) {
138+
return match toolchain_channel(legacy) {
139+
Some(channel) => minor_of(&channel),
140+
None => minor_of(legacy),
141+
};
142+
}
143+
minor_of(&toolchain_channel(toolchain_toml?)?)
144+
}
145+
146+
/// The lowest cargo minor the project declares, from the root `Cargo.toml`
147+
/// ([`root_manifest_minor`]), else its toolchain files
148+
/// ([`toolchain_minor`]).
149+
///
150+
/// `None` when the project declares nothing, or pins a rolling channel —
151+
/// socket-patch never runs `cargo`, so there is no other signal, and "no
152+
/// declaration" is NOT evidence of a modern cargo.
153+
pub(crate) fn declared_cargo_minor(
154+
manifest: Option<&str>,
155+
toolchain_toml: Option<&str>,
156+
toolchain_legacy: Option<&str>,
157+
) -> Option<u32> {
158+
manifest
159+
.and_then(root_manifest_minor)
160+
.or_else(|| toolchain_minor(toolchain_toml, toolchain_legacy))
161+
}
162+
163+
#[cfg(test)]
164+
mod tests {
165+
use super::*;
166+
167+
fn from_manifest(text: &str) -> Option<u32> {
168+
declared_cargo_minor(Some(text), None, None)
169+
}
170+
171+
#[test]
172+
fn reads_package_and_workspace_rust_version() {
173+
assert_eq!(
174+
from_manifest("[package]\nname = \"a\"\nrust-version = \"1.70\"\n"),
175+
Some(70)
176+
);
177+
assert_eq!(
178+
from_manifest("\u{feff}[package]\nname = \"a\"\nrust-version = \"1.67.1\"\n"),
179+
Some(67)
180+
);
181+
assert_eq!(
182+
from_manifest("[workspace]\n\n[workspace.package]\nrust-version = \"1.60\"\n"),
183+
Some(60)
184+
);
185+
// The package's own value wins over the workspace default.
186+
assert_eq!(
187+
from_manifest(
188+
"[workspace.package]\nrust-version = \"1.60\"\n\n\
189+
[package]\nname = \"a\"\nrust-version = \"1.75\"\n"
190+
),
191+
Some(75)
192+
);
193+
assert_eq!(from_manifest("[package]\nname = \"a\"\n"), None);
194+
assert_eq!(from_manifest("not toml ["), None);
195+
}
196+
197+
/// #651: `rust-version.workspace = true` inherits `[workspace.package]`
198+
/// — in the dotted spelling and the inline-table one.
199+
#[test]
200+
fn workspace_inheritor_reads_workspace_package() {
201+
for spelling in [
202+
"rust-version.workspace = true",
203+
"rust-version = { workspace = true }",
204+
] {
205+
let text = format!(
206+
"[workspace]\n\n[workspace.package]\nrust-version = \"1.70\"\n\n\
207+
[package]\nname = \"consumer\"\nversion = \"0.1.0\"\n{spelling}\n"
208+
);
209+
assert_eq!(from_manifest(&text), Some(70), "{spelling}");
210+
}
211+
}
212+
213+
#[test]
214+
fn falls_back_to_toolchain_files() {
215+
let toml = "[toolchain]\nchannel = \"1.67.1\"\n";
216+
assert_eq!(declared_cargo_minor(None, Some(toml), None), Some(67));
217+
assert_eq!(declared_cargo_minor(None, None, Some("1.56.0\n")), Some(56));
218+
assert_eq!(declared_cargo_minor(None, None, Some(toml)), Some(67));
219+
assert_eq!(declared_cargo_minor(None, None, Some("stable\n")), None);
220+
// rustup prefers the legacy file when both exist.
221+
let stable = "[toolchain]\nchannel = \"stable\"\n";
222+
assert_eq!(
223+
declared_cargo_minor(None, Some(stable), Some("1.50\n")),
224+
Some(50)
225+
);
226+
assert_eq!(
227+
declared_cargo_minor(None, Some(toml), Some("stable\n")),
228+
None
229+
);
230+
// A blank legacy placeholder (an unreadable entry) is no toolchain.
231+
for blank in ["", " \n"] {
232+
assert_eq!(
233+
declared_cargo_minor(None, Some(toml), Some(blank)),
234+
Some(67)
235+
);
236+
}
237+
// `rust-version` wins over the toolchain files.
238+
assert_eq!(
239+
declared_cargo_minor(
240+
Some("[package]\nname = \"a\"\nrust-version = \"1.70\"\n"),
241+
Some(toml),
242+
None
243+
),
244+
Some(70)
245+
);
246+
}
247+
248+
/// A dated nightly or beta pins the cargo of that day's release train.
249+
#[test]
250+
fn dated_channels_map_to_their_release_train() {
251+
let cases = [
252+
("nightly-2022-06-01", Some(63)),
253+
("beta-2022-06-01", Some(62)),
254+
// The day 1.68 shipped: nightly is 1.70, beta 1.69.
255+
("nightly-2023-03-09", Some(70)),
256+
("beta-2023-03-08", Some(68)),
257+
("nightly-2020-07-16", Some(47)),
258+
("nightly-2024-13-01", None),
259+
("nightly", None),
260+
("stable", None),
261+
];
262+
for (channel, want) in cases {
263+
let toml = format!("[toolchain]\nchannel = \"{channel}\"\n");
264+
assert_eq!(
265+
declared_cargo_minor(None, Some(&toml), None),
266+
want,
267+
"{channel}"
268+
);
269+
}
270+
}
271+
272+
#[test]
273+
fn a_member_declares_its_own_or_its_inherited_rust_version() {
274+
let root = "[workspace]\n\n[workspace.package]\nrust-version = \"1.60\"\n";
275+
let member = |rv: &str| format!("[package]\nname = \"m\"\n{rv}\n");
276+
assert_eq!(
277+
member_manifest_minor(&member("rust-version = \"1.64\""), Some(root)),
278+
Some(64)
279+
);
280+
assert_eq!(
281+
member_manifest_minor(&member("rust-version.workspace = true"), Some(root)),
282+
Some(60)
283+
);
284+
assert_eq!(member_manifest_minor(&member(""), Some(root)), None);
285+
assert_eq!(
286+
member_manifest_minor(&member("rust-version.workspace = true"), None),
287+
None
288+
);
289+
}
290+
}

‎crates/socket-patch-core/src/formats/cargo/mod.rs‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,13 @@
2121
//! * the vendored planner (`vendor::cargo_lock`) edits the same document
2222
//! with `toml_edit`;
2323
//!
24-
//! [`manifest`] is the one reader of a `Cargo.toml`'s `[package]` identity.
24+
//! [`manifest`] is the one reader of a `Cargo.toml`'s `[package]` identity,
25+
//! and [`floor`] the one reader of the cargo the project declares it builds
26+
//! on (`rust-version`, `rust-toolchain[.toml]`).
2527
//!
2628
//! Everything here is pure; the callers own the reads.
2729
30+
pub(crate) mod floor;
2831
pub(crate) mod hosted;
2932
pub mod manifest;
3033
pub mod source_replacement;

‎crates/socket-patch-core/src/formats/registry.rs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,11 @@ const REGISTRY: &[FormatFile] = &[
122122
// must see it (it wires the managed registry into whichever one is
123123
// present); vendored wiring before v5 lived there too.
124124
row(".cargo/config", "cargo", HOSTED | VENDORED | PROBE),
125+
// Never edited: the toolchain the project pins is its declared cargo
126+
// floor when `Cargo.toml` has no `rust-version`, which the hosted
127+
// planner checks against the sparse-registry floor (cargo 1.68).
128+
row("rust-toolchain.toml", "cargo", HOSTED | PRESENCE_ONLY),
129+
row("rust-toolchain", "cargo", HOSTED | PRESENCE_ONLY),
125130
// ── composer ──
126131
row("composer.json", "composer", VENDORED),
127132
row(

‎crates/socket-patch-core/src/hosted/engine.rs‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3543,6 +3543,33 @@ mod tests {
35433543
(vec![(purl.to_string(), uuid.to_string())], refs)
35443544
}
35453545

3546+
/// #653: the toolchain files are the cargo planner's declared-floor
3547+
/// input when `Cargo.toml` has no `rust-version`, so they are read
3548+
/// (never edited, and naming no ecosystem to refuse).
3549+
#[tokio::test]
3550+
async fn the_cargo_toolchain_files_are_read_as_advisory_input() {
3551+
let (selected, refs) = cargo_reference("u-1");
3552+
let mut p = MemoryProject::new();
3553+
p.insert_text("Cargo.toml", "[dependencies]\nserde = \"1\"\n");
3554+
p.insert_text("rust-toolchain.toml", "[toolchain]\nchannel = \"1.67.1\"\n");
3555+
p.insert_text("rust-toolchain", "1.56.0\n");
3556+
let mut skipped = Vec::new();
3557+
let candidates = build_candidates(&selected, &refs, &mut skipped);
3558+
let view = ProjectView::Memory(&p);
3559+
let read = read_candidate_files(&view, &BTreeSet::new(), &candidates).await;
3560+
assert_eq!(
3561+
read.files.get("rust-toolchain.toml").map(String::as_str),
3562+
Some("[toolchain]\nchannel = \"1.67.1\"\n")
3563+
);
3564+
assert_eq!(
3565+
read.files.get("rust-toolchain").map(String::as_str),
3566+
Some("1.56.0\n")
3567+
);
3568+
for rel in ["rust-toolchain.toml", "rust-toolchain"] {
3569+
assert_eq!(crate::formats::registry::hosted_file_ecosystem(rel), None);
3570+
}
3571+
}
3572+
35463573
#[tokio::test]
35473574
async fn an_unreadable_candidate_file_refuses_its_ecosystem() {
35483575
let (selected, refs) = cargo_reference("u-1");

0 commit comments

Comments
 (0)