Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/).

- *(upgrade)* Point single-`--verbose` users to `--verbose --verbose` when more dependencies are hidden

### Fixes

- *(upgrade)* Send the registry auth token so `cargo upgrade` works with private (`auth-required`) registries instead of failing with `401 Unauthorized`

## 0.13.12 - 2026-07-14

### Fixes
Expand Down
7 changes: 4 additions & 3 deletions src/bin/upgrade/upgrade.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(&registry_url, &dependency.name)?;
let auth = registry_token(&manifest_path, dependency.registry())?;
let krate = index.krate(&registry_url, &dependency.name, auth.as_deref())?;
let versions = krate
.as_ref()
.map(|k| k.versions.as_slice())
Expand Down
192 changes: 175 additions & 17 deletions src/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ pub enum CertsSource {

pub struct IndexCache {
certs_source: CertsSource,
index: std::collections::HashMap<Url, AnyIndexCache>,
/// 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<String>), AnyIndexCache>,
}

impl IndexCache {
Expand All @@ -30,8 +32,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<bool> {
self.index(registry)
pub fn has_krate(
&mut self,
registry: &Url,
name: &str,
auth: Option<&str>,
) -> CargoResult<bool> {
self.index(registry, auth)
.with_context(|| format!("failed to look up {name}"))?
.has_krate(name)
}
Expand All @@ -43,33 +50,49 @@ impl IndexCache {
registry: &Url,
name: &str,
version: &str,
auth: Option<&str>,
) -> CargoResult<Option<bool>> {
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<Option<IndexKrate>> {
self.index(registry)
pub fn krate(
&mut self,
registry: &Url,
name: &str,
auth: Option<&str>,
) -> CargoResult<Option<IndexKrate>> {
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> {
if !self.index.contains_key(registry) {
let index = AnyIndex::open(registry, self.certs_source)?;
fn index<'s>(
&'s mut self,
registry: &Url,
auth: Option<&str>,
) -> CargoResult<&'s mut AnyIndexCache> {
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())
}
}

Expand Down Expand Up @@ -122,13 +145,13 @@ enum AnyIndex {
}

impl AnyIndex {
fn open(url: &Url, certs_source: CertsSource) -> CargoResult<Self> {
fn open(url: &Url, certs_source: CertsSource, auth: Option<&str>) -> CargoResult<Self> {
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:?}"))
}
Expand Down Expand Up @@ -173,10 +196,13 @@ struct RemoteIndex {
client: ureq::Agent,
lock: FileLock,
etag_cache: tame_index::EtagCache,
/// Value for the `Authorization` header, sent verbatim (matching cargo) for
/// registries with `auth-required = true`. `None` for unauthenticated registries.
auth: Option<String>,
}

impl RemoteIndex {
fn open(url: &Url, certs_source: CertsSource) -> CargoResult<Self> {
fn open(url: &Url, certs_source: CertsSource, auth: Option<&str>) -> CargoResult<Self> {
log::trace!("opening index entry for {url}");
let url = url.to_string();
let url = tame_index::IndexUrl::NonCratesIo(std::borrow::Cow::Owned(url));
Expand Down Expand Up @@ -204,6 +230,7 @@ impl RemoteIndex {
client,
lock,
etag_cache: Default::default(),
auth: auth.map(str::to_owned),
})
}

Expand All @@ -214,7 +241,18 @@ impl RemoteIndex {
.etag_cache
.make_remote_request(&self.index, krate_name, &self.lock)?;

let (parts, mut body) = self.client.run(req.into())?.into_parts();
let mut req: ureq::http::Request<()> = req.into();
// 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 {
let value = ureq::http::HeaderValue::from_str(auth)
.context("invalid Authorization header value")?;
req.headers_mut()
.insert(ureq::http::header::AUTHORIZATION, value);
}

let (parts, mut body) = self.client.run(req)?.into_parts();
let body = body.read_to_vec().context("failed to read response body")?;

self.etag_cache
Expand All @@ -235,3 +273,123 @@ 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<String>) {
serve(1)
}

/// Like [`serve_once`], but answers `connections` requests, one per connection.
fn serve(connections: usize) -> (String, std::sync::mpsc::Receiver<String>) {
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 || {
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}/`.
(format!("sparse+http://{addr}/"), rx)
}

/// Extract the value of the (case-insensitive) `Authorization` request header.
fn authorization(request: &str) -> Option<String> {
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(&registry, "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(&registry, "somecrate", None).unwrap();

let request = rx.recv().unwrap();
assert_eq!(
authorization(&request),
None,
"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(&registry, "somecrate", None).unwrap();
let _ = cache
.krate(&registry, "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")
);
}
}
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading