From f32168c6a10bb4a95ad220b7bbd00435e514a32f Mon Sep 17 00:00:00 2001 From: Markus Mayer Date: Mon, 13 Jul 2026 19:29:00 +0200 Subject: [PATCH 1/2] 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/2] 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,