From f32168c6a10bb4a95ad220b7bbd00435e514a32f Mon Sep 17 00:00:00 2001 From: Markus Mayer Date: Mon, 13 Jul 2026 19:29:00 +0200 Subject: [PATCH 1/9] fix(upgrade): Authenticate index requests to private registries `cargo upgrade` aborted with `status code '401 Unauthorized': the request was not authorized` whenever a workspace depended on a private registry with `auth-required = true`. `RemoteIndex::krate` builds the sparse-index request via tame-index's `make_remote_request`, which intentionally attaches no credentials, and cargo-edit never read or sent a registry token. The request therefore went out unauthenticated and the registry rejected it with 401. This was always broken for auth-required registries; before 0.13.2 the error was silently swallowed (the dependency was skipped), but #925 made index errors propagate, so the 401 now aborts the whole run. Resolve the registry token (`CARGO_REGISTRIES__TOKEN`, then `token` under `[registries.]` in credentials/config, walking `.cargo/` up to `$CARGO_HOME`) and attach it verbatim as the `Authorization` header, matching cargo. The crates.io index is left unauthenticated so its publish token is not leaked to the CDN-fronted sparse index. Fixes #931 --- CHANGELOG.md | 4 + src/bin/upgrade/upgrade.rs | 7 +- src/index.rs | 144 ++++++++++++++++++++++++++++--- src/lib.rs | 2 +- src/registry.rs | 170 +++++++++++++++++++++++++++++++++++++ 5 files changed, 311 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e892ec367..ca369c2c0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ The format is based on [Keep a Changelog]. ## Unreleased - ReleaseDate +### Fixes + +- *(upgrade)* Send the registry auth token so `cargo upgrade` works with private (`auth-required`) registries instead of failing with `401 Unauthorized` + ## 0.13.11 - 2026-05-28 ## 0.13.10 - 2026-04-17 diff --git a/src/bin/upgrade/upgrade.rs b/src/bin/upgrade/upgrade.rs index 1afdfcf9f7..2bdc633694 100644 --- a/src/bin/upgrade/upgrade.rs +++ b/src/bin/upgrade/upgrade.rs @@ -6,8 +6,8 @@ use std::path::PathBuf; use anyhow::Context as _; use cargo_edit::{ CargoResult, CertsSource, CrateSpec, Dependency, IndexCache, LocalManifest, RustVersion, - Source, find_compatible_version, find_latest_version, registry_url, set_dep_version, - shell_note, shell_status, shell_warn, shell_write_stdout, + Source, find_compatible_version, find_latest_version, registry_token, registry_url, + set_dep_version, shell_note, shell_status, shell_warn, shell_write_stdout, }; use clap::Args; use indexmap::IndexMap; @@ -305,7 +305,8 @@ fn exec(args: UpgradeArgs) -> CargoResult<()> { // Update indices for any alternative registries, unless // we're offline. let registry_url = registry_url(&manifest_path, dependency.registry())?; - let krate = index.krate(®istry_url, &dependency.name)?; + let auth = registry_token(&manifest_path, dependency.registry())?; + let krate = index.krate(®istry_url, &dependency.name, auth.as_deref())?; let versions = krate .as_ref() .map(|k| k.versions.as_slice()) diff --git a/src/index.rs b/src/index.rs index bab4955835..77594be7e0 100644 --- a/src/index.rs +++ b/src/index.rs @@ -30,8 +30,13 @@ impl IndexCache { /// Determines if the specified crate exists in the crates.io index #[inline] - pub fn has_krate(&mut self, registry: &Url, name: &str) -> CargoResult { - self.index(registry) + pub fn has_krate( + &mut self, + registry: &Url, + name: &str, + auth: Option<&str>, + ) -> CargoResult { + self.index(registry, auth) .with_context(|| format!("failed to look up {name}"))? .has_krate(name) } @@ -43,29 +48,44 @@ impl IndexCache { registry: &Url, name: &str, version: &str, + auth: Option<&str>, ) -> CargoResult> { - self.index(registry) + self.index(registry, auth) .with_context(|| format!("failed to look up {name}@{version}"))? .has_krate_version(name, version) } #[inline] - pub fn update_krate(&mut self, registry: &Url, name: &str) -> CargoResult<()> { - self.index(registry) + pub fn update_krate( + &mut self, + registry: &Url, + name: &str, + auth: Option<&str>, + ) -> CargoResult<()> { + self.index(registry, auth) .with_context(|| format!("failed to look up {name}"))? .update_krate(name); Ok(()) } - pub fn krate(&mut self, registry: &Url, name: &str) -> CargoResult> { - self.index(registry) + pub fn krate( + &mut self, + registry: &Url, + name: &str, + auth: Option<&str>, + ) -> CargoResult> { + self.index(registry, auth) .with_context(|| format!("failed to look up {name}"))? .krate(name) } - fn index<'s>(&'s mut self, registry: &Url) -> CargoResult<&'s mut AnyIndexCache> { + fn index<'s>( + &'s mut self, + registry: &Url, + auth: Option<&str>, + ) -> CargoResult<&'s mut AnyIndexCache> { if !self.index.contains_key(registry) { - let index = AnyIndex::open(registry, self.certs_source)?; + let index = AnyIndex::open(registry, self.certs_source, auth)?; let index = AnyIndexCache::new(index); self.index.insert(registry.clone(), index); } @@ -122,13 +142,13 @@ enum AnyIndex { } impl AnyIndex { - fn open(url: &Url, certs_source: CertsSource) -> CargoResult { + fn open(url: &Url, certs_source: CertsSource, auth: Option<&str>) -> CargoResult { if url.scheme() == "file" { LocalIndex::open(url) .map(Self::Local) .with_context(|| format!("invalid local registry {url:?}")) } else { - RemoteIndex::open(url, certs_source) + RemoteIndex::open(url, certs_source, auth) .map(Self::Remote) .with_context(|| format!("invalid registry {url:?}")) } @@ -182,10 +202,13 @@ struct RemoteIndex { client: tame_index::external::reqwest::blocking::Client, lock: FileLock, etags: Vec<(String, String)>, + /// Value for the `Authorization` header, sent verbatim (matching cargo) for + /// registries with `auth-required = true`. `None` for unauthenticated registries. + auth: Option, } impl RemoteIndex { - fn open(url: &Url, certs_source: CertsSource) -> CargoResult { + fn open(url: &Url, certs_source: CertsSource, auth: Option<&str>) -> CargoResult { log::trace!("opening index entry for {url}"); let url = url.to_string(); let url = tame_index::IndexUrl::NonCratesIo(std::borrow::Cow::Owned(url)); @@ -209,6 +232,7 @@ impl RemoteIndex { client, lock, etags: Vec::new(), + auth: auth.map(str::to_owned), }) } @@ -237,6 +261,12 @@ impl RemoteIndex { let mut req = self.client.request(method, uri.to_string()); req = req.version(version); req = req.headers(headers); + // tame_index's `make_remote_request` does not attach credentials; a + // registry with `auth-required = true` rejects the request with 401 + // otherwise. cargo sends the configured token verbatim. + if let Some(auth) = &self.auth { + req = req.header(tame_index::external::reqwest::header::AUTHORIZATION, auth); + } let res = self.client.execute(req.build()?)?; // Grab the etag if it exists for future requests @@ -271,3 +301,93 @@ impl RemoteIndex { .map_err(Into::into) } } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{Read, Write}; + use std::net::TcpListener; + + /// A single valid sparse-index entry line (64-hex `cksum` is required by + /// `IndexKrate::from_slice`). + const INDEX_LINE: &str = concat!( + r#"{"name":"somecrate","vers":"1.0.0","deps":[],"#, + r#""cksum":"0000000000000000000000000000000000000000000000000000000000000000","#, + r#""features":{},"yanked":false}"#, + "\n" + ); + + /// Spawns a one-shot HTTP/1.1 server that captures the raw request and + /// answers with `INDEX_LINE`. Returns the base URL and a receiver for the + /// captured request text. + fn serve_once() -> (String, std::sync::mpsc::Receiver) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let (mut stream, _) = listener.accept().unwrap(); + let mut buf = [0u8; 8192]; + let n = stream.read(&mut buf).unwrap(); + let request = String::from_utf8_lossy(&buf[..n]).into_owned(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: text/plain\r\n\r\n{}", + INDEX_LINE.len(), + INDEX_LINE + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + let _ = tx.send(request); + }); + // tame_index requires the `sparse+` scheme prefix; it is stripped before + // the actual HTTP request is issued to `http://{addr}/`. + (format!("sparse+http://{addr}/"), rx) + } + + /// Extract the value of the (case-insensitive) `Authorization` request header. + fn authorization(request: &str) -> Option { + request + .lines() + .find(|line| line.to_ascii_lowercase().starts_with("authorization:")) + .map(|line| line.split_once(':').unwrap().1.trim().to_owned()) + } + + // Regression test for #931: a request to an auth-required registry must + // carry the configured token verbatim, or the server answers 401. + #[test] + fn sends_authorization_header_when_auth_present() { + let (url, rx) = serve_once(); + let mut cache = IndexCache::new(CertsSource::Webpki); + let registry = Url::parse(&url).unwrap(); + + let krate = cache + .krate(®istry, "somecrate", Some("Bearer test-token")) + .unwrap(); + assert!( + krate.is_some(), + "expected the crate to be parsed from the 200 response" + ); + + let request = rx.recv().unwrap(); + assert_eq!( + authorization(&request).as_deref(), + Some("Bearer test-token"), + "Authorization header missing/wrong in request:\n{request}" + ); + } + + #[test] + fn omits_authorization_header_when_no_auth() { + let (url, rx) = serve_once(); + let mut cache = IndexCache::new(CertsSource::Webpki); + let registry = Url::parse(&url).unwrap(); + + let _ = cache.krate(®istry, "somecrate", None).unwrap(); + + let request = rx.recv().unwrap(); + assert_eq!( + authorization(&request), + None, + "unexpected Authorization header in request:\n{request}" + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 9de1793644..79494897a7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -44,7 +44,7 @@ pub use fetch::{RustVersion, find_compatible_version, find_latest_version}; pub use index::*; pub use manifest::{LocalManifest, Manifest, find, get_dep_version, set_dep_version}; pub use metadata::manifest_from_pkgid; -pub use registry::registry_url; +pub use registry::{registry_token, registry_url}; pub use util::{ Color, ColorChoice, colorize_stderr, shell_note, shell_print, shell_status, shell_warn, shell_write_stderr, shell_write_stdout, diff --git a/src/registry.rs b/src/registry.rs index 4c59cb0ef3..d1a332fbbb 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -116,6 +116,76 @@ struct Source { #[derive(Debug, Deserialize)] struct Registry { index: Option, + #[serde(default)] + token: Option, +} + +/// Find the authentication token for a registry, if one is configured. +/// +/// Sparse registries with `auth-required = true` reject unauthenticated index +/// requests with `401 Unauthorized`. cargo stores the token out-of-band, so we +/// resolve it here and let the caller attach it to the request. +/// +/// Precedence mirrors cargo: the `CARGO_REGISTRIES__TOKEN` environment +/// variable wins, then `token` under `[registries.]` in the `credentials` +/// / `config` files, walking from the manifest directory up to `$CARGO_HOME`. +pub fn registry_token(manifest_path: &Path, registry: Option<&str>) -> CargoResult> { + // The crates.io index does not require authentication, and its publish + // token must not be leaked to the (CDN-fronted) sparse index, so only named + // alternative registries are considered here. + let Some(name) = registry else { + return Ok(None); + }; + + let env_key = format!( + "CARGO_REGISTRIES_{}_TOKEN", + name.to_uppercase().replace('-', "_") + ); + if let Some(token) = std::env::var_os(&env_key) { + let token = token + .into_string() + .map_err(|_err| anyhow::format_err!("`{env_key}` was not valid unicode"))?; + return Ok(Some(token)); + } + + fn read_token(path: impl AsRef, name: &str) -> CargoResult> { + let path = path.as_ref(); + if !path.is_file() { + return Ok(None); + } + let content = std::fs::read_to_string(path)?; + let config = toml::from_str::(&content) + .with_context(|| anyhow::format_err!("invalid cargo config at {}", path.display()))?; + Ok(config + .registries + .get(name) + .and_then(|registry| registry.token.clone())) + } + + // Walk the config hierarchy nearest-first. Within a directory, `credentials` + // takes precedence over `config`, matching cargo. + const CONFIG_FILES: [&str; 4] = ["credentials.toml", "credentials", "config.toml", "config"]; + for work_dir in manifest_path + .parent() + .expect("there must be a parent directory") + .ancestors() + { + let cargo_dir = work_dir.join(".cargo"); + for file in CONFIG_FILES { + if let Some(token) = read_token(cargo_dir.join(file), name)? { + return Ok(Some(token)); + } + } + } + + let cargo_home = home::cargo_home()?; + for file in CONFIG_FILES { + if let Some(token) = read_token(cargo_home.join(file), name)? { + return Ok(Some(token)); + } + } + + Ok(None) } mod code_from_cargo { @@ -138,3 +208,103 @@ mod code_from_cargo { DefaultBranch, } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Registry name unlikely to have a real token in the ambient `$CARGO_HOME`, + /// so the fallthrough to `$CARGO_HOME` never masks the file under test. + const REGISTRY: &str = "cargo-edit-token-test-registry"; + + /// A throwaway `/.cargo/` tree, cleaned up on drop. + struct TempTree { + root: std::path::PathBuf, + } + + impl TempTree { + fn new(tag: &str) -> Self { + let root = std::env::temp_dir().join(format!( + "cargo-edit-registry-test-{}-{tag}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(root.join(".cargo")).unwrap(); + Self { root } + } + + fn write(&self, rel: &str, contents: &str) { + std::fs::write(self.root.join(rel), contents).unwrap(); + } + + fn manifest(&self) -> std::path::PathBuf { + self.root.join("Cargo.toml") + } + } + + impl Drop for TempTree { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.root); + } + } + + #[test] + fn token_from_credentials_toml() { + let tree = TempTree::new("credentials"); + tree.write( + ".cargo/credentials.toml", + &format!("[registries.{REGISTRY}]\ntoken = \"cred-token\"\n"), + ); + let token = registry_token(&tree.manifest(), Some(REGISTRY)).unwrap(); + assert_eq!(token.as_deref(), Some("cred-token")); + } + + #[test] + fn credentials_wins_over_config() { + let tree = TempTree::new("precedence"); + // With only a config file, its token is used. + tree.write( + ".cargo/config.toml", + &format!( + "[registries.{REGISTRY}]\nindex = \"sparse+https://example.com/\"\ntoken = \"config-token\"\n" + ), + ); + assert_eq!( + registry_token(&tree.manifest(), Some(REGISTRY)) + .unwrap() + .as_deref(), + Some("config-token") + ); + // credentials.toml in the same directory takes precedence. + tree.write( + ".cargo/credentials.toml", + &format!("[registries.{REGISTRY}]\ntoken = \"cred-token\"\n"), + ); + assert_eq!( + registry_token(&tree.manifest(), Some(REGISTRY)) + .unwrap() + .as_deref(), + Some("cred-token") + ); + } + + #[test] + fn unknown_registry_is_none() { + let tree = TempTree::new("unknown"); + tree.write( + ".cargo/credentials.toml", + &format!("[registries.{REGISTRY}]\ntoken = \"cred-token\"\n"), + ); + assert_eq!( + registry_token(&tree.manifest(), Some("cargo-edit-absent-registry")).unwrap(), + None + ); + } + + #[test] + fn default_registry_is_none() { + // The crates.io index never requires a token; don't leak the publish token to it. + let tree = TempTree::new("default"); + assert_eq!(registry_token(&tree.manifest(), None).unwrap(), None); + } +} From 7fea1ffdf3343becd83a1c1aed07278daf1b6ee7 Mon Sep 17 00:00:00 2001 From: Markus Mayer Date: Tue, 14 Jul 2026 08:54:27 +0200 Subject: [PATCH 2/9] Use unlikely-to-conflict registry name in test Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/registry.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/registry.rs b/src/registry.rs index d1a332fbbb..1e33d5cee7 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -213,10 +213,9 @@ mod code_from_cargo { mod tests { use super::*; - /// Registry name unlikely to have a real token in the ambient `$CARGO_HOME`, + /// Registry name extremely unlikely to have a real token in the ambient `$CARGO_HOME`, /// so the fallthrough to `$CARGO_HOME` never masks the file under test. - const REGISTRY: &str = "cargo-edit-token-test-registry"; - + const REGISTRY: &str = "cargo-edit-token-test-registry-3b0ff8d5-2d5a-4c44-9b11-1b0f5b9e8f3a"; /// A throwaway `/.cargo/` tree, cleaned up on drop. struct TempTree { root: std::path::PathBuf, From 22e70823f1a40d024037469f6fe265e022058fb9 Mon Sep 17 00:00:00 2001 From: Markus Mayer Date: Fri, 23 Oct 2026 20:04:51 +0200 Subject: [PATCH 3/9] fix(index): Key index cache by registry URL and auth IndexCache was keyed by URL only while RemoteIndex captured the auth token when first opened. Two registries sharing an index URL, or the same URL looked up with and without a token, would reuse whichever client came first: either withholding the token (401) or sending it where none was configured. Including the auth in the key gives each combination its own client. --- src/index.rs | 65 +++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 49 insertions(+), 16 deletions(-) diff --git a/src/index.rs b/src/index.rs index 9506fb10a9..85c392ad89 100644 --- a/src/index.rs +++ b/src/index.rs @@ -16,7 +16,9 @@ pub enum CertsSource { pub struct IndexCache { certs_source: CertsSource, - index: std::collections::HashMap, + /// Keyed by registry URL *and* auth, so a token configured for one registry + /// is never reused for (or withheld from) another that shares its index URL. + index: std::collections::HashMap<(Url, Option), AnyIndexCache>, } impl IndexCache { @@ -84,12 +86,13 @@ impl IndexCache { registry: &Url, auth: Option<&str>, ) -> CargoResult<&'s mut AnyIndexCache> { - if !self.index.contains_key(registry) { + let key = (registry.clone(), auth.map(str::to_owned)); + if !self.index.contains_key(&key) { let index = AnyIndex::open(registry, self.certs_source, auth)?; let index = AnyIndexCache::new(index); - self.index.insert(registry.clone(), index); + self.index.insert(key.clone(), index); } - Ok(self.index.get_mut(registry).unwrap()) + Ok(self.index.get_mut(&key).unwrap()) } } @@ -290,22 +293,29 @@ mod tests { /// answers with `INDEX_LINE`. Returns the base URL and a receiver for the /// captured request text. fn serve_once() -> (String, std::sync::mpsc::Receiver) { + serve(1) + } + + /// Like [`serve_once`], but answers `connections` requests, one per connection. + fn serve(connections: usize) -> (String, std::sync::mpsc::Receiver) { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let addr = listener.local_addr().unwrap(); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { - let (mut stream, _) = listener.accept().unwrap(); - let mut buf = [0_u8; 8192]; - let n = stream.read(&mut buf).unwrap(); - let request = String::from_utf8_lossy(&buf[..n]).into_owned(); - let response = format!( - "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: text/plain\r\n\r\n{}", - INDEX_LINE.len(), - INDEX_LINE - ); - let _ = stream.write_all(response.as_bytes()); - let _ = stream.flush(); - let _ = tx.send(request); + for _ in 0..connections { + let (mut stream, _) = listener.accept().unwrap(); + let mut buf = [0_u8; 8192]; + let n = stream.read(&mut buf).unwrap(); + let request = String::from_utf8_lossy(&buf[..n]).into_owned(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nContent-Type: text/plain\r\n\r\n{}", + INDEX_LINE.len(), + INDEX_LINE + ); + let _ = stream.write_all(response.as_bytes()); + let _ = stream.flush(); + let _ = tx.send(request); + } }); // tame_index requires the `sparse+` scheme prefix; it is stripped before // the actual HTTP request is issued to `http://{addr}/`. @@ -359,4 +369,27 @@ mod tests { "unexpected Authorization header in request:\n{request}" ); } + + // The same index URL looked up without and then with a token must not reuse + // the first (unauthenticated) client, or the second request gets a 401. + #[test] + fn auth_is_part_of_the_cache_key() { + let (url, rx) = serve(2); + let mut cache = IndexCache::new(CertsSource::Webpki); + let registry = Url::parse(&url).unwrap(); + + let _ = cache.krate(®istry, "somecrate", None).unwrap(); + let _ = cache + .krate(®istry, "somecrate", Some("Bearer test-token")) + .unwrap(); + + assert_eq!( + authorization(&rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap()), + None + ); + assert_eq!( + authorization(&rx.recv_timeout(std::time::Duration::from_secs(5)).unwrap()).as_deref(), + Some("Bearer test-token") + ); + } } From 2189d9019033e7d9925d1c1df0e8c3f4ccd797dd Mon Sep 17 00:00:00 2001 From: Mikhail Katychev Date: Fri, 23 Oct 2026 13:03:51 -0500 Subject: [PATCH 4/9] fix: follow replace-with registry authentication --- src/registry.rs | 131 ++++++++++++++++++++++++++++++++++++------------ 1 file changed, 98 insertions(+), 33 deletions(-) diff --git a/src/registry.rs b/src/registry.rs index 1e33d5cee7..e1c8bf2f82 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -6,29 +6,28 @@ use url::Url; const CRATES_IO_INDEX: &str = tame_index::index::sparse::CRATES_IO_HTTP_INDEX; const CRATES_IO_REGISTRY: &str = "crates-io"; -/// Find the URL of a registry -pub fn registry_url(manifest_path: &Path, registry: Option<&str>) -> CargoResult { - // TODO support local registry sources, directory sources, git sources: https://doc.rust-lang.org/cargo/reference/source-replacement.html?highlight=replace-with#source-replacement - fn read_config( - registries: &mut HashMap, - path: impl AsRef, - ) -> CargoResult<()> { - let path = path.as_ref(); - // TODO unit test for source replacement - let content = std::fs::read_to_string(path)?; - let config = toml::from_str::(&content) - .with_context(|| anyhow::format_err!("invalid cargo config at {}", path.display()))?; - for (key, value) in config.registries { - registries.entry(key).or_insert(Source { - registry: value.index, - replace_with: None, - }); - } - for (key, value) in config.source { - registries.entry(key).or_insert(value); - } - Ok(()) +// Read `[registries]` and `[source]` tables from a cargo config file into `registries`. +fn read_config( + registries: &mut HashMap, + path: impl AsRef, +) -> CargoResult<()> { + let path = path.as_ref(); + let content = std::fs::read_to_string(path)?; + let config = toml::from_str::(&content) + .with_context(|| anyhow::format_err!("invalid cargo config at {}", path.display()))?; + for (key, value) in config.registries { + registries.entry(key).or_insert(Source { + registry: value.index, + replace_with: None, + }); } + for (key, value) in config.source { + registries.entry(key).or_insert(value); + } + Ok(()) +} + +fn load_registries(manifest_path: &Path) -> CargoResult> { // registry might be replaced with another source // it's looks like a singly linked list // put relations in this map. @@ -62,24 +61,36 @@ pub fn registry_url(manifest_path: &Path, registry: Option<&str>) -> CargoResult } } + Ok(registries) +} + +// Resolve `registry` through any `replace-with` source replacements, defaults to crates-io. +fn resolve_registry( + registries: &mut HashMap, + registry: Option<&str>, +) -> CargoResult<(Vec, Source)> { // find head of the relevant linked list - let mut source = match registry { + let (head, mut source) = match registry { Some(CRATES_IO_INDEX) | None => { let mut source = registries.remove(CRATES_IO_REGISTRY).unwrap_or_default(); source .registry .get_or_insert_with(|| CRATES_IO_INDEX.to_owned()); - source + (CRATES_IO_REGISTRY.to_owned(), source) + } + Some(r) => { + let source = registries + .remove(r) + .with_context(|| anyhow::format_err!("The registry '{r}' could not be found"))?; + (r.to_owned(), source) } - Some(r) => registries - .remove(r) - .with_context(|| anyhow::format_err!("The registry '{r}' could not be found"))?, }; + let mut names = vec![head]; // search this linked list and find the tail - while let Some(replace_with) = &source.replace_with { + while let Some(replace_with) = source.replace_with.take() { let is_crates_io = replace_with == CRATES_IO_INDEX; - source = registries.remove(replace_with).with_context(|| { + source = registries.remove(&replace_with).with_context(|| { anyhow::format_err!("The source '{replace_with}' could not be found") })?; if is_crates_io { @@ -87,8 +98,18 @@ pub fn registry_url(manifest_path: &Path, registry: Option<&str>) -> CargoResult .registry .get_or_insert_with(|| CRATES_IO_INDEX.to_owned()); } + names.push(replace_with); } + Ok((names, source)) +} + +/// Find the URL of a registry +pub fn registry_url(manifest_path: &Path, registry: Option<&str>) -> CargoResult { + // TODO support local registry sources, directory sources, git sources: https://doc.rust-lang.org/cargo/reference/source-replacement.html?highlight=replace-with#source-replacement + let mut registries = load_registries(manifest_path)?; + let (_names, source) = resolve_registry(&mut registries, registry)?; + let registry_url = source .registry .ok_or_else(|| anyhow::format_err!("missing `registry`"))?; @@ -126,17 +147,35 @@ struct Registry { /// requests with `401 Unauthorized`. cargo stores the token out-of-band, so we /// resolve it here and let the caller attach it to the request. /// +/// The registry name is resolved through `replace-with` source replacement +/// first allowing crates.io to be served by a private registry. +/// /// Precedence mirrors cargo: the `CARGO_REGISTRIES__TOKEN` environment /// variable wins, then `token` under `[registries.]` in the `credentials` /// / `config` files, walking from the manifest directory up to `$CARGO_HOME`. pub fn registry_token(manifest_path: &Path, registry: Option<&str>) -> CargoResult> { - // The crates.io index does not require authentication, and its publish - // token must not be leaked to the (CDN-fronted) sparse index, so only named - // alternative registries are considered here. - let Some(name) = registry else { + // resolve which registry ultimately serves the request: + // `[source.crates-io] replace-with = "my-registry" in that case the token belongs + // to "my-registry", not "crates-io" + let names = { + let mut registries = load_registries(manifest_path)?; + match resolve_registry(&mut registries, registry) { + Ok((names, _source)) => names, + Err(_) => match registry { + Some(CRATES_IO_INDEX) | None => vec![CRATES_IO_REGISTRY.to_owned()], + Some(r) => vec![r.to_owned()], + }, + } + }; + let Some(name) = names.last() else { return Ok(None); }; + // crates.io index does not require auth + if name == CRATES_IO_REGISTRY { + return Ok(None); + } + let env_key = format!( "CARGO_REGISTRIES_{}_TOKEN", name.to_uppercase().replace('-', "_") @@ -287,6 +326,32 @@ mod tests { ); } + #[test] + fn source_replacement_is_followed() { + // crates.io is replaced with a private registry. Both the resolved URL + // and the token must come from the replacement, never from crates.io + // (whose publish token must not leak to the CDN-fronted sparse index). + let tree = TempTree::new("replace"); + tree.write( + ".cargo/config.toml", + &format!( + "[source.crates-io]\nreplace-with = \"{REGISTRY}\"\n\n[registries.{REGISTRY}]\nindex = \"sparse+https://example.com/index/\"\n" + ), + ); + tree.write( + ".cargo/credentials.toml", + &format!( + "[registry]\ntoken = \"crates-io-publish-token\"\n\n[registries.{REGISTRY}]\ntoken = \"replaced-token\"\n" + ), + ); + + let url = registry_url(&tree.manifest(), None).unwrap(); + assert_eq!(url.as_str(), "sparse+https://example.com/index/"); + + let token = registry_token(&tree.manifest(), None).unwrap(); + assert_eq!(token.as_deref(), Some("replaced-token")); + } + #[test] fn unknown_registry_is_none() { let tree = TempTree::new("unknown"); From 0c6822dd5bf0686f061b1b3d4cd3eec74c051972 Mon Sep 17 00:00:00 2001 From: Mikhail Katychev Date: Fri, 23 Oct 2026 13:59:26 -0500 Subject: [PATCH 5/9] fix: detect cycle --- src/registry.rs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/registry.rs b/src/registry.rs index e1c8bf2f82..fc5e0650a2 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -89,6 +89,17 @@ fn resolve_registry( // search this linked list and find the tail while let Some(replace_with) = source.replace_with.take() { + // detect cycle: `a -> b -> a` + if names.contains(&replace_with) { + let chain = names + .iter() + .chain(std::iter::once(&replace_with)) + .cloned() + .collect::>() + .join(" -> "); + anyhow::bail!("cycle detected in source replacement: {chain}"); + } + let is_crates_io = replace_with == CRATES_IO_INDEX; source = registries.remove(&replace_with).with_context(|| { anyhow::format_err!("The source '{replace_with}' could not be found") @@ -352,6 +363,17 @@ mod tests { assert_eq!(token.as_deref(), Some("replaced-token")); } + #[test] + fn source_replacement_cycle_is_detected() { + let tree = TempTree::new("cycle"); + tree.write( + ".cargo/config.toml", + "[source.crates-io]\nreplace-with = \"a\"\n\n[source.a]\nreplace-with = \"b\"\n\n[source.b]\nreplace-with = \"a\"\n", + ); + let err = registry_url(&tree.manifest(), None).unwrap_err(); + assert!(err.to_string().contains("cycle")); + } + #[test] fn unknown_registry_is_none() { let tree = TempTree::new("unknown"); From 627d5d63e86fe38f71438338d21a292f989ecd06 Mon Sep 17 00:00:00 2001 From: Mikhail Katychev Date: Fri, 23 Oct 2026 14:07:51 -0500 Subject: [PATCH 6/9] fix: propagate errs --- src/registry.rs | 67 +++++++++++++++++++++++++++++++++---------------- 1 file changed, 45 insertions(+), 22 deletions(-) diff --git a/src/registry.rs b/src/registry.rs index fc5e0650a2..3759d7487e 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -166,17 +166,12 @@ struct Registry { /// / `config` files, walking from the manifest directory up to `$CARGO_HOME`. pub fn registry_token(manifest_path: &Path, registry: Option<&str>) -> CargoResult> { // resolve which registry ultimately serves the request: - // `[source.crates-io] replace-with = "my-registry" in that case the token belongs - // to "my-registry", not "crates-io" + // `source.crates-io.replace-with = "my-registry"` means the token should be pulled + // from "my-registry", not "crates-io". let names = { let mut registries = load_registries(manifest_path)?; - match resolve_registry(&mut registries, registry) { - Ok((names, _source)) => names, - Err(_) => match registry { - Some(CRATES_IO_INDEX) | None => vec![CRATES_IO_REGISTRY.to_owned()], - Some(r) => vec![r.to_owned()], - }, - } + let (names, _source) = resolve_registry(&mut registries, registry)?; + names }; let Some(name) = names.last() else { return Ok(None); @@ -300,6 +295,12 @@ mod tests { #[test] fn token_from_credentials_toml() { let tree = TempTree::new("credentials"); + // The registry must be defined in `config` (index); only the token + // lives in `credentials`. + tree.write( + ".cargo/config.toml", + &format!("[registries.{REGISTRY}]\nindex = \"sparse+https://example.com/\"\n"), + ); tree.write( ".cargo/credentials.toml", &format!("[registries.{REGISTRY}]\ntoken = \"cred-token\"\n"), @@ -364,33 +365,55 @@ mod tests { } #[test] - fn source_replacement_cycle_is_detected() { - let tree = TempTree::new("cycle"); - tree.write( + fn misconfigured_source_replacement_is_an_error() { + // A `replace-with` cycle (`a -> b -> a`) must be reported as a cycle, + // not as a misleading missing source. + let cycle = TempTree::new("cycle"); + cycle.write( ".cargo/config.toml", "[source.crates-io]\nreplace-with = \"a\"\n\n[source.a]\nreplace-with = \"b\"\n\n[source.b]\nreplace-with = \"a\"\n", ); - let err = registry_url(&tree.manifest(), None).unwrap_err(); - assert!(err.to_string().contains("cycle")); + let err = registry_url(&cycle.manifest(), None).unwrap_err(); + assert!(err.to_string().contains("cycle"), "got: {err}"); + + let missing = TempTree::new("missing"); + missing.write( + ".cargo/config.toml", + "[source.crates-io]\nreplace-with = \"a\"\n", + ); + let token_err = registry_token(&missing.manifest(), None).unwrap_err(); + let url_err = registry_url(&missing.manifest(), None).unwrap_err(); + assert_eq!(token_err.to_string(), url_err.to_string()); + assert!(token_err.to_string().contains("'a'"), "got: {token_err}"); } #[test] - fn unknown_registry_is_none() { + fn unknown_registry_is_an_error() { let tree = TempTree::new("unknown"); tree.write( ".cargo/credentials.toml", &format!("[registries.{REGISTRY}]\ntoken = \"cred-token\"\n"), ); - assert_eq!( - registry_token(&tree.manifest(), Some("cargo-edit-absent-registry")).unwrap(), - None + let err = registry_token(&tree.manifest(), Some("cargo-edit-absent-registry")).unwrap_err(); + assert!( + err.to_string().contains("cargo-edit-absent-registry"), + "expected a missing-registry error, got: {err}" ); } #[test] - fn default_registry_is_none() { - // The crates.io index never requires a token; don't leak the publish token to it. - let tree = TempTree::new("default"); - assert_eq!(registry_token(&tree.manifest(), None).unwrap(), None); + fn unauthenticated_registry_is_none() { + let crates_io = TempTree::new("default"); + assert_eq!(registry_token(&crates_io.manifest(), None).unwrap(), None); + + let tree = TempTree::new("no-token"); + tree.write( + ".cargo/config.toml", + &format!("[registries.{REGISTRY}]\nindex = \"sparse+https://example.com/\"\n"), + ); + assert_eq!( + registry_token(&tree.manifest(), Some(REGISTRY)).unwrap(), + None + ); } } From 11586989148233589e18c7490fb24c12656b6884 Mon Sep 17 00:00:00 2001 From: Mikhail Katychev Date: Fri, 23 Oct 2026 15:35:05 -0500 Subject: [PATCH 7/9] chore: fix comments --- src/registry.rs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/registry.rs b/src/registry.rs index 3759d7487e..1fd8ccf2d6 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -295,8 +295,6 @@ mod tests { #[test] fn token_from_credentials_toml() { let tree = TempTree::new("credentials"); - // The registry must be defined in `config` (index); only the token - // lives in `credentials`. tree.write( ".cargo/config.toml", &format!("[registries.{REGISTRY}]\nindex = \"sparse+https://example.com/\"\n"), @@ -340,9 +338,6 @@ mod tests { #[test] fn source_replacement_is_followed() { - // crates.io is replaced with a private registry. Both the resolved URL - // and the token must come from the replacement, never from crates.io - // (whose publish token must not leak to the CDN-fronted sparse index). let tree = TempTree::new("replace"); tree.write( ".cargo/config.toml", @@ -366,8 +361,6 @@ mod tests { #[test] fn misconfigured_source_replacement_is_an_error() { - // A `replace-with` cycle (`a -> b -> a`) must be reported as a cycle, - // not as a misleading missing source. let cycle = TempTree::new("cycle"); cycle.write( ".cargo/config.toml", From 3dbd34357f4c26cb85238967bccd64a6fc2ba002 Mon Sep 17 00:00:00 2001 From: Markus Mayer Date: Fri, 23 Oct 2026 22:56:51 +0200 Subject: [PATCH 8/9] fix(registry): Resolve replace-with = "crates-io" to the default index The replacement loop compared the source name against the crates.io index URL instead of the "crates-io" registry name, so the default index was never filled in. Because crates-io is built in and rarely declared in config, a chain ending at it failed with "The source 'crates-io' could not be found". Treat it as built in, as the head of the chain already is. --- src/registry.rs | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/src/registry.rs b/src/registry.rs index 1fd8ccf2d6..ec57b04c99 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -100,15 +100,18 @@ fn resolve_registry( anyhow::bail!("cycle detected in source replacement: {chain}"); } - let is_crates_io = replace_with == CRATES_IO_INDEX; - source = registries.remove(&replace_with).with_context(|| { - anyhow::format_err!("The source '{replace_with}' could not be found") - })?; - if is_crates_io { + // crates-io is built in, so it need not be declared in any config + source = if replace_with == CRATES_IO_REGISTRY { + let mut source = registries.remove(&replace_with).unwrap_or_default(); source .registry .get_or_insert_with(|| CRATES_IO_INDEX.to_owned()); - } + source + } else { + registries.remove(&replace_with).with_context(|| { + anyhow::format_err!("The source '{replace_with}' could not be found") + })? + }; names.push(replace_with); } @@ -359,6 +362,26 @@ mod tests { assert_eq!(token.as_deref(), Some("replaced-token")); } + #[test] + fn replacement_with_crates_io_uses_default_index() { + let tree = TempTree::new("to-crates-io"); + tree.write( + ".cargo/config.toml", + "[source.mirror]\nreplace-with = \"crates-io\"\n", + ); + tree.write( + ".cargo/credentials.toml", + "[registry]\ntoken = \"crates-io-publish-token\"\n", + ); + + let url = registry_url(&tree.manifest(), Some("mirror")).unwrap(); + assert_eq!(url.as_str(), CRATES_IO_INDEX); + + // The crates.io publish token must not be sent to the index. + let token = registry_token(&tree.manifest(), Some("mirror")).unwrap(); + assert_eq!(token, None); + } + #[test] fn misconfigured_source_replacement_is_an_error() { let cycle = TempTree::new("cycle"); From c6e2cbe29f35daffcfeba7b276fb9be65ef38d07 Mon Sep 17 00:00:00 2001 From: Markus Mayer Date: Fri, 23 Oct 2026 23:05:32 +0200 Subject: [PATCH 9/9] fix(registry): Treat an explicit "crates-io" registry as built in A dependency with `registry = "crates-io"` went through the named-registry lookup and failed with "The registry 'crates-io' could not be found" unless some config declared it. It now resolves to the default index like an unnamed dependency, and no token is sent to it. Also cover the token and source-replacement paths the auth support relies on: the `CARGO_REGISTRIES__TOKEN` override (including after replacement), multi-hop `replace-with` chains, and nearest-config-wins for both URL and token. `registry_token` takes the environment lookup through a private helper so these tests need no `unsafe` `set_var`. --- src/registry.rs | 116 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 113 insertions(+), 3 deletions(-) diff --git a/src/registry.rs b/src/registry.rs index ec57b04c99..6924a0b931 100644 --- a/src/registry.rs +++ b/src/registry.rs @@ -71,7 +71,7 @@ fn resolve_registry( ) -> CargoResult<(Vec, Source)> { // find head of the relevant linked list let (head, mut source) = match registry { - Some(CRATES_IO_INDEX) | None => { + Some(CRATES_IO_INDEX | CRATES_IO_REGISTRY) | None => { let mut source = registries.remove(CRATES_IO_REGISTRY).unwrap_or_default(); source .registry @@ -168,6 +168,15 @@ struct Registry { /// variable wins, then `token` under `[registries.]` in the `credentials` /// / `config` files, walking from the manifest directory up to `$CARGO_HOME`. pub fn registry_token(manifest_path: &Path, registry: Option<&str>) -> CargoResult> { + registry_token_with_env(manifest_path, registry, |key| std::env::var_os(key)) +} + +// `registry_token` with the environment lookup injected, so tests need not mutate process state. +fn registry_token_with_env( + manifest_path: &Path, + registry: Option<&str>, + env_var: impl Fn(&str) -> Option, +) -> CargoResult> { // resolve which registry ultimately serves the request: // `source.crates-io.replace-with = "my-registry"` means the token should be pulled // from "my-registry", not "crates-io". @@ -189,7 +198,7 @@ pub fn registry_token(manifest_path: &Path, registry: Option<&str>) -> CargoResu "CARGO_REGISTRIES_{}_TOKEN", name.to_uppercase().replace('-', "_") ); - if let Some(token) = std::env::var_os(&env_key) { + if let Some(token) = env_var(&env_key) { let token = token .into_string() .map_err(|_err| anyhow::format_err!("`{env_key}` was not valid unicode"))?; @@ -281,7 +290,9 @@ mod tests { } fn write(&self, rel: &str, contents: &str) { - std::fs::write(self.root.join(rel), contents).unwrap(); + let path = self.root.join(rel); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, contents).unwrap(); } fn manifest(&self) -> std::path::PathBuf { @@ -432,4 +443,103 @@ mod tests { None ); } + + #[test] + fn explicit_crates_io_is_builtin() { + let tree = TempTree::new("explicit-crates-io"); + tree.write( + ".cargo/credentials.toml", + "[registry]\ntoken = \"crates-io-publish-token\"\n", + ); + + let url = registry_url(&tree.manifest(), Some(CRATES_IO_REGISTRY)).unwrap(); + assert_eq!(url.as_str(), CRATES_IO_INDEX); + + let token = registry_token(&tree.manifest(), Some(CRATES_IO_REGISTRY)).unwrap(); + assert_eq!(token, None); + } + + #[test] + fn env_token_wins_over_files() { + let tree = TempTree::new("env"); + tree.write( + ".cargo/config.toml", + "[registries.my-registry]\nindex = \"sparse+https://example.com/\"\ntoken = \"config-token\"\n", + ); + tree.write( + ".cargo/credentials.toml", + "[registries.my-registry]\ntoken = \"cred-token\"\n", + ); + + // Dashes in the registry name become underscores in the variable name. + let env = + |key: &str| (key == "CARGO_REGISTRIES_MY_REGISTRY_TOKEN").then(|| "env-token".into()); + let token = registry_token_with_env(&tree.manifest(), Some("my-registry"), env).unwrap(); + assert_eq!(token.as_deref(), Some("env-token")); + } + + #[test] + fn env_token_follows_source_replacement() { + let tree = TempTree::new("env-replace"); + tree.write( + ".cargo/config.toml", + "[source.crates-io]\nreplace-with = \"my-registry\"\n\n[registries.my-registry]\nindex = \"sparse+https://example.com/\"\n", + ); + + // The token is keyed by the registry that serves the index, never by crates-io. + let env = |key: &str| match key { + "CARGO_REGISTRIES_MY_REGISTRY_TOKEN" => Some("replaced-token".into()), + "CARGO_REGISTRIES_CRATES_IO_TOKEN" => Some("crates-io-token".into()), + _ => None, + }; + let token = registry_token_with_env(&tree.manifest(), None, env).unwrap(); + assert_eq!(token.as_deref(), Some("replaced-token")); + } + + #[test] + fn multi_hop_replacement_uses_tail() { + let tree = TempTree::new("multi-hop"); + tree.write( + ".cargo/config.toml", + &format!( + "[source.crates-io]\nreplace-with = \"middle\"\n\n[source.middle]\nreplace-with = \"{REGISTRY}\"\n\n[registries.{REGISTRY}]\nindex = \"sparse+https://example.com/tail/\"\n" + ), + ); + tree.write( + ".cargo/credentials.toml", + &format!( + "[registries.middle]\ntoken = \"middle-token\"\n\n[registries.{REGISTRY}]\ntoken = \"tail-token\"\n" + ), + ); + + let url = registry_url(&tree.manifest(), None).unwrap(); + assert_eq!(url.as_str(), "sparse+https://example.com/tail/"); + + let token = registry_token(&tree.manifest(), None).unwrap(); + assert_eq!(token.as_deref(), Some("tail-token")); + } + + #[test] + fn nearest_config_wins() { + let tree = TempTree::new("nearest"); + tree.write( + ".cargo/config.toml", + &format!( + "[registries.{REGISTRY}]\nindex = \"sparse+https://example.com/parent/\"\ntoken = \"parent-token\"\n" + ), + ); + tree.write( + "member/.cargo/config.toml", + &format!( + "[registries.{REGISTRY}]\nindex = \"sparse+https://example.com/member/\"\ntoken = \"member-token\"\n" + ), + ); + let manifest = tree.root.join("member/Cargo.toml"); + + let url = registry_url(&manifest, Some(REGISTRY)).unwrap(); + assert_eq!(url.as_str(), "sparse+https://example.com/member/"); + + let token = registry_token(&manifest, Some(REGISTRY)).unwrap(); + assert_eq!(token.as_deref(), Some("member-token")); + } }