diff --git a/CHANGELOG.md b/CHANGELOG.md index d29cd3e195..b5136ec61c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/src/bin/upgrade/upgrade.rs b/src/bin/upgrade/upgrade.rs index 02357791d2..9d001cd23e 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 cfbb9eeafd..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 { @@ -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 { - 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,33 +50,49 @@ 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> { - 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()) } } @@ -122,13 +145,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:?}")) } @@ -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, } 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)); @@ -204,6 +230,7 @@ impl RemoteIndex { client, lock, etag_cache: Default::default(), + auth: auth.map(str::to_owned), }) } @@ -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 @@ -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) { + 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 || { + 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 { + 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}" + ); + } + + // 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") + ); + } +} 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..6924a0b931 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,33 +61,69 @@ 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 { - Some(CRATES_IO_INDEX) | None => { + let (head, mut source) = match registry { + Some(CRATES_IO_INDEX | CRATES_IO_REGISTRY) | 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 { - 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 { + 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}"); + } + + // 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); } + 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`"))?; @@ -116,6 +151,98 @@ 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. +/// +/// 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> { + 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". + let names = { + let mut registries = load_registries(manifest_path)?; + let (names, _source) = resolve_registry(&mut registries, registry)?; + names + }; + 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('-', "_") + ); + 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"))?; + 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 +265,281 @@ mod code_from_cargo { DefaultBranch, } } + +#[cfg(test)] +mod tests { + use super::*; + + /// 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-3b0ff8d5-2d5a-4c44-9b11-1b0f5b9e8f3a"; + /// 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) { + 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 { + 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/config.toml", + &format!("[registries.{REGISTRY}]\nindex = \"sparse+https://example.com/\"\n"), + ); + 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 source_replacement_is_followed() { + 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 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"); + 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(&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_an_error() { + let tree = TempTree::new("unknown"); + tree.write( + ".cargo/credentials.toml", + &format!("[registries.{REGISTRY}]\ntoken = \"cred-token\"\n"), + ); + 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 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 + ); + } + + #[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")); + } +}