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 @@ -7,6 +7,10 @@ The format is based on [Keep a Changelog].
<!-- next-header -->
## 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.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
144 changes: 132 additions & 12 deletions src/index.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<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,29 +48,44 @@ 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> {
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);
}
Expand Down Expand Up @@ -122,13 +142,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 @@ -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<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 All @@ -209,6 +232,7 @@ impl RemoteIndex {
client,
lock,
etags: Vec::new(),
auth: auth.map(str::to_owned),
})
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<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 || {
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<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}"
);
}
}
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