diff --git a/Cargo.lock b/Cargo.lock index 8c0eeafb220..b92088f6d1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -273,6 +273,10 @@ dependencies = [ "anyhow", "base64", "bytesize", + "cargo-credential", + "cargo-credential-1password", + "cargo-credential-macos-keychain", + "cargo-credential-wincred", "cargo-platform 0.1.4", "cargo-test-macro", "cargo-test-support", @@ -340,11 +344,16 @@ dependencies = [ [[package]] name = "cargo-credential" -version = "0.2.0" +version = "0.3.0" +dependencies = [ + "serde", + "serde_json", + "time", +] [[package]] name = "cargo-credential-1password" -version = "0.2.0" +version = "0.3.0" dependencies = [ "cargo-credential", "serde", @@ -353,7 +362,7 @@ dependencies = [ [[package]] name = "cargo-credential-gnome-secret" -version = "0.2.0" +version = "0.3.0" dependencies = [ "cargo-credential", "pkg-config", @@ -361,7 +370,7 @@ dependencies = [ [[package]] name = "cargo-credential-macos-keychain" -version = "0.2.0" +version = "0.3.0" dependencies = [ "cargo-credential", "security-framework", @@ -369,7 +378,7 @@ dependencies = [ [[package]] name = "cargo-credential-wincred" -version = "0.2.0" +version = "0.3.0" dependencies = [ "cargo-credential", "windows-sys 0.48.0", diff --git a/Cargo.toml b/Cargo.toml index 8cdb6404f47..1ee07b17e37 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,10 @@ anyhow = "1.0.47" base64 = "0.21.0" bytesize = "1.0" cargo = { path = "" } -cargo-credential = { version = "0.2.0", path = "credential/cargo-credential" } +cargo-credential = { version = "0.3.0", path = "credential/cargo-credential" } +cargo-credential-1password = { version = "0.3.0", path = "credential/cargo-credential-1password" } +cargo-credential-wincred = { version = "0.3.0", path = "credential/cargo-credential-wincred" } +cargo-credential-macos-keychain = { version = "0.3.0", path = "credential/cargo-credential-macos-keychain" } cargo-platform = { path = "crates/cargo-platform", version = "0.1.4" } cargo-test-macro = { path = "crates/cargo-test-macro" } cargo-test-support = { path = "crates/cargo-test-support" } @@ -88,7 +91,7 @@ tar = { version = "0.4.38", default-features = false } tempfile = "3.1.0" termcolor = "1.1.2" thiserror = "1.0.40" -time = { version = "0.3", features = ["parsing", "formatting"] } +time = { version = "0.3", features = ["parsing", "formatting", "serde"] } toml = "0.7.0" toml_edit = "0.19.0" unicode-width = "0.1.5" @@ -119,6 +122,10 @@ anyhow.workspace = true base64.workspace = true bytesize.workspace = true cargo-platform.workspace = true +cargo-credential.workspace = true +cargo-credential-1password.workspace = true +cargo-credential-macos-keychain.workspace = true +cargo-credential-wincred.workspace = true cargo-util.workspace = true clap = { workspace = true, features = ["wrap_help"] } crates-io.workspace = true diff --git a/crates/cargo-test-support/src/compare.rs b/crates/cargo-test-support/src/compare.rs index 96ce52afcd7..21eb64d2809 100644 --- a/crates/cargo-test-support/src/compare.rs +++ b/crates/cargo-test-support/src/compare.rs @@ -192,6 +192,7 @@ fn substitute_macros(input: &str) -> String { ("[CHECKING]", " Checking"), ("[COMPLETED]", " Completed"), ("[CREATED]", " Created"), + ("[CREDENTIAL]", " Credential"), ("[DOWNGRADING]", " Downgrading"), ("[FINISHED]", " Finished"), ("[ERROR]", "error:"), diff --git a/crates/cargo-test-support/src/registry.rs b/crates/cargo-test-support/src/registry.rs index 910f95bfa57..27c31965657 100644 --- a/crates/cargo-test-support/src/registry.rs +++ b/crates/cargo-test-support/src/registry.rs @@ -104,6 +104,8 @@ pub struct RegistryBuilder { not_found_handler: RequestCallback, /// If nonzero, the git index update to be delayed by the given number of seconds. delayed_index_update: usize, + /// Credential provider in configuration + credential_provider: Option, } pub struct TestRegistry { @@ -172,6 +174,7 @@ impl RegistryBuilder { custom_responders: HashMap::new(), not_found_handler: Box::new(not_found), delayed_index_update: 0, + credential_provider: None, } } @@ -266,6 +269,13 @@ impl RegistryBuilder { self } + /// The credential provider to configure for this registry. + #[must_use] + pub fn credential_provider(mut self, provider: &[&str]) -> Self { + self.credential_provider = Some(format!("['{}']", provider.join("','"))); + self + } + /// Initializes the registry. #[must_use] pub fn build(self) -> TestRegistry { @@ -336,6 +346,18 @@ impl RegistryBuilder { .as_bytes(), ) .unwrap(); + if let Some(p) = &self.credential_provider { + append( + &config_path, + &format!( + " + credential-provider = {p} + " + ) + .as_bytes(), + ) + .unwrap() + } } else { append( &config_path, @@ -351,6 +373,20 @@ impl RegistryBuilder { .as_bytes(), ) .unwrap(); + + if let Some(p) = &self.credential_provider { + append( + &config_path, + &format!( + " + [registry] + credential-provider = {p} + " + ) + .as_bytes(), + ) + .unwrap() + } } } diff --git a/credential/cargo-credential-1password/Cargo.toml b/credential/cargo-credential-1password/Cargo.toml index 652d798e122..a607e6da1ba 100644 --- a/credential/cargo-credential-1password/Cargo.toml +++ b/credential/cargo-credential-1password/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cargo-credential-1password" -version = "0.2.0" +version = "0.3.0" edition.workspace = true license.workspace = true repository = "https://github.com/rust-lang/cargo" diff --git a/credential/cargo-credential-1password/src/main.rs b/credential/cargo-credential-1password/src/lib.rs similarity index 72% rename from credential/cargo-credential-1password/src/main.rs rename to credential/cargo-credential-1password/src/lib.rs index 4f512b717ec..7732dda6da2 100644 --- a/credential/cargo-credential-1password/src/main.rs +++ b/credential/cargo-credential-1password/src/lib.rs @@ -1,6 +1,8 @@ //! Cargo registry 1password credential process. -use cargo_credential::{Credential, Error}; +use cargo_credential::{ + Action, CacheControl, Credential, CredentialResponse, Error, RegistryInfo, Secret, +}; use serde::Deserialize; use std::io::Read; use std::process::{Command, Stdio}; @@ -38,13 +40,13 @@ struct Url { } impl OnePasswordKeychain { - fn new() -> Result { - let mut args = std::env::args().skip(1); + fn new(args: &[&str]) -> Result { + let mut args = args.iter(); let mut action = false; let mut account = None; let mut vault = None; while let Some(arg) = args.next() { - match arg.as_str() { + match *arg { "--account" => { account = Some(args.next().ok_or("--account needs an arg")?); } @@ -63,7 +65,10 @@ impl OnePasswordKeychain { } } } - Ok(OnePasswordKeychain { account, vault }) + Ok(OnePasswordKeychain { + account: account.map(|s| s.to_string()), + vault: vault.map(|s| s.to_string()), + }) } fn signin(&self) -> Result, Error> { @@ -73,9 +78,9 @@ impl OnePasswordKeychain { return Ok(None); } let mut cmd = Command::new("op"); - cmd.args(&["signin", "--raw"]); + cmd.args(["signin", "--raw"]); cmd.stdout(Stdio::piped()); - self.with_tty(&mut cmd)?; + cmd.stdin(cargo_credential::tty()?); let mut child = cmd .spawn() .map_err(|e| format!("failed to spawn `op`: {}", e))?; @@ -121,19 +126,6 @@ impl OnePasswordKeychain { cmd } - fn with_tty(&self, cmd: &mut Command) -> Result<(), Error> { - #[cfg(unix)] - const IN_DEVICE: &str = "/dev/tty"; - #[cfg(windows)] - const IN_DEVICE: &str = "CONIN$"; - let stdin = std::fs::OpenOptions::new() - .read(true) - .write(true) - .open(IN_DEVICE)?; - cmd.stdin(stdin); - Ok(()) - } - fn run_cmd(&self, mut cmd: Command) -> Result { cmd.stdout(Stdio::piped()); let mut child = cmd @@ -196,12 +188,12 @@ impl OnePasswordKeychain { &self, session: &Option, id: &str, - token: &str, + token: Secret<&str>, _name: Option<&str>, ) -> Result<(), Error> { let cmd = self.make_cmd( session, - &["item", "edit", id, &format!("password={}", token)], + &["item", "edit", id, &format!("password={}", token.expose())], ); self.run_cmd(cmd)?; Ok(()) @@ -211,7 +203,7 @@ impl OnePasswordKeychain { &self, session: &Option, index_url: &str, - token: &str, + token: Secret<&str>, name: Option<&str>, ) -> Result<(), Error> { let title = match name { @@ -225,7 +217,7 @@ impl OnePasswordKeychain { "create", "--category", "Login", - &format!("password={}", token), + &format!("password={}", token.expose()), &format!("url={}", index_url), "--title", &title, @@ -236,12 +228,12 @@ impl OnePasswordKeychain { // For unknown reasons, `op item create` seems to not be happy if // stdin is not a tty. Otherwise it returns with a 0 exit code without // doing anything. - self.with_tty(&mut cmd)?; + cmd.stdin(cargo_credential::tty()?); self.run_cmd(cmd)?; Ok(()) } - fn get_token(&self, session: &Option, id: &str) -> Result { + fn get_token(&self, session: &Option, id: &str) -> Result, Error> { let cmd = self.make_cmd(session, &["item", "get", "--format=json", id]); let buffer = self.run_cmd(cmd)?; let item: Login = serde_json::from_str(&buffer) @@ -250,6 +242,7 @@ impl OnePasswordKeychain { match password { Some(password) => password .value + .map(Secret::from) .ok_or_else(|| format!("missing password value for entry").into()), None => Err("could not find password field".into()), } @@ -262,53 +255,54 @@ impl OnePasswordKeychain { } } -impl Credential for OnePasswordKeychain { - fn name(&self) -> &'static str { - env!("CARGO_PKG_NAME") - } +pub struct OnePasswordCredential {} - fn get(&self, index_url: &str) -> Result { - let session = self.signin()?; - if let Some(id) = self.search(&session, index_url)? { - self.get_token(&session, &id) - } else { - return Err(format!( - "no 1password entry found for registry `{}`, try `cargo login` to add a token", - index_url - ) - .into()); - } - } - - fn store(&self, index_url: &str, token: &str, name: Option<&str>) -> Result<(), Error> { - let session = self.signin()?; - // Check if an item already exists. - if let Some(id) = self.search(&session, index_url)? { - self.modify(&session, &id, token, name) - } else { - self.create(&session, index_url, token, name) - } - } - - fn erase(&self, index_url: &str) -> Result<(), Error> { - let session = self.signin()?; - // Check if an item already exists. - if let Some(id) = self.search(&session, index_url)? { - self.delete(&session, &id)?; - } else { - eprintln!("not currently logged in to `{}`", index_url); +impl Credential for OnePasswordCredential { + fn perform( + &self, + registry: &RegistryInfo, + action: &Action, + args: &[&str], + ) -> Result { + let op = OnePasswordKeychain::new(args)?; + match action { + Action::Get(_) => { + let session = op.signin()?; + if let Some(id) = op.search(&session, registry.index_url)? { + op.get_token(&session, &id) + .map(|token| CredentialResponse::Get { + token, + cache: CacheControl::Session, + operation_independent: true, + }) + } else { + Err(Error::NotFound) + } + } + Action::Login(options) => { + let session = op.signin()?; + // Check if an item already exists. + if let Some(id) = op.search(&session, registry.index_url)? { + eprintln!("note: token already exists for `{}`", registry.index_url); + let token = cargo_credential::read_token(options, registry)?; + op.modify(&session, &id, token.as_deref(), None)?; + } else { + let token = cargo_credential::read_token(options, registry)?; + op.create(&session, registry.index_url, token.as_deref(), None)?; + } + Ok(CredentialResponse::Login) + } + Action::Logout => { + let session = op.signin()?; + // Check if an item already exists. + if let Some(id) = op.search(&session, registry.index_url)? { + op.delete(&session, &id)?; + Ok(CredentialResponse::Logout) + } else { + Err(Error::NotFound) + } + } + _ => Err(Error::OperationNotSupported), } - Ok(()) } } - -fn main() { - let op = match OnePasswordKeychain::new() { - Ok(op) => op, - Err(e) => { - eprintln!("error: {}", e); - std::process::exit(1); - } - }; - cargo_credential::main(op); -} diff --git a/credential/cargo-credential-gnome-secret/Cargo.toml b/credential/cargo-credential-gnome-secret/Cargo.toml index 98e89a73107..3916bfc031d 100644 --- a/credential/cargo-credential-gnome-secret/Cargo.toml +++ b/credential/cargo-credential-gnome-secret/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cargo-credential-gnome-secret" -version = "0.2.0" +version = "0.3.0" edition.workspace = true license.workspace = true repository = "https://github.com/rust-lang/cargo" diff --git a/credential/cargo-credential-gnome-secret/src/libsecret.rs b/credential/cargo-credential-gnome-secret/src/libsecret.rs index c584eeecf68..98a66fbd89b 100644 --- a/credential/cargo-credential-gnome-secret/src/libsecret.rs +++ b/credential/cargo-credential-gnome-secret/src/libsecret.rs @@ -1,6 +1,8 @@ //! Implementation of the libsecret credential helper. -use cargo_credential::{Credential, Error}; +use cargo_credential::{ + read_token, Action, CacheControl, Credential, CredentialResponse, Error, RegistryInfo, Secret, +}; use std::ffi::{CStr, CString}; use std::os::raw::{c_char, c_int}; use std::ptr::{null, null_mut}; @@ -97,94 +99,108 @@ fn schema() -> SecretSchema { } impl Credential for GnomeSecret { - fn name(&self) -> &'static str { - env!("CARGO_PKG_NAME") - } - - fn get(&self, index_url: &str) -> Result { - let mut error: *mut GError = null_mut(); - let attr_url = CString::new("url").unwrap(); - let index_url_c = CString::new(index_url).unwrap(); - let schema = schema(); - unsafe { - let token_c = secret_password_lookup_sync( - &schema, - null_mut(), - &mut error, - attr_url.as_ptr(), - index_url_c.as_ptr(), - null() as *const gchar, - ); - if !error.is_null() { - return Err(format!( - "failed to get token: {}", - CStr::from_ptr((*error).message).to_str()? - ) - .into()); + fn perform( + &self, + registry: &RegistryInfo, + action: &Action, + _args: &[&str], + ) -> Result { + let index_url_c = CString::new(registry.index_url).unwrap(); + match action { + cargo_credential::Action::Get(_) => { + let mut error: *mut GError = null_mut(); + let attr_url = CString::new("url").unwrap(); + let schema = schema(); + unsafe { + let token_c = secret_password_lookup_sync( + &schema, + null_mut(), + &mut error, + attr_url.as_ptr(), + index_url_c.as_ptr(), + null() as *const gchar, + ); + if !error.is_null() { + return Err(format!( + "failed to get token: {}", + CStr::from_ptr((*error).message) + .to_str() + .unwrap_or_default() + ) + .into()); + } + if token_c.is_null() { + return Err(Error::NotFound); + } + let token = Secret::from( + CStr::from_ptr(token_c) + .to_str() + .map_err(|e| format!("expected utf8 token: {}", e))? + .to_string(), + ); + Ok(CredentialResponse::Get { + token, + cache: CacheControl::Session, + operation_independent: true, + }) + } } - if token_c.is_null() { - return Err(format!("cannot find token for {}", index_url).into()); + cargo_credential::Action::Login(options) => { + let label = label(registry.name.unwrap_or(registry.index_url)); + let token = CString::new(read_token(options, registry)?.expose()).unwrap(); + let mut error: *mut GError = null_mut(); + let attr_url = CString::new("url").unwrap(); + let schema = schema(); + unsafe { + secret_password_store_sync( + &schema, + b"default\0".as_ptr() as *const gchar, + label.as_ptr(), + token.as_ptr(), + null_mut(), + &mut error, + attr_url.as_ptr(), + index_url_c.as_ptr(), + null() as *const gchar, + ); + if !error.is_null() { + return Err(format!( + "failed to store token: {}", + CStr::from_ptr((*error).message) + .to_str() + .unwrap_or_default() + ) + .into()); + } + } + Ok(CredentialResponse::Login) } - let token = CStr::from_ptr(token_c) - .to_str() - .map_err(|e| format!("expected utf8 token: {}", e))? - .to_string(); - Ok(token) - } - } - - fn store(&self, index_url: &str, token: &str, name: Option<&str>) -> Result<(), Error> { - let label = label(name.unwrap_or(index_url)); - let token = CString::new(token).unwrap(); - let mut error: *mut GError = null_mut(); - let attr_url = CString::new("url").unwrap(); - let index_url_c = CString::new(index_url).unwrap(); - let schema = schema(); - unsafe { - secret_password_store_sync( - &schema, - b"default\0".as_ptr() as *const gchar, - label.as_ptr(), - token.as_ptr(), - null_mut(), - &mut error, - attr_url.as_ptr(), - index_url_c.as_ptr(), - null() as *const gchar, - ); - if !error.is_null() { - return Err(format!( - "failed to store token: {}", - CStr::from_ptr((*error).message).to_str()? - ) - .into()); - } - } - Ok(()) - } - - fn erase(&self, index_url: &str) -> Result<(), Error> { - let schema = schema(); - let mut error: *mut GError = null_mut(); - let attr_url = CString::new("url").unwrap(); - let index_url_c = CString::new(index_url).unwrap(); - unsafe { - secret_password_clear_sync( - &schema, - null_mut(), - &mut error, - attr_url.as_ptr(), - index_url_c.as_ptr(), - null() as *const gchar, - ); - if !error.is_null() { - return Err(format!( - "failed to erase token: {}", - CStr::from_ptr((*error).message).to_str()? - ) - .into()); + cargo_credential::Action::Logout => { + let schema = schema(); + let mut error: *mut GError = null_mut(); + let attr_url = CString::new("url").unwrap(); + unsafe { + secret_password_clear_sync( + &schema, + null_mut(), + &mut error, + attr_url.as_ptr(), + index_url_c.as_ptr(), + null() as *const gchar, + ); + if !error.is_null() { + return Err(format!( + "failed to erase token: {}", + CStr::from_ptr((*error).message) + .to_str() + .unwrap_or_default() + ) + .into()); + } + } + Ok(CredentialResponse::Logout) } + _ => Err(Error::OperationNotSupported), } - Ok(()) } } diff --git a/credential/cargo-credential-macos-keychain/Cargo.toml b/credential/cargo-credential-macos-keychain/Cargo.toml index c52e73cb278..342c771b5e6 100644 --- a/credential/cargo-credential-macos-keychain/Cargo.toml +++ b/credential/cargo-credential-macos-keychain/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cargo-credential-macos-keychain" -version = "0.2.0" +version = "0.3.0" edition.workspace = true license.workspace = true repository = "https://github.com/rust-lang/cargo" diff --git a/credential/cargo-credential-macos-keychain/src/lib.rs b/credential/cargo-credential-macos-keychain/src/lib.rs new file mode 100644 index 00000000000..d7d9da164d3 --- /dev/null +++ b/credential/cargo-credential-macos-keychain/src/lib.rs @@ -0,0 +1,87 @@ +//! Cargo registry macos keychain credential process. + +#[cfg(target_os = "macos")] +mod macos { + use cargo_credential::{ + read_token, Action, CacheControl, Credential, CredentialResponse, Error, RegistryInfo, + }; + use security_framework::os::macos::keychain::SecKeychain; + + pub struct MacKeychain; + + /// The account name is not used. + const ACCOUNT: &'static str = ""; + const NOT_FOUND: i32 = -25300; // errSecItemNotFound + + fn registry(index_url: &str) -> String { + format!("cargo-registry:{}", index_url) + } + + fn to_credential_error(e: security_framework::base::Error) -> Error { + Error::Other(format!("security framework ({}): {e}", e.code())) + } + + impl Credential for MacKeychain { + fn perform( + &self, + reg: &RegistryInfo<'_>, + action: &Action<'_>, + _args: &[&str], + ) -> Result { + let keychain = SecKeychain::default().unwrap(); + let service_name = registry(reg.index_url); + let not_found = security_framework::base::Error::from(NOT_FOUND).code(); + match action { + Action::Get(_) => match keychain.find_generic_password(&service_name, ACCOUNT) { + Err(e) if e.code() == not_found => Err(Error::NotFound), + Err(e) => Err(to_credential_error(e)), + Ok((pass, _)) => { + let token = String::from_utf8(pass.as_ref().to_vec()).map_err(|_| { + Error::Other("failed to convert token to UTF8".to_string()) + })?; + Ok(CredentialResponse::Get { + token: token.into(), + cache: CacheControl::Session, + operation_independent: true, + }) + } + }, + Action::Login(options) => { + let token = read_token(options, reg)?; + match keychain.find_generic_password(&service_name, ACCOUNT) { + Err(e) => { + if e.code() == not_found { + keychain + .add_generic_password( + &service_name, + ACCOUNT, + token.expose().as_bytes(), + ) + .map_err(to_credential_error)?; + } + } + Ok((_, mut item)) => { + item.set_password(token.expose().as_bytes()) + .map_err(to_credential_error)?; + } + } + Ok(CredentialResponse::Login) + } + Action::Logout => match keychain.find_generic_password(&service_name, ACCOUNT) { + Err(e) if e.code() == not_found => Err(Error::NotFound), + Err(e) => Err(to_credential_error(e)), + Ok((_, item)) => { + item.delete(); + Ok(CredentialResponse::Logout) + } + }, + _ => Err(Error::OperationNotSupported), + } + } + } +} + +#[cfg(not(target_os = "macos"))] +pub use cargo_credential::UnsupportedCredential as MacKeychain; +#[cfg(target_os = "macos")] +pub use macos::MacKeychain; diff --git a/credential/cargo-credential-macos-keychain/src/main.rs b/credential/cargo-credential-macos-keychain/src/main.rs deleted file mode 100644 index 4d6ea96d05b..00000000000 --- a/credential/cargo-credential-macos-keychain/src/main.rs +++ /dev/null @@ -1,58 +0,0 @@ -//! Cargo registry macos keychain credential process. - -#[cfg(target_os = "macos")] -mod macos { - use cargo_credential::{Credential, Error}; - use security_framework::os::macos::keychain::SecKeychain; - - pub(crate) struct MacKeychain; - - /// The account name is not used. - const ACCOUNT: &'static str = ""; - - fn registry(registry_name: &str) -> String { - format!("cargo-registry:{}", registry_name) - } - - impl Credential for MacKeychain { - fn name(&self) -> &'static str { - env!("CARGO_PKG_NAME") - } - - fn get(&self, index_url: &str) -> Result { - let keychain = SecKeychain::default().unwrap(); - let service_name = registry(index_url); - let (pass, _item) = keychain.find_generic_password(&service_name, ACCOUNT)?; - String::from_utf8(pass.as_ref().to_vec()) - .map_err(|_| "failed to convert token to UTF8".into()) - } - - fn store(&self, index_url: &str, token: &str, name: Option<&str>) -> Result<(), Error> { - let keychain = SecKeychain::default().unwrap(); - let service_name = registry(name.unwrap_or(index_url)); - if let Ok((_pass, mut item)) = keychain.find_generic_password(&service_name, ACCOUNT) { - item.set_password(token.as_bytes())?; - } else { - keychain.add_generic_password(&service_name, ACCOUNT, token.as_bytes())?; - } - Ok(()) - } - - fn erase(&self, index_url: &str) -> Result<(), Error> { - let keychain = SecKeychain::default().unwrap(); - let service_name = registry(index_url); - let (_pass, item) = keychain.find_generic_password(&service_name, ACCOUNT)?; - item.delete(); - Ok(()) - } - } -} - -#[cfg(not(target_os = "macos"))] -use cargo_credential::UnsupportedCredential as MacKeychain; -#[cfg(target_os = "macos")] -use macos::MacKeychain; - -fn main() { - cargo_credential::main(MacKeychain); -} diff --git a/credential/cargo-credential-wincred/Cargo.toml b/credential/cargo-credential-wincred/Cargo.toml index e68f230d851..8c609dc4ef4 100644 --- a/credential/cargo-credential-wincred/Cargo.toml +++ b/credential/cargo-credential-wincred/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "cargo-credential-wincred" -version = "0.2.0" +version = "0.3.0" edition.workspace = true license.workspace = true repository = "https://github.com/rust-lang/cargo" diff --git a/credential/cargo-credential-wincred/src/lib.rs b/credential/cargo-credential-wincred/src/lib.rs new file mode 100644 index 00000000000..3d67e4613cd --- /dev/null +++ b/credential/cargo-credential-wincred/src/lib.rs @@ -0,0 +1,128 @@ +//! Cargo registry windows credential process. + +#[cfg(windows)] +mod win { + use cargo_credential::{read_token, Action, CacheControl, CredentialResponse, RegistryInfo}; + use cargo_credential::{Credential, Error}; + use std::ffi::OsStr; + + use std::os::windows::ffi::OsStrExt; + + use windows_sys::core::PWSTR; + use windows_sys::Win32::Foundation::ERROR_NOT_FOUND; + use windows_sys::Win32::Foundation::FILETIME; + use windows_sys::Win32::Foundation::TRUE; + use windows_sys::Win32::Security::Credentials::CredReadW; + use windows_sys::Win32::Security::Credentials::CredWriteW; + use windows_sys::Win32::Security::Credentials::CREDENTIALW; + use windows_sys::Win32::Security::Credentials::CRED_PERSIST_LOCAL_MACHINE; + use windows_sys::Win32::Security::Credentials::CRED_TYPE_GENERIC; + use windows_sys::Win32::Security::Credentials::{CredDeleteW, CredFree}; + + pub struct WindowsCredential; + + /// Converts a string to a nul-terminated wide UTF-16 byte sequence. + fn wstr(s: &str) -> Vec { + let mut wide: Vec = OsStr::new(s).encode_wide().collect(); + if wide.iter().any(|b| *b == 0) { + panic!("nul byte in wide string"); + } + wide.push(0); + wide + } + + fn target_name(index_url: &str) -> Vec { + wstr(&format!("cargo-registry:{}", index_url)) + } + + impl Credential for WindowsCredential { + fn perform( + &self, + registry: &RegistryInfo, + action: &Action, + _args: &[&str], + ) -> Result { + match action { + Action::Get(_) => { + let target_name = target_name(registry.index_url); + let mut p_credential: *mut CREDENTIALW = std::ptr::null_mut() as *mut _; + let bytes = unsafe { + if CredReadW( + target_name.as_ptr(), + CRED_TYPE_GENERIC, + 0, + &mut p_credential as *mut _, + ) != TRUE + { + let err = std::io::Error::last_os_error(); + if err.raw_os_error() == Some(ERROR_NOT_FOUND as i32) { + return Err(Error::NotFound); + } + return Err(err.into()); + } + std::slice::from_raw_parts( + (*p_credential).CredentialBlob, + (*p_credential).CredentialBlobSize as usize, + ) + }; + let result = match String::from_utf8(bytes.to_vec()) { + Err(_) => Err("failed to convert token to UTF8".into()), + Ok(token) => Ok(CredentialResponse::Get { + token: token.into(), + cache: CacheControl::Session, + operation_independent: true, + }), + }; + let _ = unsafe { CredFree(p_credential as *mut _) }; + result + } + Action::Login(options) => { + let token = read_token(options, registry)?.expose(); + let target_name = target_name(registry.index_url); + let comment = wstr("Cargo registry token"); + let credential = CREDENTIALW { + Flags: 0, + Type: CRED_TYPE_GENERIC, + TargetName: target_name.as_ptr() as PWSTR, + Comment: comment.as_ptr() as PWSTR, + LastWritten: FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }, + CredentialBlobSize: token.len() as u32, + CredentialBlob: token.as_bytes().as_ptr() as *mut u8, + Persist: CRED_PERSIST_LOCAL_MACHINE, + AttributeCount: 0, + Attributes: std::ptr::null_mut(), + TargetAlias: std::ptr::null_mut(), + UserName: std::ptr::null_mut(), + }; + let result = unsafe { CredWriteW(&credential, 0) }; + if result != TRUE { + let err = std::io::Error::last_os_error(); + return Err(err.into()); + } + Ok(CredentialResponse::Login) + } + Action::Logout => { + let target_name = target_name(registry.index_url); + let result = unsafe { CredDeleteW(target_name.as_ptr(), CRED_TYPE_GENERIC, 0) }; + if result != TRUE { + let err = std::io::Error::last_os_error(); + if err.raw_os_error() == Some(ERROR_NOT_FOUND as i32) { + return Err(Error::NotFound); + } + return Err(err.into()); + } + Ok(CredentialResponse::Logout) + } + _ => Err(Error::OperationNotSupported), + } + } + } +} + +#[cfg(not(windows))] +pub use cargo_credential::UnsupportedCredential as WindowsCredential; +#[cfg(windows)] +pub use win::WindowsCredential; diff --git a/credential/cargo-credential-wincred/src/main.rs b/credential/cargo-credential-wincred/src/main.rs deleted file mode 100644 index 4377172e8f7..00000000000 --- a/credential/cargo-credential-wincred/src/main.rs +++ /dev/null @@ -1,122 +0,0 @@ -//! Cargo registry windows credential process. - -#[cfg(windows)] -mod win { - use cargo_credential::{Credential, Error}; - use std::ffi::OsStr; - use std::os::windows::ffi::OsStrExt; - - use windows_sys::core::PWSTR; - use windows_sys::Win32::Foundation::ERROR_NOT_FOUND; - use windows_sys::Win32::Foundation::FILETIME; - use windows_sys::Win32::Foundation::TRUE; - use windows_sys::Win32::Security::Credentials::CredDeleteW; - use windows_sys::Win32::Security::Credentials::CredReadW; - use windows_sys::Win32::Security::Credentials::CredWriteW; - use windows_sys::Win32::Security::Credentials::CREDENTIALW; - use windows_sys::Win32::Security::Credentials::CRED_PERSIST_LOCAL_MACHINE; - use windows_sys::Win32::Security::Credentials::CRED_TYPE_GENERIC; - - pub(crate) struct WindowsCredential; - - /// Converts a string to a nul-terminated wide UTF-16 byte sequence. - fn wstr(s: &str) -> Vec { - let mut wide: Vec = OsStr::new(s).encode_wide().collect(); - if wide.iter().any(|b| *b == 0) { - panic!("nul byte in wide string"); - } - wide.push(0); - wide - } - - fn target_name(registry_name: &str) -> Vec { - wstr(&format!("cargo-registry:{}", registry_name)) - } - - impl Credential for WindowsCredential { - fn name(&self) -> &'static str { - env!("CARGO_PKG_NAME") - } - - fn get(&self, index_url: &str) -> Result { - let target_name = target_name(index_url); - let p_credential: *mut CREDENTIALW = std::ptr::null_mut() as *mut _; - unsafe { - if CredReadW( - target_name.as_ptr(), - CRED_TYPE_GENERIC, - 0, - p_credential as *mut _ as *mut _, - ) != TRUE - { - return Err(format!( - "failed to fetch token: {}", - std::io::Error::last_os_error() - ) - .into()); - } - let bytes = std::slice::from_raw_parts( - (*p_credential).CredentialBlob, - (*p_credential).CredentialBlobSize as usize, - ); - String::from_utf8(bytes.to_vec()) - .map_err(|_| "failed to convert token to UTF8".into()) - } - } - - fn store(&self, index_url: &str, token: &str, name: Option<&str>) -> Result<(), Error> { - let token = token.as_bytes(); - let target_name = target_name(index_url); - let comment = match name { - Some(name) => wstr(&format!("Cargo registry token for {}", name)), - None => wstr("Cargo registry token"), - }; - let mut credential = CREDENTIALW { - Flags: 0, - Type: CRED_TYPE_GENERIC, - TargetName: target_name.as_ptr() as PWSTR, - Comment: comment.as_ptr() as PWSTR, - LastWritten: FILETIME { - dwLowDateTime: 0, - dwHighDateTime: 0, - }, - CredentialBlobSize: token.len() as u32, - CredentialBlob: token.as_ptr() as *mut u8, - Persist: CRED_PERSIST_LOCAL_MACHINE, - AttributeCount: 0, - Attributes: std::ptr::null_mut(), - TargetAlias: std::ptr::null_mut(), - UserName: std::ptr::null_mut(), - }; - let result = unsafe { CredWriteW(&mut credential, 0) }; - if result != TRUE { - let err = std::io::Error::last_os_error(); - return Err(format!("failed to store token: {}", err).into()); - } - Ok(()) - } - - fn erase(&self, index_url: &str) -> Result<(), Error> { - let target_name = target_name(index_url); - let result = unsafe { CredDeleteW(target_name.as_ptr(), CRED_TYPE_GENERIC, 0) }; - if result != TRUE { - let err = std::io::Error::last_os_error(); - if err.raw_os_error() == Some(ERROR_NOT_FOUND as i32) { - eprintln!("not currently logged in to `{}`", index_url); - return Ok(()); - } - return Err(format!("failed to remove token: {}", err).into()); - } - Ok(()) - } - } -} - -#[cfg(not(windows))] -use cargo_credential::UnsupportedCredential as WindowsCredential; -#[cfg(windows)] -use win::WindowsCredential; - -fn main() { - cargo_credential::main(WindowsCredential); -} diff --git a/credential/cargo-credential/Cargo.toml b/credential/cargo-credential/Cargo.toml index 345ab90fdc2..98ace8f3076 100644 --- a/credential/cargo-credential/Cargo.toml +++ b/credential/cargo-credential/Cargo.toml @@ -1,9 +1,12 @@ [package] name = "cargo-credential" -version = "0.2.0" +version = "0.3.0" edition.workspace = true license.workspace = true repository = "https://github.com/rust-lang/cargo" description = "A library to assist writing Cargo credential helpers." [dependencies] +serde.workspace = true +serde_json.workspace = true +time.workspace = true diff --git a/credential/cargo-credential/README.md b/credential/cargo-credential/README.md index 53dc8e6b7d6..049b3ba552d 100644 --- a/credential/cargo-credential/README.md +++ b/credential/cargo-credential/README.md @@ -18,7 +18,7 @@ Create a Cargo project with this as a dependency: # Add this to your Cargo.toml: [dependencies] -cargo-credential = "0.1" +cargo-credential = "0.3" ``` And then include a `main.rs` binary which implements the `Credential` trait, and calls diff --git a/credential/cargo-credential/src/lib.rs b/credential/cargo-credential/src/lib.rs index c751722423e..3bae2f40a50 100644 --- a/credential/cargo-credential/src/lib.rs +++ b/credential/cargo-credential/src/lib.rs @@ -8,99 +8,307 @@ //! cargo_credential::main(MyCredential); //! } //! ``` -//! -//! This will determine the action to perform (get/store/erase) by looking at -//! the CLI arguments for the first argument that does not start with `-`. It -//! will then call the corresponding method of the trait to perform the -//! requested action. -pub type Error = Box; +use serde::{Deserialize, Serialize}; +use std::{ + fmt::Display, + fs::File, + io::{self, BufRead, BufReader}, +}; +use time::OffsetDateTime; -pub trait Credential { - /// Returns the name of this credential process. - fn name(&self) -> &'static str; +mod secret; +pub use secret::Secret; - /// Retrieves a token for the given registry. - fn get(&self, index_url: &str) -> Result; +/// Message sent by the credential helper on startup +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct CredentialHello { + // Protocol versions supported by the credential process. + pub v: Vec, +} - /// Stores the given token for the given registry. - fn store(&self, index_url: &str, token: &str, name: Option<&str>) -> Result<(), Error>; +/// Credential provider that doesn't support any registries. +pub struct UnsupportedCredential; +impl Credential for UnsupportedCredential { + fn perform( + &self, + _registry: &RegistryInfo, + _action: &Action, + _args: &[&str], + ) -> Result { + Err(Error::UrlNotSupported) + } +} - /// Removes the token for the given registry. - /// - /// If the user is not logged in, this should print a message to stderr if - /// possible indicating that the user is not currently logged in, and - /// return `Ok`. - fn erase(&self, index_url: &str) -> Result<(), Error>; +/// Message sent by Cargo to the credential helper after the hello +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "kebab-case")] +pub struct CredentialRequest<'a> { + // Cargo will respond with the highest common protocol supported by both. + pub v: u32, + #[serde(borrow)] + pub registry: RegistryInfo<'a>, + #[serde(borrow, flatten)] + pub action: Action<'a>, + /// Additional command-line arguments passed to the credential provider. + pub args: Vec<&'a str>, } -pub struct UnsupportedCredential; +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "kebab-case")] +pub struct RegistryInfo<'a> { + /// Registry index url + pub index_url: &'a str, + /// Name of the registry in configuration. May not be available. + /// The crates.io registry will be `crates-io` (`CRATES_IO_REGISTRY`). + pub name: Option<&'a str>, + /// Headers from attempting to access a registry that resulted in a HTTP 401. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub headers: Vec, +} -impl Credential for UnsupportedCredential { - fn name(&self) -> &'static str { - "unsupported" +#[derive(Serialize, Deserialize, Clone, Debug)] +#[non_exhaustive] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub enum Action<'a> { + #[serde(borrow)] + Get(Operation<'a>), + Login(LoginOptions<'a>), + Logout, +} + +impl<'a> Display for Action<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Action::Get(_) => f.write_str("get"), + Action::Login(_) => f.write_str("login"), + Action::Logout => f.write_str("logout"), + } } +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "kebab-case")] +pub struct LoginOptions<'a> { + /// Token passed on the command line via --token or from stdin + pub token: Option>, + /// Optional URL that the user can visit to log in to the registry + pub login_url: Option<&'a str>, +} + +/// A record of what kind of operation is happening that we should generate a token for. +#[derive(Serialize, Deserialize, Clone, Debug)] +#[non_exhaustive] +#[serde(tag = "operation", rename_all = "kebab-case")] +pub enum Operation<'a> { + /// The user is attempting to fetch a crate. + Read, + /// The user is attempting to publish a crate. + Publish { + /// The name of the crate + name: &'a str, + /// The version of the crate + vers: &'a str, + /// The checksum of the crate file being uploaded + cksum: &'a str, + }, + /// The user is attempting to yank a crate. + Yank { + /// The name of the crate + name: &'a str, + /// The version of the crate + vers: &'a str, + }, + /// The user is attempting to unyank a crate. + Unyank { + /// The name of the crate + name: &'a str, + /// The version of the crate + vers: &'a str, + }, + /// The user is attempting to modify the owners of a crate. + Owners { + /// The name of the crate + name: &'a str, + }, +} + +/// Message sent by the credential helper +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(tag = "kind", rename_all = "kebab-case")] +#[non_exhaustive] +pub enum CredentialResponse { + Get { + token: Secret, + cache: CacheControl, + operation_independent: bool, + }, + Login, + Logout, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "kebab-case")] +#[non_exhaustive] +pub enum CacheControl { + /// Do not cache this result. + Never, + /// Cache this result and use it for subsequent requests in the current Cargo invocation until the specified time. + Expires(#[serde(with = "time::serde::timestamp")] OffsetDateTime), + /// Cache this result and use it for all subsequent requests in the current Cargo invocation. + Session, +} + +/// Credential process JSON protocol version. Incrementing +/// this version will prevent new credential providers +/// from working with older versions of Cargo. +pub const PROTOCOL_VERSION_1: u32 = 1; + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "kebab-case", tag = "kind", content = "detail")] +#[non_exhaustive] +pub enum Error { + UrlNotSupported, + ProtocolNotSupported(u32), + Subprocess(String), + Io(String), + Serde(String), + Other(String), + OperationNotSupported, + NotFound, +} - fn get(&self, _index_url: &str) -> Result { - Err("unsupported".into()) +impl From for Error { + fn from(err: serde_json::Error) -> Self { + Error::Serde(err.to_string()) } +} - fn store(&self, _index_url: &str, _token: &str, _name: Option<&str>) -> Result<(), Error> { - Err("unsupported".into()) +impl From for Error { + fn from(err: std::io::Error) -> Self { + Error::Io(err.to_string()) } +} - fn erase(&self, _index_url: &str) -> Result<(), Error> { - Err("unsupported".into()) +impl From for Error { + fn from(err: String) -> Self { + Error::Other(err) } } -/// Runs the credential interaction by processing the command-line and -/// environment variables. -pub fn main(credential: impl Credential) { - let name = credential.name(); - if let Err(e) = doit(credential) { - eprintln!("{} error: {}", name, e); - std::process::exit(1); +impl From<&str> for Error { + fn from(err: &str) -> Self { + Error::Other(err.to_string()) } } -fn env(name: &str) -> Result { - std::env::var(name).map_err(|_| format!("environment variable `{}` is not set", name).into()) +impl std::error::Error for Error {} + +impl core::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::UrlNotSupported => { + write!(f, "credential provider does not support this registry") + } + Error::ProtocolNotSupported(v) => write!( + f, + "credential provider does not support protocol version {v}" + ), + Error::Io(msg) => write!(f, "i/o error: {msg}"), + Error::Serde(msg) => write!(f, "serialization error: {msg}"), + Error::Other(msg) => write!(f, "error: {msg}"), + Error::Subprocess(msg) => write!(f, "subprocess failed: {msg}"), + Error::OperationNotSupported => write!( + f, + "credential provider does not support the requested operation" + ), + Error::NotFound => write!(f, "credential not found"), + } + } +} + +pub trait Credential { + /// Retrieves a token for the given registry. + fn perform( + &self, + registry: &RegistryInfo, + action: &Action, + args: &[&str], + ) -> Result; +} + +/// Runs the credential interaction +pub fn main(credential: impl Credential) { + let result = doit(credential); + if result.is_err() { + serde_json::to_writer(std::io::stdout(), &result) + .expect("failed to serialize credential provider error"); + println!(); + } } fn doit(credential: impl Credential) -> Result<(), Error> { - let which = std::env::args() - .skip(1) - .skip_while(|arg| arg.starts_with('-')) - .next() - .ok_or_else(|| "first argument must be the {action}")?; - let index_url = env("CARGO_REGISTRY_INDEX_URL")?; - let name = std::env::var("CARGO_REGISTRY_NAME_OPT").ok(); - let result = match which.as_ref() { - "get" => credential.get(&index_url).and_then(|token| { - println!("{}", token); - Ok(()) - }), - "store" => { - read_token().and_then(|token| credential.store(&index_url, &token, name.as_deref())) + let hello = CredentialHello { + v: vec![PROTOCOL_VERSION_1], + }; + serde_json::to_writer(std::io::stdout(), &hello)?; + println!(); + + loop { + let mut buffer = String::new(); + let len = std::io::stdin().read_line(&mut buffer)?; + if len == 0 { + return Ok(()); } - "erase" => credential.erase(&index_url), - _ => { - return Err(format!( - "unexpected command-line argument `{}`, expected get/store/erase", - which - ) - .into()) + let request: CredentialRequest = serde_json::from_str(&buffer)?; + if request.v != PROTOCOL_VERSION_1 { + return Err(Error::ProtocolNotSupported(request.v)); } - }; - result.map_err(|e| format!("failed to `{}` token: {}", which, e).into()) + serde_json::to_writer( + std::io::stdout(), + &credential.perform(&request.registry, &request.action, &request.args), + )?; + println!(); + } +} + +/// Open stdin from the tty +pub fn tty() -> Result { + #[cfg(unix)] + const IN_DEVICE: &str = "/dev/tty"; + #[cfg(windows)] + const IN_DEVICE: &str = "CONIN$"; + let stdin = std::fs::OpenOptions::new() + .read(true) + .write(true) + .open(IN_DEVICE)?; + Ok(stdin) +} + +/// Read a line of text from stdin. +pub fn read_line() -> Result { + let mut reader = BufReader::new(tty()?); + let mut buf = String::new(); + reader.read_line(&mut buf)?; + Ok(buf.trim().to_string()) } -fn read_token() -> Result { - let mut buffer = String::new(); - std::io::stdin().read_line(&mut buffer)?; - if buffer.ends_with('\n') { - buffer.pop(); +/// Prompt the user for a token. +pub fn read_token( + login_options: &LoginOptions, + registry: &RegistryInfo, +) -> Result, Error> { + if let Some(token) = &login_options.token { + return Ok(token.to_owned()); } - Ok(buffer) + + if let Some(url) = login_options.login_url { + eprintln!("please paste the token found on {url} below"); + } else if let Some(name) = registry.name { + eprintln!("please paste the token for {name} below"); + } else { + eprintln!("please paste the token for {} below", registry.index_url); + } + + Ok(Secret::from(read_line()?)) } diff --git a/credential/cargo-credential/src/secret.rs b/credential/cargo-credential/src/secret.rs new file mode 100644 index 00000000000..1c2314d8eac --- /dev/null +++ b/credential/cargo-credential/src/secret.rs @@ -0,0 +1,101 @@ +use std::fmt; +use std::ops::Deref; + +use serde::{Deserialize, Serialize}; + +/// A wrapper for values that should not be printed. +/// +/// This type does not implement `Display`, and has a `Debug` impl that hides +/// the contained value. +/// +/// ``` +/// # use cargo_credential::Secret; +/// let token = Secret::from("super secret string"); +/// assert_eq!(format!("{:?}", token), "Secret { inner: \"REDACTED\" }"); +/// ``` +/// +/// Currently, we write a borrowed `Secret` as `Secret<&T>`. +/// The [`as_deref`](Secret::as_deref) and [`to_owned`](Secret::to_owned) methods can +/// be used to convert back and forth between `Secret` and `Secret<&str>`. +#[derive(Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Secret { + inner: T, +} + +impl Secret { + /// Unwraps the contained value. + /// + /// Use of this method marks the boundary of where the contained value is + /// hidden. + pub fn expose(self) -> T { + self.inner + } + + /// Converts a `Secret` to a `Secret<&T::Target>`. + /// ``` + /// # use cargo_credential::Secret; + /// let owned: Secret = Secret::from(String::from("token")); + /// let borrowed: Secret<&str> = owned.as_deref(); + /// ``` + pub fn as_deref(&self) -> Secret<&::Target> + where + T: Deref, + { + Secret::from(self.inner.deref()) + } + + /// Converts a `Secret` to a `Secret<&T>`. + pub fn as_ref(&self) -> Secret<&T> { + Secret::from(&self.inner) + } + + /// Converts a `Secret` to a `Secret` by applying `f` to the contained value. + pub fn map(self, f: F) -> Secret + where + F: FnOnce(T) -> U, + { + Secret::from(f(self.inner)) + } +} + +impl Secret<&T> { + /// Converts a `Secret` containing a borrowed type to a `Secret` containing the + /// corresponding owned type. + /// ``` + /// # use cargo_credential::Secret; + /// let borrowed: Secret<&str> = Secret::from("token"); + /// let owned: Secret = borrowed.to_owned(); + /// ``` + pub fn to_owned(&self) -> Secret<::Owned> { + Secret::from(self.inner.to_owned()) + } +} + +impl Secret> { + /// Converts a `Secret>` to a `Result, E>`. + pub fn transpose(self) -> Result, E> { + self.inner.map(|v| Secret::from(v)) + } +} + +impl> Secret { + /// Checks if the contained value is empty. + pub fn is_empty(&self) -> bool { + self.inner.as_ref().is_empty() + } +} + +impl From for Secret { + fn from(inner: T) -> Self { + Self { inner } + } +} + +impl fmt::Debug for Secret { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Secret") + .field("inner", &"REDACTED") + .finish() + } +} diff --git a/src/bin/cargo/commands/login.rs b/src/bin/cargo/commands/login.rs index 1c8d3ae4cff..1d68059a069 100644 --- a/src/bin/cargo/commands/login.rs +++ b/src/bin/cargo/commands/login.rs @@ -4,32 +4,10 @@ use cargo::ops; pub fn cli() -> Command { subcommand("login") - .about( - "Save an api token from the registry locally. \ - If token is not specified, it will be read from stdin.", - ) + .about("Log in to a registry.") .arg_quiet() .arg(Arg::new("token").action(ArgAction::Set)) .arg(opt("registry", "Registry to use").value_name("REGISTRY")) - .arg( - flag( - "generate-keypair", - "Generate a public/secret keypair (unstable)", - ) - .conflicts_with("token"), - ) - .arg( - flag("secret-key", "Prompt for secret key (unstable)") - .conflicts_with_all(&["generate-keypair", "token"]), - ) - .arg( - opt( - "key-subject", - "Set the key subject for this registry (unstable)", - ) - .value_name("SUBJECT") - .conflicts_with("token"), - ) .after_help("Run `cargo help login` for more detailed information.\n") } @@ -39,9 +17,6 @@ pub fn exec(config: &mut Config, args: &ArgMatches) -> CliResult { config, args.get_one::("token").map(|s| s.as_str().into()), registry.as_deref(), - args.flag("generate-keypair"), - args.flag("secret-key"), - args.get_one("key-subject").map(String::as_str), )?; Ok(()) } diff --git a/src/bin/cargo/commands/owner.rs b/src/bin/cargo/commands/owner.rs index 493072b7b3a..8c213d8666b 100644 --- a/src/bin/cargo/commands/owner.rs +++ b/src/bin/cargo/commands/owner.rs @@ -1,7 +1,7 @@ use crate::command_prelude::*; use cargo::ops::{self, OwnersOptions}; -use cargo::util::auth::Secret; +use cargo_credential::Secret; pub fn cli() -> Command { subcommand("owner") diff --git a/src/bin/cargo/commands/yank.rs b/src/bin/cargo/commands/yank.rs index 3dee522793b..758259324b1 100644 --- a/src/bin/cargo/commands/yank.rs +++ b/src/bin/cargo/commands/yank.rs @@ -1,7 +1,7 @@ use crate::command_prelude::*; use cargo::ops; -use cargo::util::auth::Secret; +use cargo_credential::Secret; pub fn cli() -> Command { subcommand("yank") diff --git a/src/cargo/core/package.rs b/src/cargo/core/package.rs index f4ab448d28d..093c64d72d3 100644 --- a/src/cargo/core/package.rs +++ b/src/cargo/core/package.rs @@ -25,7 +25,7 @@ use crate::core::source::MaybePackage; use crate::core::{Dependency, Manifest, PackageId, SourceId, Target}; use crate::core::{SourceMap, Summary, Workspace}; use crate::util::config::PackageCacheLock; -use crate::util::errors::{CargoResult, HttpNotSuccessful, DEBUG_HEADERS}; +use crate::util::errors::{CargoResult, HttpNotSuccessful}; use crate::util::interning::InternedString; use crate::util::network::http::http_handle_and_timeout; use crate::util::network::http::HttpTimeout; @@ -748,9 +748,7 @@ impl<'a, 'cfg> Downloads<'a, 'cfg> { // Headers contain trailing \r\n, trim them to make it easier // to work with. let h = String::from_utf8_lossy(data).trim().to_string(); - if DEBUG_HEADERS.iter().any(|p| h.starts_with(p)) { - downloads.pending[&token].0.headers.borrow_mut().push(h); - } + downloads.pending[&token].0.headers.borrow_mut().push(h); } }); true diff --git a/src/cargo/ops/registry/login.rs b/src/cargo/ops/registry/login.rs index 1e2b3a87b3b..3128ef98520 100644 --- a/src/cargo/ops/registry/login.rs +++ b/src/cargo/ops/registry/login.rs @@ -5,40 +5,26 @@ //! //! [1]: https://doc.rust-lang.org/nightly/cargo/reference/registry-web-api.html#login -use std::io; -use std::io::BufRead; +use std::io::IsTerminal; -use anyhow::anyhow; -use anyhow::bail; -use anyhow::Context as _; -use pasetors::keys::AsymmetricKeyPair; -use pasetors::keys::Generate as _; -use pasetors::paserk::FormatAsPaserk; - -use crate::drop_println; -use crate::ops::RegistryCredentialConfig; -use crate::sources::CRATES_IO_DOMAIN; use crate::util::auth; -use crate::util::auth::paserk_public_from_paserk_secret; use crate::util::auth::AuthorizationError; -use crate::util::auth::Secret; use crate::CargoResult; use crate::Config; +use cargo_credential::LoginOptions; +use cargo_credential::Secret; use super::get_source_id; +use super::registry; pub fn registry_login( config: &Config, - token: Option>, + token_from_cmdline: Option>, reg: Option<&str>, - generate_keypair: bool, - secret_key_required: bool, - key_subject: Option<&str>, ) -> CargoResult<()> { let source_ids = get_source_id(config, None, reg)?; - let reg_cfg = auth::registry_credential_config(config, &source_ids.original)?; - let login_url = match super::registry(config, token.clone(), None, reg, false, None) { + let login_url = match registry(config, token_from_cmdline.clone(), None, reg, false, None) { Ok((registry, _)) => Some(format!("{}/me", registry.host())), Err(e) if e.is::() => e .downcast::() @@ -47,114 +33,21 @@ pub fn registry_login( .map(|u| u.to_string()), Err(e) => return Err(e), }; - let new_token; - if generate_keypair || secret_key_required || key_subject.is_some() { - if !config.cli_unstable().registry_auth { - let flag = if generate_keypair { - "generate-keypair" - } else if secret_key_required { - "secret-key" - } else if key_subject.is_some() { - "key-subject" - } else { - unreachable!("how did we get here"); - }; - bail!( - "the `{flag}` flag is unstable, pass `-Z registry-auth` to enable it\n\ - See https://github.com/rust-lang/cargo/issues/10519 for more \ - information about the `{flag}` flag." - ); - } - assert!(token.is_none()); - // we are dealing with asymmetric tokens - let (old_secret_key, old_key_subject) = match ®_cfg { - RegistryCredentialConfig::AsymmetricKey((old_secret_key, old_key_subject)) => { - (Some(old_secret_key), old_key_subject.clone()) - } - _ => (None, None), - }; - let secret_key: Secret; - if generate_keypair { - assert!(!secret_key_required); - let kp = AsymmetricKeyPair::::generate().unwrap(); - secret_key = Secret::default().map(|mut key| { - FormatAsPaserk::fmt(&kp.secret, &mut key).unwrap(); - key - }); - } else if secret_key_required { - assert!(!generate_keypair); - drop_println!(config, "please paste the API secret key below"); - secret_key = Secret::default() - .map(|mut line| { - let input = io::stdin(); - input - .lock() - .read_line(&mut line) - .with_context(|| "failed to read stdin") - .map(|_| line.trim().to_string()) - }) - .transpose()?; - } else { - secret_key = old_secret_key - .cloned() - .ok_or_else(|| anyhow!("need a secret_key to set a key_subject"))?; - } - if let Some(p) = paserk_public_from_paserk_secret(secret_key.as_deref()) { - drop_println!(config, "{}", &p); - } else { - bail!("not a validly formatted PASERK secret key"); - } - new_token = RegistryCredentialConfig::AsymmetricKey(( - secret_key, - match key_subject { - Some(key_subject) => Some(key_subject.to_string()), - None => old_key_subject, - }, - )); - } else { - new_token = RegistryCredentialConfig::Token(match token { - Some(token) => token.owned(), - None => { - if let Some(login_url) = login_url { - drop_println!( - config, - "please paste the token found on {} below", - login_url - ) - } else { - drop_println!( - config, - "please paste the token for {} below", - source_ids.original.display_registry_name() - ) - } - let mut line = String::new(); - let input = io::stdin(); - input - .lock() - .read_line(&mut line) - .with_context(|| "failed to read stdin")?; - // Automatically remove `cargo login` from an inputted token to - // allow direct pastes from `registry.host()`/me. - Secret::from(line.replace("cargo login", "").trim().to_string()) - } - }); - - if let Some(tok) = new_token.as_token() { - crates_io::check_token(tok.as_ref().expose())?; + let mut token_from_stdin = None; + if !std::io::stdin().is_terminal() { + let token = std::io::read_to_string(std::io::stdin()).unwrap_or_default(); + if !token.is_empty() { + token_from_stdin = Some(token); } } - if ®_cfg == &new_token { - config.shell().status("Login", "already logged in")?; - return Ok(()); - } + let token = token_from_cmdline.or_else(|| token_from_stdin.as_deref().map(Secret::from)); - auth::login(config, &source_ids.original, new_token)?; + let options = LoginOptions { + token, + login_url: login_url.as_deref(), + }; - config.shell().status( - "Login", - format!("token for `{}` saved", reg.unwrap_or(CRATES_IO_DOMAIN)), - )?; + auth::login(config, &source_ids.original, options)?; Ok(()) } diff --git a/src/cargo/ops/registry/logout.rs b/src/cargo/ops/registry/logout.rs index 59f2d9261d9..d1f080baefb 100644 --- a/src/cargo/ops/registry/logout.rs +++ b/src/cargo/ops/registry/logout.rs @@ -11,32 +11,6 @@ use super::get_source_id; pub fn registry_logout(config: &Config, reg: Option<&str>) -> CargoResult<()> { let source_ids = get_source_id(config, None, reg)?; - let reg_cfg = auth::registry_credential_config(config, &source_ids.original)?; - let reg_name = source_ids.original.display_registry_name(); - if reg_cfg.is_none() { - config - .shell() - .status("Logout", format!("not currently logged in to `{reg_name}`"))?; - return Ok(()); - } auth::logout(config, &source_ids.original)?; - config.shell().status( - "Logout", - format!("token for `{reg_name}` has been removed from local storage"), - )?; - let location = if source_ids.original.is_crates_io() { - "".to_string() - } else { - // The URL for the source requires network access to load the config. - // That could be a fairly heavy operation to perform just to provide a - // help message, so for now this just provides some generic text. - // Perhaps in the future this could have an API to fetch the config if - // it is cached, but avoid network access otherwise? - format!("the `{reg_name}` website") - }; - config.shell().note(format!( - "This does not revoke the token on the registry server.\n \ - If you need to revoke the token, visit {location} and follow the instructions there." - ))?; Ok(()) } diff --git a/src/cargo/ops/registry/mod.rs b/src/cargo/ops/registry/mod.rs index ecb610ddda1..94ef1ead271 100644 --- a/src/cargo/ops/registry/mod.rs +++ b/src/cargo/ops/registry/mod.rs @@ -10,18 +10,18 @@ mod search; mod yank; use std::collections::HashSet; -use std::path::PathBuf; use std::str; use std::task::Poll; use anyhow::{bail, format_err, Context as _}; +use cargo_credential::{Operation, Secret}; use crates_io::{self, Registry}; use crate::core::source::Source; use crate::core::SourceId; use crate::sources::{RegistrySource, SourceConfigMap}; -use crate::util::auth::{self, Secret}; -use crate::util::config::Config; +use crate::util::auth; +use crate::util::config::{Config, PathAndArgs}; use crate::util::errors::CargoResult; use crate::util::network::http::http_handle; use crate::util::IntoUrl; @@ -44,7 +44,7 @@ pub enum RegistryCredentialConfig { /// The authentication token. Token(Secret), /// Process used for fetching a token. - Process((PathBuf, Vec)), + Process(Vec), /// Secret Key and subject for Asymmetric tokens. AsymmetricKey((Secret, Option)), } @@ -75,7 +75,7 @@ impl RegistryCredentialConfig { None } } - pub fn as_process(&self) -> Option<&(PathBuf, Vec)> { + pub fn as_process(&self) -> Option<&Vec> { if let Self::Process(v) = self { Some(v) } else { @@ -106,7 +106,7 @@ fn registry( index: Option<&str>, registry: Option<&str>, force_update: bool, - token_required: Option>, + token_required: Option>, ) -> CargoResult<(Registry, RegistrySourceIds)> { let source_ids = get_source_id(config, index, registry)?; @@ -114,7 +114,7 @@ fn registry( bail!("command-line argument --index requires --token to be specified"); } if let Some(token) = token_from_cmdline { - auth::cache_token(config, &source_ids.original, token); + auth::cache_token_from_commandline(config, &source_ids.original, token); } let cfg = { @@ -138,11 +138,13 @@ fn registry( .api .ok_or_else(|| format_err!("{} does not support API commands", source_ids.replacement))?; let token = if token_required.is_some() || cfg.auth_required { + let operation = token_required.unwrap_or(Operation::Read); Some(auth::auth_token( config, &source_ids.original, None, - token_required, + operation, + vec![], )?) } else { None diff --git a/src/cargo/ops/registry/owner.rs b/src/cargo/ops/registry/owner.rs index e53e07cb820..e29c6400b6b 100644 --- a/src/cargo/ops/registry/owner.rs +++ b/src/cargo/ops/registry/owner.rs @@ -3,12 +3,12 @@ //! [1]: https://doc.rust-lang.org/nightly/cargo/reference/registry-web-api.html#owners use anyhow::Context as _; +use cargo_credential::Operation; +use cargo_credential::Secret; use crate::core::Workspace; use crate::drop_print; use crate::drop_println; -use crate::util::auth; -use crate::util::auth::Secret; use crate::util::important_paths::find_root_manifest_for_wd; use crate::CargoResult; use crate::Config; @@ -33,7 +33,7 @@ pub fn modify_owners(config: &Config, opts: &OwnersOptions) -> CargoResult<()> { } }; - let mutation = auth::Mutation::Owners { name: &name }; + let operation = Operation::Owners { name: &name }; let (mut registry, _) = super::registry( config, @@ -41,7 +41,7 @@ pub fn modify_owners(config: &Config, opts: &OwnersOptions) -> CargoResult<()> { opts.index.as_deref(), opts.registry.as_deref(), true, - Some(mutation), + Some(operation), )?; if let Some(ref v) = opts.to_add { diff --git a/src/cargo/ops/registry/publish.rs b/src/cargo/ops/registry/publish.rs index 7f4fbbae23d..40ca9fd16f5 100644 --- a/src/cargo/ops/registry/publish.rs +++ b/src/cargo/ops/registry/publish.rs @@ -9,6 +9,8 @@ use std::time::Duration; use anyhow::bail; use anyhow::Context as _; +use cargo_credential::Operation; +use cargo_credential::Secret; use cargo_util::paths; use crates_io::NewCrate; use crates_io::NewCrateDependency; @@ -28,7 +30,6 @@ use crate::ops::Packages; use crate::sources::SourceConfigMap; use crate::sources::CRATES_IO_REGISTRY; use crate::util::auth; -use crate::util::auth::Secret; use crate::util::config::JobsConfig; use crate::util::Progress; use crate::util::ProgressStyle; @@ -113,7 +114,7 @@ pub fn publish(ws: &Workspace<'_>, opts: &PublishOpts<'_>) -> CargoResult<()> { // This is only used to confirm that we can create a token before we build the package. // This causes the credential provider to be called an extra time, but keeps the same order of errors. let ver = pkg.version().to_string(); - let mutation = auth::Mutation::PrePublish; + let operation = Operation::Read; let (mut registry, reg_ids) = super::registry( opts.config, @@ -121,7 +122,7 @@ pub fn publish(ws: &Workspace<'_>, opts: &PublishOpts<'_>) -> CargoResult<()> { opts.index.as_deref(), publish_registry.as_deref(), true, - Some(mutation).filter(|_| !opts.dry_run), + Some(operation).filter(|_| !opts.dry_run), )?; verify_dependencies(pkg, ®istry, reg_ids.original)?; @@ -149,16 +150,17 @@ pub fn publish(ws: &Workspace<'_>, opts: &PublishOpts<'_>) -> CargoResult<()> { let hash = cargo_util::Sha256::new() .update_file(tarball.file())? .finish_hex(); - let mutation = Some(auth::Mutation::Publish { + let operation = Operation::Publish { name: pkg.name().as_str(), vers: &ver, cksum: &hash, - }); + }; registry.set_token(Some(auth::auth_token( &opts.config, ®_ids.original, None, - mutation, + operation, + vec![], )?)); } diff --git a/src/cargo/ops/registry/yank.rs b/src/cargo/ops/registry/yank.rs index 7f087570a49..8a961b990e7 100644 --- a/src/cargo/ops/registry/yank.rs +++ b/src/cargo/ops/registry/yank.rs @@ -5,10 +5,10 @@ use anyhow::bail; use anyhow::Context as _; +use cargo_credential::Operation; +use cargo_credential::Secret; use crate::core::Workspace; -use crate::util::auth; -use crate::util::auth::Secret; use crate::util::config::Config; use crate::util::errors::CargoResult; use crate::util::important_paths::find_root_manifest_for_wd; @@ -36,12 +36,12 @@ pub fn yank( }; let message = if undo { - auth::Mutation::Unyank { + Operation::Unyank { name: &name, vers: &version, } } else { - auth::Mutation::Yank { + Operation::Yank { name: &name, vers: &version, } diff --git a/src/cargo/sources/registry/download.rs b/src/cargo/sources/registry/download.rs index a85d87177a7..08940b3a1db 100644 --- a/src/cargo/sources/registry/download.rs +++ b/src/cargo/sources/registry/download.rs @@ -4,6 +4,7 @@ //! [`RemoteRegistry`]: super::remote::RemoteRegistry use anyhow::Context; +use cargo_credential::Operation; use cargo_util::registry::make_dep_path; use cargo_util::Sha256; @@ -78,7 +79,13 @@ pub(super) fn download( } let authorization = if registry_config.auth_required { - Some(auth::auth_token(config, &pkg.source_id(), None, None)?) + Some(auth::auth_token( + config, + &pkg.source_id(), + None, + Operation::Read, + vec![], + )?) } else { None }; diff --git a/src/cargo/sources/registry/http_remote.rs b/src/cargo/sources/registry/http_remote.rs index 622590dafd4..05920eab11e 100644 --- a/src/cargo/sources/registry/http_remote.rs +++ b/src/cargo/sources/registry/http_remote.rs @@ -4,12 +4,13 @@ use crate::core::{PackageId, SourceId}; use crate::sources::registry::download; use crate::sources::registry::MaybeLock; use crate::sources::registry::{LoadResponse, RegistryConfig, RegistryData}; -use crate::util::errors::{CargoResult, HttpNotSuccessful, DEBUG_HEADERS}; +use crate::util::errors::{CargoResult, HttpNotSuccessful}; use crate::util::network::http::http_handle; use crate::util::network::retry::{Retry, RetryResult}; use crate::util::network::sleep::SleepTracker; use crate::util::{auth, Config, Filesystem, IntoUrl, Progress, ProgressStyle}; use anyhow::Context; +use cargo_credential::Operation; use cargo_util::paths; use curl::easy::{Easy, List}; use curl::multi::{EasyHandle, Multi}; @@ -96,6 +97,9 @@ pub struct HttpRegistry<'cfg> { /// Url to get a token for the registry. login_url: Option, + /// Headers received with an HTTP 401. + auth_error_headers: Vec, + /// Disables status messages. quiet: bool, } @@ -149,8 +153,8 @@ struct Headers { last_modified: Option, etag: Option, www_authenticate: Vec, - /// We don't care about these headers. Put them here for debugging purpose. - others: Vec, + /// All headers, including explicit headers above. + all: Vec, } /// HTTP status code [`HttpRegistry`] cares about. @@ -221,6 +225,7 @@ impl<'cfg> HttpRegistry<'cfg> { registry_config: None, auth_required: false, login_url: None, + auth_error_headers: vec![], quiet: false, }) } @@ -316,7 +321,7 @@ impl<'cfg> HttpRegistry<'cfg> { &mut handle, &url, data, - download.header_map.take().others, + download.header_map.take().all, ) .into()); } @@ -569,6 +574,7 @@ impl<'cfg> RegistryData for HttpRegistry<'cfg> { } } } + self.auth_error_headers = result.header_map.all; } StatusCode::Unauthorized => { let err = Err(HttpNotSuccessful { @@ -576,7 +582,7 @@ impl<'cfg> RegistryData for HttpRegistry<'cfg> { body: result.data, url: self.full_url(path), ip: None, - headers: result.header_map.others, + headers: result.header_map.all, } .into()); if self.auth_required { @@ -639,8 +645,13 @@ impl<'cfg> RegistryData for HttpRegistry<'cfg> { } } if self.auth_required { - let authorization = - auth::auth_token(self.config, &self.source_id, self.login_url.as_ref(), None)?; + let authorization = auth::auth_token( + self.config, + &self.source_id, + self.login_url.as_ref(), + Operation::Read, + self.auth_error_headers.clone(), + )?; headers.append(&format!("Authorization: {}", authorization))?; trace!("including authorization for {}", full_url); } @@ -679,15 +690,12 @@ impl<'cfg> RegistryData for HttpRegistry<'cfg> { tls::with(|downloads| { if let Some(downloads) = downloads { let mut header_map = downloads.pending[&token].0.header_map.borrow_mut(); + header_map.all.push(format!("{tag}: {value}")); match tag.to_ascii_lowercase().as_str() { LAST_MODIFIED => header_map.last_modified = Some(value.to_string()), ETAG => header_map.etag = Some(value.to_string()), WWW_AUTHENTICATE => header_map.www_authenticate.push(value.to_string()), - _ => { - if DEBUG_HEADERS.iter().any(|prefix| tag.starts_with(prefix)) { - header_map.others.push(format!("{tag}: {value}")); - } - } + _ => {} } } }); diff --git a/src/cargo/util/auth/asymmetric.rs b/src/cargo/util/auth/asymmetric.rs deleted file mode 100644 index b038ba7f87c..00000000000 --- a/src/cargo/util/auth/asymmetric.rs +++ /dev/null @@ -1,156 +0,0 @@ -//! Registry asymmetric authentication support. See [RFC 3231] for more. -//! -//! [RFC 3231]: https://rust-lang.github.io/rfcs/3231-cargo-asymmetric-tokens.html - -use pasetors::keys::AsymmetricPublicKey; -use pasetors::keys::AsymmetricSecretKey; -use pasetors::paserk; -use pasetors::paserk::FormatAsPaserk; -use pasetors::version3; -use pasetors::version3::PublicToken; -use time::format_description::well_known::Rfc3339; -use time::OffsetDateTime; - -use crate::core::SourceId; -use crate::ops::RegistryCredentialConfig; -use crate::CargoResult; - -use super::Mutation; -use super::Secret; - -/// The main body of an asymmetric token as describe in RFC 3231. -#[derive(serde::Serialize)] -struct Message<'a> { - iat: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - sub: Option<&'a str>, - #[serde(skip_serializing_if = "Option::is_none")] - mutation: Option<&'a str>, - #[serde(skip_serializing_if = "Option::is_none")] - name: Option<&'a str>, - #[serde(skip_serializing_if = "Option::is_none")] - vers: Option<&'a str>, - #[serde(skip_serializing_if = "Option::is_none")] - cksum: Option<&'a str>, - #[serde(skip_serializing_if = "Option::is_none")] - challenge: Option<&'a str>, - /// This field is not yet used. This field can be set to a value >1 to - /// indicate a breaking change in the token format. - #[serde(skip_serializing_if = "Option::is_none")] - v: Option, -} - -/// The footer of an asymmetric token as describe in RFC 3231. -#[derive(serde::Serialize)] -struct Footer<'a> { - url: &'a str, - kip: paserk::Id, -} - -/// Checks that a secret key is valid, and returns the associated public key in -/// Paserk format. -pub fn paserk_public_from_paserk_secret(secret_key: Secret<&str>) -> Option { - let secret: Secret> = - secret_key.map(|key| key.try_into()).transpose().ok()?; - let public: AsymmetricPublicKey = secret - .as_ref() - .map(|key| key.try_into()) - .transpose() - .ok()? - .expose(); - let mut paserk_pub_key = String::new(); - FormatAsPaserk::fmt(&public, &mut paserk_pub_key).unwrap(); - Some(paserk_pub_key) -} - -/// Generates a public token from a registry's `credential` configuration for -/// authenticating to a `source_id` -/// -/// An optional `mutation` for authenticating a mutation operation aganist the -/// registry. -pub fn public_token_from_credential( - credential: RegistryCredentialConfig, - source_id: &SourceId, - mutation: Option<&'_ Mutation<'_>>, -) -> CargoResult> { - let RegistryCredentialConfig::AsymmetricKey((secret_key, secret_key_subject)) = credential - else { - anyhow::bail!("credential must be an asymmetric secret key") - }; - - let secret: Secret> = - secret_key.map(|key| key.as_str().try_into()).transpose()?; - let public: AsymmetricPublicKey = secret - .as_ref() - .map(|key| key.try_into()) - .transpose()? - .expose(); - let kip = (&public).try_into()?; - let iat = OffsetDateTime::now_utc(); - - let message = Message { - iat: &iat.format(&Rfc3339)?, - sub: secret_key_subject.as_deref(), - mutation: mutation.and_then(|m| { - Some(match m { - Mutation::PrePublish => return None, - Mutation::Publish { .. } => "publish", - Mutation::Yank { .. } => "yank", - Mutation::Unyank { .. } => "unyank", - Mutation::Owners { .. } => "owners", - }) - }), - name: mutation.and_then(|m| { - Some(match m { - Mutation::PrePublish => return None, - Mutation::Publish { name, .. } - | Mutation::Yank { name, .. } - | Mutation::Unyank { name, .. } - | Mutation::Owners { name, .. } => *name, - }) - }), - vers: mutation.and_then(|m| { - Some(match m { - Mutation::PrePublish | Mutation::Owners { .. } => return None, - Mutation::Publish { vers, .. } - | Mutation::Yank { vers, .. } - | Mutation::Unyank { vers, .. } => *vers, - }) - }), - cksum: mutation.and_then(|m| { - Some(match m { - Mutation::PrePublish - | Mutation::Yank { .. } - | Mutation::Unyank { .. } - | Mutation::Owners { .. } => return None, - Mutation::Publish { cksum, .. } => *cksum, - }) - }), - challenge: None, // todo: PASETO with challenges - v: None, - }; - - let footer = Footer { - url: &source_id.url().to_string(), - kip, - }; - - let secret = secret - .map(|secret| { - PublicToken::sign( - &secret, - serde_json::to_string(&message) - .expect("cannot serialize") - .as_bytes(), - Some( - serde_json::to_string(&footer) - .expect("cannot serialize") - .as_bytes(), - ), - None, - ) - }) - .transpose()?; - - Ok(secret) -} diff --git a/src/cargo/util/auth/mod.rs b/src/cargo/util/auth/mod.rs index 58309964f49..1e221a2c7b7 100644 --- a/src/cargo/util/auth/mod.rs +++ b/src/cargo/util/auth/mod.rs @@ -1,142 +1,213 @@ //! Registry authentication support. -mod asymmetric; +use crate::{ + sources::CRATES_IO_REGISTRY, + util::{config::ConfigKey, CanonicalUrl, CargoResult, Config, IntoUrl}, +}; +use anyhow::{bail, Context as _}; +use cargo_credential::{ + Action, CacheControl, Credential, CredentialResponse, LoginOptions, Operation, RegistryInfo, + Secret, +}; -use crate::util::{config, config::ConfigKey, CanonicalUrl, CargoResult, Config, IntoUrl}; -use anyhow::{bail, format_err, Context as _}; -use cargo_util::ProcessError; use core::fmt; use serde::Deserialize; use std::collections::HashMap; use std::error::Error; -use std::io::{Read, Write}; -use std::ops::Deref; -use std::path::PathBuf; -use std::process::{Command, Stdio}; +use time::{Duration, OffsetDateTime}; use url::Url; use crate::core::SourceId; -use crate::ops::RegistryCredentialConfig; +use crate::util::config::Value; +use crate::util::credential::adaptor::BasicProcessCredential; +use crate::util::credential::paseto::PasetoCredential; -pub use self::asymmetric::paserk_public_from_paserk_secret; +use super::{ + config::{CredentialCacheValue, OptValue, PathAndArgs}, + credential::process::CredentialProcessCredential, + credential::token::TokenCredential, +}; -use super::config::CredentialCacheValue; - -/// A wrapper for values that should not be printed. -/// -/// This type does not implement `Display`, and has a `Debug` impl that hides -/// the contained value. -/// -/// ``` -/// # use cargo::util::auth::Secret; -/// let token = Secret::from("super secret string"); -/// assert_eq!(format!("{:?}", token), "Secret { inner: \"REDACTED\" }"); -/// ``` +/// `[registries.NAME]` tables. /// -/// Currently, we write a borrowed `Secret` as `Secret<&T>`. -/// The [`as_deref`](Secret::as_deref) and [`owned`](Secret::owned) methods can -/// be used to convert back and forth between `Secret` and `Secret<&str>`. -#[derive(Default, Clone, PartialEq, Eq)] -pub struct Secret { - inner: T, +/// The values here should be kept in sync with `RegistryConfigExtended` +#[derive(Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct RegistryConfig { + pub index: Option, + pub token: OptValue>, + pub credential_provider: Option, + pub secret_key: OptValue>, + pub secret_key_subject: Option, + #[serde(rename = "protocol")] + _protocol: Option, } -impl Secret { - /// Unwraps the contained value. - /// - /// Use of this method marks the boundary of where the contained value is - /// hidden. - pub fn expose(self) -> T { - self.inner - } - - /// Converts a `Secret` to a `Secret<&T::Target>`. - /// ``` - /// # use cargo::util::auth::Secret; - /// let owned: Secret = Secret::from(String::from("token")); - /// let borrowed: Secret<&str> = owned.as_deref(); - /// ``` - pub fn as_deref(&self) -> Secret<&::Target> - where - T: Deref, - { - Secret::from(self.inner.deref()) - } - - /// Converts a `Secret` to a `Secret<&T>`. - pub fn as_ref(&self) -> Secret<&T> { - Secret::from(&self.inner) - } - - /// Converts a `Secret` to a `Secret` by applying `f` to the contained value. - pub fn map(self, f: F) -> Secret - where - F: FnOnce(T) -> U, - { - Secret::from(f(self.inner)) - } +/// The `[registry]` table, which more keys than the `[registries.NAME]` tables. +/// +/// Note: nesting `RegistryConfig` inside this struct and using `serde(flatten)` *should* work +/// but fails with "invalid type: sequence, expected a value" when attempting to deserialize. +#[derive(Deserialize)] +#[serde(rename_all = "kebab-case")] +pub struct RegistryConfigExtended { + pub index: Option, + pub token: OptValue>, + pub credential_provider: Option, + pub secret_key: OptValue>, + pub secret_key_subject: Option, + #[serde(rename = "default")] + _default: Option, + #[serde(rename = "global-credential-providers")] + _global_credential_providers: Option>, } -impl Secret<&T> { - /// Converts a `Secret` containing a borrowed type to a `Secret` containing the - /// corresponding owned type. - /// ``` - /// # use cargo::util::auth::Secret; - /// let borrowed: Secret<&str> = Secret::from("token"); - /// let owned: Secret = borrowed.owned(); - /// ``` - pub fn owned(&self) -> Secret<::Owned> { - Secret::from(self.inner.to_owned()) +impl RegistryConfigExtended { + pub fn to_registry_config(self) -> RegistryConfig { + RegistryConfig { + index: self.index, + token: self.token, + credential_provider: self.credential_provider, + secret_key: self.secret_key, + secret_key_subject: self.secret_key_subject, + _protocol: None, + } } } -impl Secret> { - /// Converts a `Secret>` to a `Result, E>`. - pub fn transpose(self) -> Result, E> { - self.inner.map(|v| Secret::from(v)) - } -} +/// Get the list of credential providers for a registry source. +fn credential_provider(config: &Config, sid: &SourceId) -> CargoResult>> { + let cfg = registry_credential_config_raw(config, sid)?; + let allow_cred_proc = config.cli_unstable().credential_process; + let default_providers = || { + if allow_cred_proc { + // Enable the PASETO provider + vec![ + vec!["cargo:token".to_string()], + vec!["cargo:paseto".to_string()], + ] + } else { + vec![vec!["cargo:token".to_string()]] + } + }; + let global_providers = config + .get::>>>("registry.global-credential-providers")? + .filter(|p| !p.is_empty() && allow_cred_proc) + .map(|p| { + p.iter() + .rev() + .map(PathAndArgs::from_whitespace_separated_string) + .map(|p| resolve_credential_alias(config, p)) + .collect() + }) + .unwrap_or_else(default_providers); -impl> Secret { - /// Checks if the contained value is empty. - pub fn is_empty(&self) -> bool { - self.inner.as_ref().is_empty() - } -} + let providers = match cfg { + // If there's a specific provider configured for this registry, use it. + Some(RegistryConfig { + credential_provider: Some(provider), + token, + secret_key, + .. + }) if allow_cred_proc => { + if let Some(token) = token { + config.shell().warn(format!( + "{sid} has a token configured in {} that will be ignored \ + because a credential-provider is configured for this registry`", + token.definition + ))?; + } + if let Some(secret_key) = secret_key { + config.shell().warn(format!( + "{sid} has a secret-key configured in {} that will be ignored \ + because a credential-provider is configured for this registry`", + secret_key.definition + ))?; + } + vec![resolve_credential_alias(config, provider)] + } -impl From for Secret { - fn from(inner: T) -> Self { - Self { inner } - } -} + // Warning for both `token` and `secret-key`, stating which will be ignored + Some(RegistryConfig { + token: Some(token), + secret_key: Some(secret_key), + .. + }) if allow_cred_proc => { + let token_pos = global_providers + .iter() + .position(|p| p.first().map(String::as_str) == Some("cargo:token")); + let paseto_pos = global_providers + .iter() + .position(|p| p.first().map(String::as_str) == Some("cargo:paseto")); + match (token_pos, paseto_pos) { + (Some(token_pos), Some(paseto_pos)) => { + if token_pos < paseto_pos { + config.shell().warn(format!( + "{sid} has a `secret_key` configured in {} that will be ignored \ + because a `token` is also configured, and the `cargo:token` provider is \ + configured with higher precedence", + secret_key.definition + ))?; + } else { + config.shell().warn(format!("{sid} has a `token` configured in {} that will be ignored \ + because a `secret_key` is also configured, and the `cargo:paseto` provider is \ + configured with higher precedence", token.definition))?; + } + } + (_, _) => { + // One or both of the below individual warnings will trigger + } + } + global_providers + } -impl fmt::Debug for Secret { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("Secret") - .field("inner", &"REDACTED") - .finish() - } + // Check if a `token` is configured that will be ignored. + Some(RegistryConfig { + token: Some(token), .. + }) => { + if !global_providers + .iter() + .any(|p| p.first().map(String::as_str) == Some("cargo:token")) + { + config.shell().warn(format!( + "{sid} has a token configured in {} that will be ignored \ + because the `cargo:token` credential provider is not listed in \ + `registry.global-credential-providers`", + token.definition + ))?; + } + global_providers + } + + // Check if a asymmetric token is configured that will be ignored. + Some(RegistryConfig { + secret_key: Some(token), + .. + }) if allow_cred_proc => { + if !global_providers + .iter() + .any(|p| p.first().map(String::as_str) == Some("cargo:paseto")) + { + config.shell().warn(format!( + "{sid} has a secret-key configured in {} that will be ignored \ + because the `cargo:paseto` credential provider is not listed in \ + `registry.global-credential-providers`", + token.definition + ))?; + } + global_providers + } + + // If we couldn't find a registry-specific provider, use the fallback provider list. + None | Some(RegistryConfig { .. }) => global_providers, + }; + Ok(providers) } /// Get the credential configuration for a `SourceId`. -pub fn registry_credential_config( +pub fn registry_credential_config_raw( config: &Config, sid: &SourceId, -) -> CargoResult { - #[derive(Deserialize)] - #[serde(rename_all = "kebab-case")] - struct RegistryConfig { - index: Option, - token: Option, - credential_process: Option, - secret_key: Option, - secret_key_subject: Option, - #[serde(rename = "default")] - _default: Option, - #[serde(rename = "protocol")] - _protocol: Option, - } - +) -> CargoResult> { log::trace!("loading credential config for {}", sid); config.load_credentials()?; if !sid.is_remote_registry() { @@ -150,22 +221,9 @@ pub fn registry_credential_config( // Handle crates.io specially, since it uses different configuration keys. if sid.is_crates_io() { config.check_registry_index_not_set()?; - let RegistryConfig { - token, - credential_process, - secret_key, - secret_key_subject, - .. - } = config.get::("registry")?; - return registry_credential_config_inner( - true, - None, - token.map(Secret::from), - credential_process, - secret_key.map(Secret::from), - secret_key_subject, - config, - ); + return Ok(config + .get::>("registry")? + .map(|c| c.to_registry_config())); } // Find the SourceId's name by its index URL. If environment variables @@ -232,99 +290,34 @@ pub fn registry_credential_config( } } - let (token, credential_process, secret_key, secret_key_subject) = if let Some(name) = &name { + if let Some(name) = &name { log::debug!("found alternative registry name `{name}` for {sid}"); - let RegistryConfig { - token, - secret_key, - secret_key_subject, - credential_process, - .. - } = config.get::(&format!("registries.{name}"))?; - (token, credential_process, secret_key, secret_key_subject) + config.get::>(&format!("registries.{name}")) } else { log::debug!("no registry name found for {sid}"); - (None, None, None, None) - }; - - registry_credential_config_inner( - false, - name.as_deref(), - token.map(Secret::from), - credential_process, - secret_key.map(Secret::from), - secret_key_subject, - config, - ) + Ok(None) + } } -fn registry_credential_config_inner( - is_crates_io: bool, - name: Option<&str>, - token: Option>, - credential_process: Option, - secret_key: Option>, - secret_key_subject: Option, - config: &Config, -) -> CargoResult { - let credential_process = - credential_process.filter(|_| config.cli_unstable().credential_process); - let secret_key = secret_key.filter(|_| config.cli_unstable().registry_auth); - let secret_key_subject = secret_key_subject.filter(|_| config.cli_unstable().registry_auth); - let err_both = |token_key: &str, proc_key: &str| { - let registry = if is_crates_io { - "".to_string() - } else { - format!(" for registry `{}`", name.unwrap_or("UN-NAMED")) - }; - Err(format_err!( - "both `{token_key}` and `{proc_key}` \ - were specified in the config{registry}.\n\ - Only one of these values may be set, remove one or the other to proceed.", - )) - }; - Ok( - match (token, credential_process, secret_key, secret_key_subject) { - (Some(_), Some(_), _, _) => return err_both("token", "credential-process"), - (Some(_), _, Some(_), _) => return err_both("token", "secret-key"), - (_, Some(_), Some(_), _) => return err_both("credential-process", "secret-key"), - (_, _, None, Some(_)) => { - let registry = if is_crates_io { - "".to_string() - } else { - format!(" for registry `{}`", name.as_ref().unwrap()) - }; - return Err(format_err!( - "`secret-key-subject` was set but `secret-key` was not in the config{}.\n\ - Either set the `secret-key` or remove the `secret-key-subject`.", - registry - )); - } - (Some(token), _, _, _) => RegistryCredentialConfig::Token(token), - (_, Some(process), _, _) => RegistryCredentialConfig::Process(( - process.path.resolve_program(config), - process.args, - )), - (None, None, Some(key), subject) => { - RegistryCredentialConfig::AsymmetricKey((key, subject)) - } - (None, None, None, _) => { - if !is_crates_io { - // If we couldn't find a registry-specific credential, try the global credential process. - if let Some(process) = config - .get::>("registry.credential-process")? - .filter(|_| config.cli_unstable().credential_process) - { - return Ok(RegistryCredentialConfig::Process(( - process.path.resolve_program(config), - process.args, - ))); - } - } - RegistryCredentialConfig::None - } - }, - ) +/// Use the `[credential-alias]` table to see if the provider name has been aliased. +fn resolve_credential_alias(config: &Config, mut provider: PathAndArgs) -> Vec { + if provider.args.is_empty() { + let key = format!("credential-alias.{}", provider.path.raw_value()); + if let Ok(alias) = config.get::(&key) { + log::debug!("resolving credential alias '{key}' -> '{alias:?}'"); + provider = alias; + } + } + provider.args.insert( + 0, + provider + .path + .resolve_program(config) + .to_str() + .unwrap() + .to_string(), + ); + provider.args } #[derive(Debug, PartialEq)] @@ -403,18 +396,75 @@ my-registry = {{ index = "{}" }} } // Store a token in the cache for future calls. -pub fn cache_token(config: &Config, sid: &SourceId, token: Secret<&str>) { +pub fn cache_token_from_commandline(config: &Config, sid: &SourceId, token: Secret<&str>) { let url = sid.canonical_url(); config.credential_cache().insert( url.clone(), CredentialCacheValue { - from_commandline: true, - independent_of_endpoint: true, - token_value: token.owned(), + token_value: token.to_owned(), + expiration: None, + operation_independent: true, }, ); } +fn credential_action( + config: &Config, + sid: &SourceId, + action: Action<'_>, + headers: Vec, +) -> CargoResult { + let name = if sid.is_crates_io() { + Some(CRATES_IO_REGISTRY) + } else { + sid.alt_registry_key() + }; + let registry = RegistryInfo { + index_url: sid.url().as_str(), + name, + headers, + }; + let providers = credential_provider(config, sid)?; + for provider in providers { + let args: Vec<&str> = provider.iter().map(String::as_str).collect(); + let process = args[0]; + log::debug!("attempting credential provider: {args:?}"); + let provider: Box = match process { + "cargo:token" => Box::new(TokenCredential::new(config)), + "cargo:paseto" => Box::new(PasetoCredential::new(config)), + "cargo:basic" => Box::new(BasicProcessCredential {}), + "cargo:1password" => Box::new(cargo_credential_1password::OnePasswordCredential {}), + "cargo:wincred" => Box::new(cargo_credential_wincred::WindowsCredential {}), + "cargo:macos-keychain" => Box::new(cargo_credential_macos_keychain::MacKeychain {}), + process => Box::new(CredentialProcessCredential::new(process)), + }; + config.shell().verbose(|c| { + c.status( + "Credential", + format!( + "{} {action} {}", + args.join(" "), + sid.display_registry_name() + ), + ) + })?; + match provider.perform(®istry, &action, &args[1..]) { + Ok(response) => return Ok(response), + Err(cargo_credential::Error::UrlNotSupported) + | Err(cargo_credential::Error::NotFound) => {} + e => { + return e.with_context(|| { + format!( + "credential provider `{}` failed action `{action}`", + args.join(" ") + ) + }) + } + } + } + Err(cargo_credential::Error::NotFound.into()) +} + /// Returns the token to use for the given registry. /// If a `login_url` is provided and a token is not available, the /// login_url will be included in the returned error. @@ -422,9 +472,10 @@ pub fn auth_token( config: &Config, sid: &SourceId, login_url: Option<&Url>, - mutation: Option>, + operation: Operation<'_>, + headers: Vec, ) -> CargoResult { - match auth_token_optional(config, sid, mutation.as_ref())? { + match auth_token_optional(config, sid, operation, headers)? { Some(token) => Ok(token.expose()), None => Err(AuthorizationError { sid: sid.clone(), @@ -440,285 +491,94 @@ pub fn auth_token( fn auth_token_optional( config: &Config, sid: &SourceId, - mutation: Option<&'_ Mutation<'_>>, + operation: Operation<'_>, + headers: Vec, ) -> CargoResult>> { + log::trace!("token requested for {}", sid.display_registry_name()); let mut cache = config.credential_cache(); let url = sid.canonical_url(); - - if let Some(cache_token_value) = cache.get(url) { - // Tokens for endpoints that do not involve a mutation can always be reused. - // If the value is put in the cache by the command line, then we reuse it without looking at the configuration. - if cache_token_value.from_commandline - || cache_token_value.independent_of_endpoint - || mutation.is_none() + if let Some(cached_token) = cache.get(url) { + if cached_token + .expiration + .map(|exp| OffsetDateTime::now_utc() + Duration::minutes(1) < exp) + .unwrap_or(true) { - return Ok(Some(cache_token_value.token_value.clone())); + if cached_token.operation_independent || matches!(operation, Operation::Read) { + log::trace!("using token from in-memory cache"); + return Ok(Some(cached_token.token_value.clone())); + } + } else { + // Remove expired token from the cache + cache.remove(url); } } - let credential = registry_credential_config(config, sid)?; - let (independent_of_endpoint, token) = match credential { - RegistryCredentialConfig::None => return Ok(None), - RegistryCredentialConfig::Token(config_token) => (true, config_token), - RegistryCredentialConfig::Process(process) => { - // todo: PASETO with process - let (independent_of_endpoint, token) = - run_command(config, &process, sid, Action::Get)?.unwrap(); - (independent_of_endpoint, Secret::from(token)) - } - cred @ RegistryCredentialConfig::AsymmetricKey(..) => { - let token = asymmetric::public_token_from_credential(cred, sid, mutation)?; - (false, token) + let credential_response = credential_action(config, sid, Action::Get(operation), headers); + if let Some(e) = credential_response.as_ref().err() { + if let Some(e) = e.downcast_ref::() { + if matches!(e, cargo_credential::Error::NotFound) { + return Ok(None); + } } + } + let credential_response = credential_response?; + + let CredentialResponse::Get { + token, + cache: cache_control, + operation_independent, + } = credential_response + else { + bail!("credential provider produced unexpected response for `get` request: {credential_response:?}") + }; + let token = Secret::from(token); + log::trace!("found token"); + let expiration = match cache_control { + CacheControl::Expires(expiration) => Some(expiration), + CacheControl::Session => None, + CacheControl::Never | _ => return Ok(Some(token)), }; - if independent_of_endpoint || mutation.is_none() { - cache.insert( - url.clone(), - CredentialCacheValue { - from_commandline: false, - independent_of_endpoint, - token_value: token.clone(), - }, - ); - } + cache.insert( + url.clone(), + CredentialCacheValue { + token_value: token.clone(), + expiration, + operation_independent, + }, + ); Ok(Some(token)) } -/// A record of what kind of operation is happening that we should generate a token for. -pub enum Mutation<'a> { - /// Before we generate a crate file for the users attempt to publish, - /// we need to check if we are configured correctly to generate a token. - /// This variant is used to make sure that we can generate a token, - /// to error out early if the token is not configured correctly. - PrePublish, - /// The user is attempting to publish a crate. - Publish { - /// The name of the crate - name: &'a str, - /// The version of the crate - vers: &'a str, - /// The checksum of the crate file being uploaded - cksum: &'a str, - }, - /// The user is attempting to yank a crate. - Yank { - /// The name of the crate - name: &'a str, - /// The version of the crate - vers: &'a str, - }, - /// The user is attempting to unyank a crate. - Unyank { - /// The name of the crate - name: &'a str, - /// The version of the crate - vers: &'a str, - }, - /// The user is attempting to modify the owners of a crate. - Owners { - /// The name of the crate - name: &'a str, - }, -} - -enum Action { - Get, - Store(String), - Erase, -} - -/// Saves the given token. -pub fn login(config: &Config, sid: &SourceId, token: RegistryCredentialConfig) -> CargoResult<()> { - match registry_credential_config(config, sid)? { - RegistryCredentialConfig::Process(process) => { - let token = token - .as_token() - .expect("credential_process cannot use login with a secret_key") - .expose() - .to_owned(); - run_command(config, &process, sid, Action::Store(token))?; - } - _ => { - config::save_credentials(config, Some(token), &sid)?; - } - }; - Ok(()) -} - -/// Removes the token for the given registry. +/// Log out from the given registry. pub fn logout(config: &Config, sid: &SourceId) -> CargoResult<()> { - match registry_credential_config(config, sid)? { - RegistryCredentialConfig::Process(process) => { - run_command(config, &process, sid, Action::Erase)?; - } - _ => { - config::save_credentials(config, None, &sid)?; - } - }; - Ok(()) -} - -fn run_command( - config: &Config, - process: &(PathBuf, Vec), - sid: &SourceId, - action: Action, -) -> CargoResult> { - let index_url = sid.url().as_str(); - let cred_proc; - let (exe, args) = if process.0.to_str().unwrap_or("").starts_with("cargo:") { - cred_proc = sysroot_credential(config, process)?; - &cred_proc - } else { - process - }; - if !args.iter().any(|arg| arg.contains("{action}")) { - let msg = |which| { - format!( - "credential process `{}` cannot be used to {}, \ - the credential-process configuration value must pass the \ - `{{action}}` argument in the config to support this command", - exe.display(), - which - ) - }; - match action { - Action::Get => {} - Action::Store(_) => bail!(msg("log in")), - Action::Erase => bail!(msg("log out")), - } - } - // todo: PASETO with process - let independent_of_endpoint = true; - let action_str = match action { - Action::Get => "get", - Action::Store(_) => "store", - Action::Erase => "erase", - }; - let args: Vec<_> = args - .iter() - .map(|arg| { - arg.replace("{action}", action_str) - .replace("{index_url}", index_url) - }) - .collect(); - - let mut cmd = Command::new(&exe); - cmd.args(args) - .env(crate::CARGO_ENV, config.cargo_exe()?) - .env("CARGO_REGISTRY_INDEX_URL", index_url); - if sid.is_crates_io() { - cmd.env("CARGO_REGISTRY_NAME_OPT", "crates-io"); - } else if let Some(name) = sid.alt_registry_key() { - cmd.env("CARGO_REGISTRY_NAME_OPT", name); - } - match action { - Action::Get => { - cmd.stdout(Stdio::piped()); - } - Action::Store(_) => { - cmd.stdin(Stdio::piped()); - } - Action::Erase => {} - } - let mut child = cmd.spawn().with_context(|| { - let verb = match action { - Action::Get => "fetch", - Action::Store(_) => "store", - Action::Erase => "erase", - }; - format!( - "failed to execute `{}` to {} authentication token for registry `{}`", - exe.display(), - verb, - sid.display_registry_name(), - ) - })?; - let mut token = None; - match &action { - Action::Get => { - let mut buffer = String::new(); - log::debug!("reading into buffer"); - child - .stdout - .as_mut() - .unwrap() - .read_to_string(&mut buffer) - .with_context(|| { + let credential_response = credential_action(config, sid, Action::Logout, vec![]); + if let Some(e) = credential_response.as_ref().err() { + if let Some(e) = e.downcast_ref::() { + if matches!(e, cargo_credential::Error::NotFound) { + config.shell().status( + "Logout", format!( - "failed to read token from registry credential process `{}`", - exe.display() - ) - })?; - if let Some(end) = buffer.find('\n') { - if buffer.len() > end + 1 { - bail!( - "credential process `{}` returned more than one line of output; \ - expected a single token", - exe.display() - ); - } - buffer.truncate(end); + "not currently logged in to `{}`", + sid.display_registry_name() + ), + )?; + return Ok(()); } - token = Some((independent_of_endpoint, buffer)); - } - Action::Store(token) => { - writeln!(child.stdin.as_ref().unwrap(), "{}", token).with_context(|| { - format!( - "failed to send token to registry credential process `{}`", - exe.display() - ) - })?; } - Action::Erase => {} } - let status = child.wait().with_context(|| { - format!( - "registry credential process `{}` exit failure", - exe.display() - ) - })?; - if !status.success() { - let msg = match action { - Action::Get => "failed to authenticate to registry", - Action::Store(_) => "failed to store token to registry", - Action::Erase => "failed to erase token from registry", - }; - return Err(ProcessError::new( - &format!( - "registry credential process `{}` {} `{}`", - exe.display(), - msg, - sid.display_registry_name() - ), - Some(status), - None, - ) - .into()); - } - Ok(token) + let credential_response = credential_response?; + let CredentialResponse::Logout = credential_response else { + bail!("credential provider produced unexpected response for `logout` request: {credential_response:?}") + }; + Ok(()) } -/// Gets the path to the libexec processes in the sysroot. -fn sysroot_credential( - config: &Config, - process: &(PathBuf, Vec), -) -> CargoResult<(PathBuf, Vec)> { - let cred_name = process.0.to_str().unwrap().strip_prefix("cargo:").unwrap(); - let cargo = config.cargo_exe()?; - let root = cargo - .parent() - .and_then(|p| p.parent()) - .ok_or_else(|| format_err!("expected cargo path {}", cargo.display()))?; - let exe = root.join("libexec").join(format!( - "cargo-credential-{}{}", - cred_name, - std::env::consts::EXE_SUFFIX - )); - let mut args = process.1.clone(); - if !args.iter().any(|arg| arg == "{action}") { - args.push("{action}".to_string()); - } - Ok((exe, args)) +/// Log in to the given registry. +pub fn login(config: &Config, sid: &SourceId, options: LoginOptions<'_>) -> CargoResult<()> { + let credential_response = credential_action(config, sid, Action::Login(options), vec![])?; + let CredentialResponse::Login = credential_response else { + bail!("credential provider produced unexpected response for `login` request: {credential_response:?}") + }; + Ok(()) } diff --git a/src/cargo/util/config/mod.rs b/src/cargo/util/config/mod.rs index 4e6bca30212..8f1cfe073f5 100644 --- a/src/cargo/util/config/mod.rs +++ b/src/cargo/util/config/mod.rs @@ -70,20 +70,21 @@ use crate::core::compiler::rustdoc::RustdocExternMap; use crate::core::shell::Verbosity; use crate::core::{features, CliUnstable, Shell, SourceId, Workspace, WorkspaceRootConfig}; use crate::ops::RegistryCredentialConfig; -use crate::util::auth::Secret; use crate::util::errors::CargoResult; use crate::util::network::http::configure_http_handle; use crate::util::network::http::http_handle; -use crate::util::CanonicalUrl; -use crate::util::{internal, toml as cargo_toml}; +use crate::util::toml as cargo_toml; +use crate::util::{internal, CanonicalUrl}; use crate::util::{try_canonicalize, validate_package_name}; use crate::util::{FileLock, Filesystem, IntoUrl, IntoUrlWithBase, Rustc}; use anyhow::{anyhow, bail, format_err, Context as _}; +use cargo_credential::Secret; use cargo_util::paths; use curl::easy::Easy; use lazycell::LazyCell; use serde::de::IntoDeserializer as _; use serde::Deserialize; +use time::OffsetDateTime; use toml_edit::Item; use url::Url; @@ -146,13 +147,9 @@ enum WhyLoad { /// A previously generated authentication token and the data needed to determine if it can be reused. #[derive(Debug)] pub struct CredentialCacheValue { - /// If the command line was used to override the token then it must always be reused, - /// even if reading the configuration files would lead to a different value. - pub from_commandline: bool, - /// If nothing depends on which endpoint is being hit, then we can reuse the token - /// for any future request even if some of the requests involve mutations. - pub independent_of_endpoint: bool, pub token_value: Secret, + pub expiration: Option, + pub operation_independent: bool, } /// Configuration information for cargo. This is not specific to a build, it is information @@ -810,7 +807,7 @@ impl Config { /// /// See `get` for more details. pub fn get_string(&self, key: &str) -> CargoResult> { - self.get::>>(key) + self.get::>(key) } /// Get a config value that is expected to be a path. @@ -819,7 +816,7 @@ impl Config { /// directory separators. See `ConfigRelativePath::resolve_program` for /// more details. pub fn get_path(&self, key: &str) -> CargoResult> { - self.get::>>(key).map(|v| { + self.get::>(key).map(|v| { v.map(|v| Value { val: v.val.resolve_program(self), definition: v.definition, diff --git a/src/cargo/util/config/path.rs b/src/cargo/util/config/path.rs index a90cab2b268..bc53ffcfa79 100644 --- a/src/cargo/util/config/path.rs +++ b/src/cargo/util/config/path.rs @@ -10,6 +10,10 @@ use std::path::PathBuf; pub struct ConfigRelativePath(Value); impl ConfigRelativePath { + pub fn new(path: Value) -> ConfigRelativePath { + ConfigRelativePath(path) + } + /// Returns the underlying value. pub fn value(&self) -> &Value { &self.0 @@ -49,7 +53,7 @@ impl ConfigRelativePath { /// /// Typically you should use `ConfigRelativePath::resolve_program` on the path /// to get the actual program. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub struct PathAndArgs { pub path: ConfigRelativePath, pub args: Vec, @@ -76,3 +80,22 @@ impl<'de> serde::Deserialize<'de> for PathAndArgs { }) } } + +impl PathAndArgs { + /// Construct a PathAndArgs from a string. The string will be split on ascii whitespace, + /// with the first item being treated as a `ConfigRelativePath` to the executable, and subsequent + /// items as arguments. + pub fn from_whitespace_separated_string(p: &Value) -> PathAndArgs { + let mut iter = p.val.split_ascii_whitespace().map(str::to_string); + let val = iter.next().unwrap_or_default(); + let args = iter.collect(); + let crp = Value { + val, + definition: p.definition.clone(), + }; + PathAndArgs { + path: ConfigRelativePath(crp), + args, + } + } +} diff --git a/src/cargo/util/credential/adaptor.rs b/src/cargo/util/credential/adaptor.rs new file mode 100644 index 00000000000..5d8f7931322 --- /dev/null +++ b/src/cargo/util/credential/adaptor.rs @@ -0,0 +1,71 @@ +//! Credential provider that launches an external process that only outputs a credential + +use std::{ + io::Read, + process::{Command, Stdio}, +}; + +use cargo_credential::{ + Action, CacheControl, Credential, CredentialResponse, RegistryInfo, Secret, +}; + +pub struct BasicProcessCredential {} + +impl Credential for BasicProcessCredential { + fn perform( + &self, + registry: &RegistryInfo<'_>, + action: &Action<'_>, + args: &[&str], + ) -> Result { + match action { + Action::Get(_) => { + let mut args = args.iter(); + let exe = args.next() + .ok_or_else(||cargo_credential::Error::Other(format!("The first argument to the `cargo:basic` adaptor must be the path to the credential provider executable.")))?; + let args = args.map(|arg| arg.replace("{index_url}", registry.index_url)); + + let mut cmd = Command::new(exe); + cmd.args(args) + .env("CARGO_REGISTRY_INDEX_URL", registry.index_url); + if let Some(name) = registry.name { + cmd.env("CARGO_REGISTRY_NAME_OPT", name); + } + cmd.stdout(Stdio::piped()); + let mut child = cmd + .spawn() + .map_err(|e| cargo_credential::Error::Subprocess(e.to_string()))?; + let mut buffer = String::new(); + child + .stdout + .take() + .unwrap() + .read_to_string(&mut buffer) + .map_err(|e| cargo_credential::Error::Subprocess(e.to_string()))?; + if let Some(end) = buffer.find('\n') { + if buffer.len() > end + 1 { + return Err(cargo_credential::Error::Other(format!( + "process `{}` returned more than one line of output; \ + expected a single token", + exe + ))); + } + buffer.truncate(end); + } + let status = child.wait().expect("process was started"); + if !status.success() { + return Err(cargo_credential::Error::Subprocess(format!( + "process `{}` failed with status `{status}`", + exe + ))); + } + Ok(CredentialResponse::Get { + token: Secret::from(buffer), + cache: CacheControl::Session, + operation_independent: true, + }) + } + _ => Err(cargo_credential::Error::OperationNotSupported), + } + } +} diff --git a/src/cargo/util/credential/mod.rs b/src/cargo/util/credential/mod.rs new file mode 100644 index 00000000000..7baf7d2a1cf --- /dev/null +++ b/src/cargo/util/credential/mod.rs @@ -0,0 +1,8 @@ +//! Built-in Cargo credential providers + +#![allow(clippy::print_stderr)] + +pub mod adaptor; +pub mod paseto; +pub mod process; +pub mod token; diff --git a/src/cargo/util/credential/paseto.rs b/src/cargo/util/credential/paseto.rs new file mode 100644 index 00000000000..39a47100188 --- /dev/null +++ b/src/cargo/util/credential/paseto.rs @@ -0,0 +1,216 @@ +//! Credential provider that implements PASETO asymmetric tokens stored in Cargo's config. + +use cargo_credential::{ + Action, CacheControl, Credential, CredentialResponse, Error, Operation, RegistryInfo, Secret, +}; +use pasetors::{ + keys::{AsymmetricKeyPair, AsymmetricPublicKey, AsymmetricSecretKey, Generate}, + paserk::FormatAsPaserk, +}; +use time::{format_description::well_known::Rfc3339, OffsetDateTime}; +use url::Url; + +use crate::{ + core::SourceId, + ops::RegistryCredentialConfig, + util::{auth::registry_credential_config_raw, config}, + Config, +}; + +/// The main body of an asymmetric token as describe in RFC 3231. +#[derive(serde::Serialize)] +struct Message<'a> { + iat: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + sub: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + mutation: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + name: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + vers: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + cksum: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + challenge: Option<&'a str>, + /// This field is not yet used. This field can be set to a value >1 to indicate a breaking change in the token format. + #[serde(skip_serializing_if = "Option::is_none")] + v: Option, +} +/// The footer of an asymmetric token as describe in RFC 3231. +#[derive(serde::Serialize)] +struct Footer<'a> { + url: &'a str, + kip: pasetors::paserk::Id, +} + +pub(crate) struct PasetoCredential<'a> { + config: &'a Config, +} + +impl<'a> PasetoCredential<'a> { + pub fn new(config: &'a Config) -> Self { + Self { config } + } +} + +impl<'a> Credential for PasetoCredential<'a> { + fn perform( + &self, + registry: &RegistryInfo<'_>, + action: &Action<'_>, + _args: &[&str], + ) -> Result { + let index_url = Url::parse(registry.index_url).map_err(|e| e.to_string())?; + let sid = if let Some(name) = registry.name { + SourceId::for_alt_registry(&index_url, name) + } else { + SourceId::for_registry(&index_url) + } + .map_err(|e| e.to_string())?; + + let reg_cfg = registry_credential_config_raw(self.config, &sid) + .map_err(|e| Error::Other(e.to_string()))?; + + match action { + Action::Get(operation) => { + let Some(reg_cfg) = reg_cfg else { + return Err(Error::NotFound); + }; + let Some(secret_key) = reg_cfg.secret_key.as_ref() else { + return Err(Error::NotFound); + }; + + let secret_key_subject = reg_cfg.secret_key_subject; + let secret: Secret> = secret_key + .val + .as_ref() + .map(|key| key.as_str().try_into()) + .transpose() + .map_err(|e| Error::Other(format!("failed to load private key: {e}")))?; + let public: AsymmetricPublicKey = secret + .as_ref() + .map(|key| key.try_into()) + .transpose() + .map_err(|e| { + Error::Other(format!("failed to load public key from private key: {e}")) + })? + .expose(); + let kip: pasetors::paserk::Id = (&public).into(); + + let iat = OffsetDateTime::now_utc(); + + let message = Message { + iat: &iat.format(&Rfc3339).unwrap(), + sub: secret_key_subject.as_deref(), + mutation: match operation { + Operation::Publish { .. } => Some("publish"), + Operation::Yank { .. } => Some("yank"), + Operation::Unyank { .. } => Some("unyank"), + Operation::Owners { .. } => Some("owners"), + _ => None, + }, + name: match operation { + Operation::Publish { name, .. } + | Operation::Yank { name, .. } + | Operation::Unyank { name, .. } + | Operation::Owners { name, .. } => Some(name), + _ => None, + }, + vers: match operation { + Operation::Publish { vers, .. } + | Operation::Yank { vers, .. } + | Operation::Unyank { vers, .. } => Some(vers), + _ => None, + }, + cksum: match operation { + Operation::Publish { cksum, .. } => Some(cksum), + _ => None, + }, + challenge: None, // todo: PASETO with challenges + v: None, + }; + let footer = Footer { + url: ®istry.index_url, + kip, + }; + + // Only read operations can be cached with asymmetric tokens. + let cache = match operation { + Operation::Read => CacheControl::Session, + _ => CacheControl::Never, + }; + + let token = secret + .map(|secret| { + pasetors::version3::PublicToken::sign( + &secret, + serde_json::to_string(&message) + .expect("cannot serialize") + .as_bytes(), + Some( + serde_json::to_string(&footer) + .expect("cannot serialize") + .as_bytes(), + ), + None, + ) + }) + .transpose() + .map_err(|e| Error::Other(format!("failed to sign request: {e}")))?; + + Ok(CredentialResponse::Get { + token, + cache, + operation_independent: false, + }) + } + Action::Login(options) => { + let new_token; + let secret_key: Secret; + if let Some(key) = &options.token { + secret_key = key.clone().map(str::to_string); + } else { + let kp = AsymmetricKeyPair::::generate().unwrap(); + secret_key = Secret::default().map(|mut key| { + FormatAsPaserk::fmt(&kp.secret, &mut key).unwrap(); + key + }); + } + + if let Some(p) = paserk_public_from_paserk_secret(secret_key.as_deref()) { + eprintln!("{}", &p); + } else { + return Err(Error::Other( + "not a validly formatted PASERK secret key".to_string(), + )); + } + new_token = RegistryCredentialConfig::AsymmetricKey((secret_key, None)); + config::save_credentials(self.config, Some(new_token), &sid) + .map_err(|e| Error::Other(e.to_string()))?; + Ok(CredentialResponse::Login) + } + Action::Logout => { + config::save_credentials(self.config, None, &sid) + .map_err(|e| Error::Other(e.to_string()))?; + Ok(CredentialResponse::Logout) + } + _ => Err(Error::OperationNotSupported), + } + } +} + +/// Checks that a secret key is valid, and returns the associated public key in Paserk format. +pub(crate) fn paserk_public_from_paserk_secret(secret_key: Secret<&str>) -> Option { + let secret: Secret> = + secret_key.map(|key| key.try_into()).transpose().ok()?; + let public: AsymmetricPublicKey = secret + .as_ref() + .map(|key| key.try_into()) + .transpose() + .ok()? + .expose(); + let mut paserk_pub_key = String::new(); + FormatAsPaserk::fmt(&public, &mut paserk_pub_key).unwrap(); + Some(paserk_pub_key) +} diff --git a/src/cargo/util/credential/process.rs b/src/cargo/util/credential/process.rs new file mode 100644 index 00000000000..aafb2574ea8 --- /dev/null +++ b/src/cargo/util/credential/process.rs @@ -0,0 +1,79 @@ +//! Credential provider that launches an external process using Cargo's credential +//! protocol. + +use std::{ + io::{BufRead, BufReader, Write}, + path::PathBuf, + process::{Command, Stdio}, +}; + +use cargo_credential::{ + Action, Credential, CredentialHello, CredentialRequest, CredentialResponse, RegistryInfo, +}; + +pub struct CredentialProcessCredential { + path: PathBuf, +} + +impl<'a> CredentialProcessCredential { + pub fn new(path: &str) -> Self { + Self { + path: PathBuf::from(path), + } + } +} + +impl<'a> Credential for CredentialProcessCredential { + fn perform( + &self, + registry: &RegistryInfo<'_>, + action: &Action<'_>, + args: &[&str], + ) -> Result { + let mut cmd = Command::new(&self.path); + cmd.stdout(Stdio::piped()); + cmd.stdin(Stdio::piped()); + cmd.arg("--cargo-plugin"); + log::debug!("credential-process: {cmd:?}"); + let mut child = cmd.spawn().map_err(|e| { + cargo_credential::Error::Subprocess(format!( + "failed to spawn credential process `{}`: {e}", + self.path.display() + )) + })?; + let mut output_from_child = BufReader::new(child.stdout.take().unwrap()); + let mut input_to_child = child.stdin.take().unwrap(); + let mut buffer = String::new(); + output_from_child.read_line(&mut buffer)?; + let credential_hello: CredentialHello = serde_json::from_str(&buffer)?; + log::debug!("credential-process > {credential_hello:?}"); + + let req = CredentialRequest { + v: cargo_credential::PROTOCOL_VERSION_1, + action: action.clone(), + registry: registry.clone(), + args: args.to_vec(), + }; + let request = serde_json::to_string(&req)?; + log::debug!("credential-process < {req:?}"); + writeln!(input_to_child, "{request}")?; + + buffer.clear(); + output_from_child.read_line(&mut buffer)?; + let response: Result = + serde_json::from_str(&buffer)?; + log::debug!("credential-process > {response:?}"); + drop(input_to_child); + let status = child.wait().expect("credential process never started"); + if !status.success() { + return Err(cargo_credential::Error::Subprocess(format!( + "credential process `{}` failed with status {}`", + self.path.display(), + status + )) + .into()); + } + log::trace!("credential process exited successfully"); + response + } +} diff --git a/src/cargo/util/credential/token.rs b/src/cargo/util/credential/token.rs new file mode 100644 index 00000000000..7cd6e1e3503 --- /dev/null +++ b/src/cargo/util/credential/token.rs @@ -0,0 +1,100 @@ +//! Credential provider that uses plaintext tokens in Cargo's config. + +use cargo_credential::{Action, CacheControl, Credential, CredentialResponse, Error, RegistryInfo}; +use url::Url; + +use crate::{ + core::SourceId, + ops::RegistryCredentialConfig, + util::{auth::registry_credential_config_raw, config}, + Config, +}; + +pub struct TokenCredential<'a> { + config: &'a Config, +} + +impl<'a> TokenCredential<'a> { + pub fn new(config: &'a Config) -> Self { + Self { config } + } +} + +impl<'a> Credential for TokenCredential<'a> { + fn perform( + &self, + registry: &RegistryInfo<'_>, + action: &Action<'_>, + _args: &[&str], + ) -> Result { + let index_url = Url::parse(registry.index_url).map_err(|e| e.to_string())?; + let sid = if let Some(name) = registry.name { + SourceId::for_alt_registry(&index_url, name) + } else { + SourceId::for_registry(&index_url) + } + .map_err(|e| e.to_string())?; + let previous_token = registry_credential_config_raw(self.config, &sid) + .map_err(|e| Error::Other(e.to_string()))? + .and_then(|c| c.token); + + match action { + Action::Get(_) => { + let token = previous_token.ok_or_else(|| Error::NotFound)?.val; + Ok(CredentialResponse::Get { + token, + cache: CacheControl::Session, + operation_independent: true, + }) + } + Action::Login(options) => { + // Automatically remove `cargo login` from an inputted token to + // allow direct pastes from `registry.host()`/me. + let new_token = cargo_credential::read_token(options, registry)? + .map(|line| line.replace("cargo login", "").trim().to_string()); + + crates_io::check_token(new_token.as_ref().expose()) + .map_err(|e| Error::Other(e.to_string()))?; + config::save_credentials( + self.config, + Some(RegistryCredentialConfig::Token(new_token)), + &sid, + ) + .map_err(|e| Error::Other(e.to_string()))?; + let _ = self.config.shell().status( + "Login", + format!("token for `{}` saved", sid.display_registry_name()), + ); + Ok(CredentialResponse::Login) + } + Action::Logout => { + if previous_token.is_none() { + return Err(Error::NotFound); + } + let reg_name = sid.display_registry_name(); + config::save_credentials(self.config, None, &sid) + .map_err(|e| Error::Other(e.to_string()))?; + let _ = self.config.shell().status( + "Logout", + format!("token for `{reg_name}` has been removed from local storage"), + ); + let location = if sid.is_crates_io() { + "".to_string() + } else { + // The URL for the source requires network access to load the config. + // That could be a fairly heavy operation to perform just to provide a + // help message, so for now this just provides some generic text. + // Perhaps in the future this could have an API to fetch the config if + // it is cached, but avoid network access otherwise? + format!("the `{reg_name}` website") + }; + eprintln!( + "note: This does not revoke the token on the registry server.\n \ + If you need to revoke the token, visit {location} and follow the instructions there." + ); + Ok(CredentialResponse::Logout) + } + _ => Err(Error::OperationNotSupported), + } + } +} diff --git a/src/cargo/util/errors.rs b/src/cargo/util/errors.rs index 5c7eebcdb71..91258c53c08 100644 --- a/src/cargo/util/errors.rs +++ b/src/cargo/util/errors.rs @@ -83,8 +83,19 @@ impl HttpNotSuccessful { } write!(result, ", got {}\n", self.code).unwrap(); if show_headers { - if !self.headers.is_empty() { - write!(result, "debug headers:\n{}\n", self.headers.join("\n")).unwrap(); + let headers: Vec<_> = self + .headers + .iter() + .filter(|header| { + let Some((name, _)) = header.split_once(":") else { return false }; + DEBUG_HEADERS.contains(&name.to_ascii_lowercase().trim()) + }) + .collect(); + if !headers.is_empty() { + writeln!(result, "debug headers:").unwrap(); + for header in headers { + writeln!(result, "{header}").unwrap(); + } } } write!(result, "body:\n{body}").unwrap(); diff --git a/src/cargo/util/mod.rs b/src/cargo/util/mod.rs index e72f8183b5f..6cefb97c2b7 100644 --- a/src/cargo/util/mod.rs +++ b/src/cargo/util/mod.rs @@ -36,6 +36,7 @@ pub mod command_prelude; pub mod config; mod counter; pub mod cpu; +pub mod credential; mod dependency_queue; pub mod diagnostic_server; pub mod edit_distance; diff --git a/src/doc/src/reference/unstable.md b/src/doc/src/reference/unstable.md index dfef2281a49..df9fb8ae779 100644 --- a/src/doc/src/reference/unstable.md +++ b/src/doc/src/reference/unstable.md @@ -1000,7 +1000,7 @@ It is intended for the rare use cases like "cryptographic proof that the central Both fields can be set with `cargo login --registry=name --private-key --private-key-subject="subject"` which will prompt you to put in the key value. -A registry can have at most one of `private-key`, `token`, or `credential-process` set. +A registry can have at most one of `private-key` or `token` set. All PASETOs will include `iat`, the current time in ISO 8601 format. Cargo will include the following where appropriate: - `sub` an optional, non-secret string chosen by the registry that is expected to be claimed with every request. The value will be the `private-key-subject` from the `config.toml` file. @@ -1026,54 +1026,78 @@ If a claim should be expected for the request but is missing in the PASETO then The `credential-process` feature adds a config setting to fetch registry authentication tokens by calling an external process. -Token authentication is used by the [`cargo login`], [`cargo publish`], -[`cargo owner`], [`cargo yank`], and [`cargo logout`] commands. - To use this feature, you must pass the `-Z credential-process` flag on the -command-line. Additionally, you must remove any current tokens currently saved -in the [`credentials.toml` file] (which can be done with the [`cargo logout`] command). +command-line. #### `credential-process` Configuration To configure which process to run to fetch the token, specify the process in -the `registry` table in a [config file]: +the `registry` table in a [config file] with spaces separating arguments. If the +path to the provider or its arguments contain spaces, then it mused be defined in +the `credential-alias` table and referenced instead. ```toml [registry] -credential-process = "/usr/bin/cargo-creds" +global-credential-providers = ["/usr/bin/cargo-creds"] ``` -If you want to use a different process for a specific registry, it can be +The provider at the end of the list will be attempted first. This ensures +that when config files are merged, files closer to the project (and ultimatly +environment variables) have precedence. + +In this example, the `my-provider` provider will be attempted first, and if +it cannot provide credentials, then the `cargo:token` provider will be used. + +```toml +[registry] +global-credential-providers = ['cargo:token', 'my-provider'] +``` + +If you want to use a different provider for a specific registry, it can be specified in the `registries` table: ```toml [registries.my-registry] -credential-process = "/usr/bin/cargo-creds" +global-credential-provider = "/usr/bin/cargo-creds" ``` The value can be a string with spaces separating arguments or it can be a TOML array of strings. -Command-line arguments allow special placeholders which will be replaced with -the corresponding value: - -* `{name}` --- The name of the registry. -* `{api_url}` --- The base URL of the registry API endpoints. -* `{action}` --- The authentication action (described below). - -Process names with the prefix `cargo:` are loaded from the `libexec` directory -next to cargo. Several experimental credential wrappers are included with -Cargo, and this provides convenient access to them: +For commonly-used providers, or providers that need to contain spaces in the arguments +or path, the `credential-alias` table can be used. These aliases can be referenced +in `credential-provider` or `global-credential-providers`. ```toml +[credential-alias] +my-alias = ["/usr/bin/cargo-creds", "--argument"] + [registry] -credential-process = "cargo:macos-keychain" +global-credential-providers = ["cargo:token", "my-alias"] ``` -The current wrappers are: - -* `cargo:macos-keychain`: Uses the macOS Keychain to store the token. -* `cargo:wincred`: Uses the Windows Credential Manager to store the token. +#### Built-in providers + +Cargo now includes several built-in credential providers. These providers are +executed within the Cargo process. They are identified with the `cargo:` prefix. + +* `cargo:token` - Uses Cargo's config and `credentials.toml` to store the token (default). +* `cargo:wincred` - Uses the Windows Credential Manager to store the token. +* `cargo:macos-keychain` - Uses the macOS Keychain to store the token. +* `cargo:basic` - A basic authenticator is a process that returns a token on stdout. Newlines + will be trimmed. The process inherits the user's stdin and stderr. It should + exit 0 on success, and nonzero on error. + + With this form, [`cargo login`] and [`cargo logout`] are not supported and + return an error if used. + + The following environment variables will be provided to the executed command: + + * `CARGO` --- Path to the `cargo` binary executing the command. + * `CARGO_REGISTRY_INDEX_URL` --- The URL of the registry index. + * `CARGO_REGISTRY_NAME_OPT` --- Optional name of the registry. Should not be used as a storage key. Not always available. + +* `cargo:paseto` - implements asymmetric token support (RFC3231) as a credential provider. * `cargo:1password`: Uses the 1password `op` CLI to store the token. You must install the `op` CLI from the [1password website](https://1password.com/downloads/command-line/). You must run `op @@ -1098,72 +1122,149 @@ In the config, use a path to the binary like this: ```toml [registry] -credential-process = "cargo-credential-gnome-secret {action}" +global-credential-providers = ["cargo-credential-gnome-secret"] ``` -#### `credential-process` Interface +#### JSON Interface +When using an external credential provider, Cargo communicates with the credential +provider using stdin/stdout messages passed as a single line of JSON. -There are two different kinds of token processes that Cargo supports. The -simple "basic" kind will only be called by Cargo when it needs a token. This -is intended for simple and easy integration with password managers, that can -often use pre-existing tooling. The more advanced "Cargo" kind supports -different actions passed as a command-line argument. This is intended for more -pleasant integration experience, at the expense of requiring a Cargo-specific -process to glue to the password manager. Cargo will determine which kind is -supported by the `credential-process` definition. If it contains the -`{action}` argument, then it uses the advanced style, otherwise it assumes it -only supports the "basic" kind. +Cargo will always execute the credential provider with the `--cargo-plugin` argument. +This enables a credential provider executable to have additional functionality beyond +how Cargo uses it. -##### Basic authenticator +The messages here have additional newlines added for readability. +Actual messages must not contain newlines. -A basic authenticator is a process that returns a token on stdout. Newlines -will be trimmed. The process inherits the user's stdin and stderr. It should -exit 0 on success, and nonzero on error. - -With this form, [`cargo login`] and [`cargo logout`] are not supported and -return an error if used. - -##### Cargo authenticator - -The protocol between the Cargo and the process is very basic, intended to -ensure the credential process is kept as simple as possible. Cargo will -execute the process with the `{action}` argument indicating which action to -perform: - -* `store` --- Store the given token in secure storage. -* `get` --- Get a token from storage. -* `erase` --- Remove a token from storage. - -The `cargo login` command uses `store` to save a token. Commands that require -authentication, like `cargo publish`, uses `get` to retrieve a token. `cargo -logout` uses the `erase` command to remove a token. - -The process inherits the user's stderr, so the process can display messages. -Some values are passed in via environment variables (see below). The expected -interactions are: - -* `store` --- The token is sent to the process's stdin, terminated by a newline. - The process should store the token keyed off the registry name. If the - process fails, it should exit with a nonzero exit status. +##### Credential hello +* Sent by: credential provider +* Purpose: used to identify the supported protocols on process startup +```javascript +{ + "v":[1] +} +``` -* `get` --- The process should send the token to its stdout (trailing newline - will be trimmed). The process inherits the user's stdin, should it need to - receive input. +##### Login request +* Sent by: Cargo +* Purpose: collect and store credentials +```javascript +{ + // Protocol version + "v":1, + // Action to perform: login + "kind":"login", + // Registry information + "registry":{"index-url":"sparse+https://registry-url/index/", "name": "my-registry"}, +} +``` - If the process is unable to fulfill the request, it should exit with a - nonzero exit code. +##### Read request +* Sent by: Cargo +* Purpose: Get the credential for reading crate information +```javascript +{ + // Protocol version + "v":1, + // Request kind: get credentials + "kind":"get", + // Action to perform: read crate information + "operation":"read", + // Registry information + "registry":{"index-url":"sparse+https://registry-url/index/", "name": "my-registry"}, + // Additional command-line args + "args":[] +} +``` -* `erase` --- The process should remove the token associated with the registry - name. If the token is not found, the process should exit with a 0 exit - status. +##### Publish request +* Sent by: Cargo +* Purpose: Get the credential for publishing a crate +```javascript +{ + // Protocol version + "v":1, + // Request kind: get credentials + "kind":"get", + // Action to perform: publish crate + "operation":"publish", + // Crate name + "name":"sample", + // Crate version + "vers":"0.1.0", + // Crate checksum + "cksum":"...", + // Registry information + "registry":{"index-url":"sparse+https://registry-url/index/", "name": "my-registry"}, + // Additional command-line args + "args":[] +} +``` -##### Environment +##### Success response +* Sent by: credential process +* Purpose: Gives the credential to Cargo +```javascript +{"Ok":{ + // Response kind: this was a get request kind + "kind":"get", + // Token to send to the registry + "token":"...", + // Cache control. Can be one of the following: + // * "never" + // * "session" + // * { "expires": UNIX timestamp } + "cache":{"expires":1684251794}, + // Is the token operation independent? + "operation_independent":true +}} +``` -The following environment variables will be provided to the executed command: +##### Failure response +* Sent by: credential process +* Purpose: Gives error information to Cargo +```javascript +{"Err":{ + // Error: the credential provider does not support the + // registry + "kind":"url-not-supported", + + // Error: The credential could not be found in the provider. + // using `cargo login --registry ...`. + "kind":"not-found", + + // Error: something else has failed + "kind":"other", + "detail": "free form string error message" +}} +``` -* `CARGO` --- Path to the `cargo` binary executing the command. -* `CARGO_REGISTRY_INDEX_URL` --- The URL of the registry index. -* `CARGO_REGISTRY_NAME_OPT` --- Optional name of the registry. Should not be used as a storage key. Not always available. +##### Example communication to request a token for reading: +1. Cargo spawns the credential process, capturing stdin and stdout. +2. Credential process sends the Hello message to Cargo + ```javascript + { "v": [1] } + ``` +3. Cargo sends the CredentialRequest message to the credential process (newlines added for readability). + ```javascript + { + "v": 1, + "kind": "get", + "operation": "read", + "registry":{"index-url":"sparse+https://registry-url/index/", "name":"ado2"}, + "args":[] + } + ``` +4. Credential process sends the CredentialResponse to Cargo (newlines added for readability). + ```javascript + { + "token": "...", + "cache": "session", + "operation_independent": false + } + ``` +5. Credential process exits +6. Cargo uses the token for the remainder of the session (until Cargo exits) when interacting with this registry. [`cargo login`]: ../commands/cargo-login.md [`cargo logout`]: ../commands/cargo-logout.md diff --git a/tests/testsuite/credential_process.rs b/tests/testsuite/credential_process.rs index 8c202c6a3ca..c04ddcf421d 100644 --- a/tests/testsuite/credential_process.rs +++ b/tests/testsuite/credential_process.rs @@ -1,8 +1,7 @@ //! Tests for credential-process. -use cargo_test_support::registry::TestRegistry; +use cargo_test_support::registry::{Package, TestRegistry}; use cargo_test_support::{basic_manifest, cargo_process, paths, project, registry, Project}; -use std::fs::{self, read_to_string}; fn toml_bin(proj: &Project, name: &str) -> String { proj.bin(name).display().to_string().replace('\\', "\\\\") @@ -24,7 +23,7 @@ fn gated() { ".cargo/config", r#" [registry] - credential-process = "false" + credential-provider = ["false"] "#, ) .file("Cargo.toml", &basic_manifest("foo", "1.0.0")) @@ -65,80 +64,6 @@ or use environment variable CARGO_REGISTRIES_ALTERNATIVE_TOKEN .run(); } -#[cargo_test] -fn warn_both_token_and_process() { - // Specifying both credential-process and a token in config should issue a warning. - let _server = registry::RegistryBuilder::new() - .http_api() - .http_index() - .alternative() - .no_configure_token() - .build(); - let p = project() - .file( - ".cargo/config", - r#" - [registries.alternative] - token = "alternative-sekrit" - credential-process = "false" - "#, - ) - .file( - "Cargo.toml", - r#" - [package] - name = "foo" - version = "0.1.0" - description = "foo" - authors = [] - license = "MIT" - homepage = "https://example.com/" - "#, - ) - .file("src/lib.rs", "") - .build(); - - p.cargo("publish --no-verify --registry alternative -Z credential-process") - .masquerade_as_nightly_cargo(&["credential-process"]) - .with_status(101) - .with_stderr( - "\ -[UPDATING] [..] -[ERROR] both `token` and `credential-process` were specified in the config for registry `alternative`. -Only one of these values may be set, remove one or the other to proceed. -", - ) - .run(); - - // Try with global credential-process, and registry-specific `token`. - // This should silently use the config token, and not run the "false" exe. - p.change_file( - ".cargo/config", - r#" - [registry] - credential-process = "false" - - [registries.alternative] - token = "alternative-sekrit" - "#, - ); - p.cargo("publish --no-verify --registry alternative -Z credential-process") - .masquerade_as_nightly_cargo(&["credential-process"]) - .with_stderr( - "\ -[UPDATING] [..] -[PACKAGING] foo v0.1.0 [..] -[PACKAGED] [..] -[UPLOADING] foo v0.1.0 [..] -[UPLOADED] foo v0.1.0 [..] -note: Waiting [..] -You may press ctrl-c [..] -[PUBLISHED] foo v0.1.0 [..] -", - ) - .run(); -} - /// Setup for a test that will issue a command that needs to fetch a token. /// /// This does the following: @@ -158,29 +83,14 @@ fn get_token_test() -> (Project, TestRegistry) { )) .alternative() .http_api() + .http_index() + .auth_required() .build(); - // The credential process to use. - let cred_proj = project() - .at("cred_proj") - .file("Cargo.toml", &basic_manifest("test-cred", "1.0.0")) - .file( - "src/main.rs", - r#" - use std::fs::File; - use std::io::Write; - fn main() { - let mut f = File::options() - .write(true) - .create(true) - .append(true) - .open("runs.log") - .unwrap(); - write!(f, "+"); - println!("sekrit"); - } "#, - ) - .build(); - cred_proj.cargo("build").run(); + + let provider = build_provider( + "test-cred", + r#"{"Ok":{"kind":"get","token":"sekrit","cache":"session","operation_independent":false}}"#, + ); let p = project() .file( @@ -189,10 +99,9 @@ fn get_token_test() -> (Project, TestRegistry) { r#" [registries.alternative] index = "{}" - credential-process = ["{}"] + credential-provider = ["{provider}"] "#, server.index_url(), - toml_bin(&cred_proj, "test-cred") ), ) .file( @@ -202,7 +111,6 @@ fn get_token_test() -> (Project, TestRegistry) { name = "foo" version = "0.1.0" description = "foo" - authors = [] license = "MIT" homepage = "https://example.com/" "#, @@ -217,24 +125,22 @@ fn publish() { // Checks that credential-process is used for `cargo publish`. let (p, _t) = get_token_test(); - p.cargo("publish --no-verify --registry alternative -Z credential-process") + p.cargo("publish --no-verify --registry alternative -Z credential-process -Z registry-auth") .masquerade_as_nightly_cargo(&["credential-process"]) .with_stderr( - "\ -[UPDATING] [..] + r#"[UPDATING] [..] +{"v":1,"registry":{"index-url":"[..]","name":"alternative","headers":[..]},"kind":"get","operation":"read","args":[]} [PACKAGING] foo v0.1.0 [..] [PACKAGED] [..] +{"v":1,"registry":{"index-url":"[..]","name":"alternative"},"kind":"get","operation":"publish","name":"foo","vers":"0.1.0","cksum":"[..]","args":[]} [UPLOADING] foo v0.1.0 [..] [UPLOADED] foo v0.1.0 [..] note: Waiting [..] You may press ctrl-c [..] [PUBLISHED] foo v0.1.0 [..] -", +"#, ) .run(); - - let calls = read_to_string(p.root().join("runs.log")).unwrap().len(); - assert_eq!(calls, 1); } #[cargo_test] @@ -242,15 +148,8 @@ fn basic_unsupported() { // Non-action commands don't support login/logout. let registry = registry::RegistryBuilder::new() .no_configure_token() + .credential_provider(&["cargo:basic", "false"]) .build(); - cargo_util::paths::append( - &paths::home().join(".cargo/config"), - br#" - [registry] - credential-process = "false" - "#, - ) - .unwrap(); cargo_process("login -Z credential-process abcdefg") .replace_crates_io(registry.index_url()) @@ -259,9 +158,10 @@ fn basic_unsupported() { .with_stderr( "\ [UPDATING] crates.io index -[ERROR] credential process `false` cannot be used to log in, \ -the credential-process configuration value must pass the \ -`{action}` argument in the config to support this command +[ERROR] credential provider `cargo:basic false` failed action `login` + +Caused by: + credential provider does not support the requested operation ", ) .run(); @@ -272,9 +172,10 @@ the credential-process configuration value must pass the \ .with_status(101) .with_stderr( "\ -[ERROR] credential process `false` cannot be used to log out, \ -the credential-process configuration value must pass the \ -`{action}` argument in the config to support this command +[ERROR] credential provider `cargo:basic false` failed action `logout` + +Caused by: + credential provider does not support the requested operation ", ) .run(); @@ -282,222 +183,385 @@ the credential-process configuration value must pass the \ #[cargo_test] fn login() { + let registry = registry::RegistryBuilder::new() + .no_configure_token() + .credential_provider(&[&build_provider("test-cred", r#"{"Ok": {"kind": "login"}}"#)]) + .build(); + + cargo_process("login -Z credential-process abcdefg") + .masquerade_as_nightly_cargo(&["credential-process"]) + .replace_crates_io(registry.index_url()) + .with_stderr( + r#"[UPDATING] [..] +{"v":1,"registry":{"index-url":"https://github.com/rust-lang/crates.io-index","name":"crates-io"},"kind":"login","token":"abcdefg","login-url":"[..]","args":[]} +"#, + ) + .run(); +} + +#[cargo_test] +fn logout() { let server = registry::RegistryBuilder::new() .no_configure_token() + .credential_provider(&[&build_provider( + "test-cred", + r#"{"Ok": {"kind": "logout"}}"#, + )]) .build(); - // The credential process to use. + + cargo_process("logout -Z credential-process") + .masquerade_as_nightly_cargo(&["credential-process"]) + .replace_crates_io(server.index_url()) + .with_stderr( + r#"{"v":1,"registry":{"index-url":"https://github.com/rust-lang/crates.io-index","name":"crates-io"},"kind":"logout","args":[]} +"#, + ) + .run(); +} + +#[cargo_test] +fn yank() { + let (p, _t) = get_token_test(); + + p.cargo("yank --version 0.1.0 --registry alternative -Zcredential-process -Zregistry-auth") + .masquerade_as_nightly_cargo(&["credential-process"]) + .with_stderr( + r#"[UPDATING] [..] +{"v":1,"registry":{"index-url":"[..]","name":"alternative","headers":[..]},"kind":"get","operation":"read","args":[]} +{"v":1,"registry":{"index-url":"[..]","name":"alternative"},"kind":"get","operation":"yank","name":"foo","vers":"0.1.0","args":[]} +[YANK] foo@0.1.0 +"#, + ) + .run(); +} + +#[cargo_test] +fn owner() { + let (p, _t) = get_token_test(); + + p.cargo("owner --add username --registry alternative -Zcredential-process -Zregistry-auth") + .masquerade_as_nightly_cargo(&["credential-process"]) + .with_stderr( + r#"[UPDATING] [..] +{"v":1,"registry":{"index-url":"[..]","name":"alternative","headers":[..]},"kind":"get","operation":"read","args":[]} +{"v":1,"registry":{"index-url":"[..]","name":"alternative"},"kind":"get","operation":"owners","name":"foo","args":[]} +[OWNER] completed! +"#, + ) + .run(); +} + +#[cargo_test] +fn invalid_token_output() { + // Error when credential process does not output the expected format for a token. let cred_proj = project() .at("cred_proj") .file("Cargo.toml", &basic_manifest("test-cred", "1.0.0")) - .file( - "src/main.rs", - r#" - use std::io::Read; - - fn main() {{ - assert_eq!(std::env::var("CARGO_REGISTRY_NAME_OPT").unwrap(), "crates-io"); - assert_eq!(std::env::var("CARGO_REGISTRY_INDEX_URL").unwrap(), "https://github.com/rust-lang/crates.io-index"); - assert_eq!(std::env::args().skip(1).next().unwrap(), "store"); - let mut buffer = String::new(); - std::io::stdin().read_to_string(&mut buffer).unwrap(); - assert_eq!(buffer, "abcdefg\n"); - std::fs::write("token-store", buffer).unwrap(); - }} - "#, - ) + .file("src/main.rs", r#"fn main() { print!("a\nb\n"); } "#) .build(); cred_proj.cargo("build").run(); + let _server = registry::RegistryBuilder::new() + .alternative() + .credential_provider(&["cargo:basic", &toml_bin(&cred_proj, "test-cred")]) + .no_configure_token() + .build(); - cargo_util::paths::append( - &paths::home().join(".cargo/config"), - format!( - r#" - [registry] - credential-process = ["{}", "{{action}}"] - "#, - toml_bin(&cred_proj, "test-cred") - ) - .as_bytes(), - ) - .unwrap(); + let p = project() + .file("Cargo.toml", &basic_manifest("foo", "1.0.0")) + .file("src/lib.rs", "") + .build(); - cargo_process("login -Z credential-process abcdefg") + p.cargo("publish --no-verify --registry alternative -Z credential-process") .masquerade_as_nightly_cargo(&["credential-process"]) - .replace_crates_io(server.index_url()) + .with_status(101) .with_stderr( "\ [UPDATING] [..] -[LOGIN] token for `crates.io` saved +[ERROR] credential provider `[..]test-cred[EXE]` failed action `get` + +Caused by: + error: process `[..]` returned more than one line of output; expected a single token ", ) .run(); - assert_eq!( - fs::read_to_string(paths::root().join("token-store")).unwrap(), - "abcdefg\n" - ); } -#[cargo_test] -fn logout() { - let server = registry::RegistryBuilder::new() - .no_configure_token() - .build(); +/// Builds a credential provider that echos the request from cargo to stderr, +/// and prints the `response` to stdout. +fn build_provider(name: &str, response: &str) -> String { // The credential process to use. let cred_proj = project() - .at("cred_proj") - .file("Cargo.toml", &basic_manifest("test-cred", "1.0.0")) + .at(name) + .file("Cargo.toml", &basic_manifest(name, "1.0.0")) .file( "src/main.rs", - r#" - use std::io::Read; - - fn main() {{ - assert_eq!(std::env::var("CARGO_REGISTRY_NAME_OPT").unwrap(), "crates-io"); - assert_eq!(std::env::var("CARGO_REGISTRY_INDEX_URL").unwrap(), "https://github.com/rust-lang/crates.io-index"); - assert_eq!(std::env::args().skip(1).next().unwrap(), "erase"); - std::fs::write("token-store", "").unwrap(); - eprintln!("token for `crates-io` has been erased!") - }} - "#, + &r####" + fn main() { + println!(r#"{{"v":[1]}}"#); + assert_eq!(std::env::args().skip(1).next().unwrap(), "--cargo-plugin"); + let mut buffer = String::new(); + std::io::stdin().read_line(&mut buffer).unwrap(); + eprint!("{}", buffer); + use std::io::Write; + std::io::stdout().write_all(r###"[RESPONSE]"###.as_bytes()).unwrap(); + println!(); + } "#### + .replace("[RESPONSE]", response), ) .build(); cred_proj.cargo("build").run(); + toml_bin(&cred_proj, name) +} + +#[cargo_test] +fn multiple_providers() { + let server = registry::RegistryBuilder::new() + .no_configure_token() + .build(); + + // Set up two credential providers: the first will fail with "UrlNotSupported" + // and Cargo should skip it. The second should succeed. + let url_not_supported = build_provider( + "url_not_supported", + r#"{"Err": {"kind": "url-not-supported"}}"#, + ); + + let success_provider = build_provider("success_provider", r#"{"Ok": {"kind": "login"}}"#); cargo_util::paths::append( &paths::home().join(".cargo/config"), format!( r#" [registry] - credential-process = ["{}", "{{action}}"] + global-credential-providers = ["success_provider", "url_not_supported"] + + [credential-alias] + success_provider = ["{success_provider}"] + url_not_supported = ["{url_not_supported}"] "#, - toml_bin(&cred_proj, "test-cred") ) .as_bytes(), ) .unwrap(); - cargo_process("logout -Z credential-process") + cargo_process("login -Z credential-process -v abcdefg") .masquerade_as_nightly_cargo(&["credential-process"]) .replace_crates_io(server.index_url()) .with_stderr( - "\ -token for `crates-io` has been erased! -[LOGOUT] token for `crates-io` has been removed from local storage -[NOTE] This does not revoke the token on the registry server. - If you need to revoke the token, visit \ - and follow the instructions there. -", + r#"[UPDATING] [..] +[CREDENTIAL] [..]url_not_supported[..] login crates-io +{"v":1,"registry":{"index-url":"https://github.com/rust-lang/crates.io-index","name":"crates-io"},"kind":"login","token":"abcdefg","login-url":"[..]","args":[]} +[CREDENTIAL] [..]success_provider[..] login crates-io +{"v":1,"registry":{"index-url":"https://github.com/rust-lang/crates.io-index","name":"crates-io"},"kind":"login","token":"abcdefg","login-url":"[..]","args":[]} +"#, ) .run(); - assert_eq!( - fs::read_to_string(paths::root().join("token-store")).unwrap(), - "" - ); } #[cargo_test] -fn yank() { - let (p, _t) = get_token_test(); +fn both_token_and_provider() { + let server = registry::RegistryBuilder::new().build(); + cargo_util::paths::append( + &paths::home().join(".cargo/config"), + format!( + r#" + [registry] + credential-provider = ["cargo:token"] + "#, + ) + .as_bytes(), + ) + .unwrap(); - p.cargo("yank --version 0.1.0 --registry alternative -Z credential-process") + cargo_process("login -Z credential-process -v abcdefg") .masquerade_as_nightly_cargo(&["credential-process"]) + .replace_crates_io(server.index_url()) .with_stderr( - "\ -[UPDATING] [..] -[YANK] foo@0.1.0 -", + r#"[UPDATING] [..] +[WARNING] registry `crates-io` has a token configured in [..]credentials.toml that will be ignored because a credential-provider is configured for this registry` +[CREDENTIAL] cargo:token login crates-io +[LOGIN] token for `crates-io` saved +"#, ) .run(); + let credentials = + std::fs::read_to_string(paths::home().join(".cargo/credentials.toml")).unwrap(); + assert_eq!(credentials, "[registry]\ntoken = \"abcdefg\"\n"); } #[cargo_test] -fn owner() { - let (p, _t) = get_token_test(); +fn both_asymmetric_and_token() { + let server = registry::RegistryBuilder::new().build(); + cargo_util::paths::append( + &paths::home().join(".cargo/config"), + format!( + r#" + [registry] + token = "foo" + secret-key = "bar" + "#, + ) + .as_bytes(), + ) + .unwrap(); - p.cargo("owner --add username --registry alternative -Z credential-process") + cargo_process("login -Z credential-process -v abcdefg") .masquerade_as_nightly_cargo(&["credential-process"]) + .replace_crates_io(server.index_url()) .with_stderr( - "\ -[UPDATING] [..] -[OWNER] completed! -", + r#"[UPDATING] [..] +[WARNING] registry `crates-io` has a `secret_key` configured in [..]config that will be ignored because a `token` is also configured, and the `cargo:token` provider is configured with higher precedence +[CREDENTIAL] cargo:token login crates-io +[LOGIN] token for `crates-io` saved +"#, ) .run(); } #[cargo_test] -fn libexec_path() { - // cargo: prefixed names use the sysroot +fn token_caching() { let server = registry::RegistryBuilder::new() .no_configure_token() + .no_configure_registry() + .token(cargo_test_support::registry::Token::Plaintext( + "sekrit".to_string(), + )) + .alternative() + .http_api() + .http_index() .build(); - cargo_util::paths::append( - &paths::home().join(".cargo/config"), - br#" - [registry] - credential-process = "cargo:doesnotexist" - "#, - ) - .unwrap(); - cargo_process("login -Z credential-process abcdefg") - .masquerade_as_nightly_cargo(&["credential-process"]) - .replace_crates_io(server.index_url()) - .with_status(101) - .with_stderr( - // FIXME: Update "Caused by" error message once rust/pull/87704 is merged. - // On Windows, changing to a custom executable resolver has changed the - // error messages. - &format!("\ -[UPDATING] [..] -[ERROR] failed to execute `[..]libexec/cargo-credential-doesnotexist[EXE]` to store authentication token for registry `crates-io` + // Token should not be re-used if it is expired + let expired_provider = build_provider( + "test-cred", + r#"{"Ok":{"kind":"get","token":"sekrit","cache":{"expires":0},"operation_independent":true}}"#, + ); -Caused by: - [..] -"), + // Token should not be re-used for a different operation if it is not operation_independent + let non_independent_provider = build_provider( + "test-cred", + r#"{"Ok":{"kind":"get","token":"sekrit","cache":"session","operation_independent":false}}"#, + ); + + let p = project() + .file( + ".cargo/config", + &format!( + r#" + [registries.alternative] + index = "{}" + credential-provider = ["{expired_provider}"] + "#, + server.index_url(), + ), + ) + .file( + "Cargo.toml", + r#" + [package] + name = "foo" + version = "0.1.0" + description = "foo" + license = "MIT" + homepage = "https://example.com/" + "#, ) + .file("src/lib.rs", "") + .build(); + + let output = r#"[UPDATING] `alternative` index +{"v":1,"registry":{"index-url":"[..]","name":"alternative"},"kind":"get","operation":"read","args":[]} +[PACKAGING] foo v0.1.0 [..] +[PACKAGED] [..] +{"v":1,"registry":{"index-url":"[..]","name":"alternative"},"kind":"get","operation":"publish","name":"foo","vers":"0.1.0","cksum":"[..]","args":[]} +[UPLOADING] foo v0.1.0 [..] +[UPLOADED] foo v0.1.0 [..] +note: Waiting [..] +You may press ctrl-c [..] +[PUBLISHED] foo v0.1.0 [..] +"#; + + // The output should contain two JSON messages from the provider in boths cases: + // The first because the credential is expired, the second because the provider + // indicated that the token was non-operation-independent. + p.cargo("publish -Z credential-process --registry alternative --no-verify") + .masquerade_as_nightly_cargo(&["credential-process"]) + .with_stderr(output) + .run(); + + p.change_file( + ".cargo/config", + &format!( + r#" + [registries.alternative] + index = "{}" + credential-provider = ["{non_independent_provider}"] + "#, + server.index_url(), + ), + ); + + p.cargo("publish -Z credential-process --registry alternative --no-verify") + .masquerade_as_nightly_cargo(&["credential-process"]) + .with_stderr(output) .run(); } #[cargo_test] -fn invalid_token_output() { - // Error when credential process does not output the expected format for a token. - let _server = registry::RegistryBuilder::new() - .alternative() - .no_configure_token() - .build(); +fn basic_provider() { let cred_proj = project() .at("cred_proj") .file("Cargo.toml", &basic_manifest("test-cred", "1.0.0")) - .file("src/main.rs", r#"fn main() { print!("a\nb\n"); } "#) + .file("src/main.rs", r#"fn main() { + eprintln!("CARGO={:?}", std::env::var("CARGO").ok()); + eprintln!("CARGO_REGISTRY_NAME_OPT={:?}", std::env::var("CARGO_REGISTRY_NAME_OPT").ok()); + eprintln!("CARGO_REGISTRY_INDEX_URL={:?}", std::env::var("CARGO_REGISTRY_INDEX_URL").ok()); + print!("sekrit"); + }"#) .build(); cred_proj.cargo("build").run(); - cargo_util::paths::append( - &paths::home().join(".cargo/config"), - format!( + let _server = registry::RegistryBuilder::new() + .no_configure_token() + .credential_provider(&["cargo:basic", &toml_bin(&cred_proj, "test-cred")]) + .token(cargo_test_support::registry::Token::Plaintext( + "sekrit".to_string(), + )) + .alternative() + .http_api() + .auth_required() + .build(); + + let p = project() + .file( + "Cargo.toml", r#" - [registry] - credential-process = ["{}"] + [package] + name = "foo" + version = "0.0.1" + authors = [] + [dependencies.bar] + version = "0.0.1" + registry = "alternative" "#, - toml_bin(&cred_proj, "test-cred") ) - .as_bytes(), - ) - .unwrap(); - - let p = project() - .file("Cargo.toml", &basic_manifest("foo", "1.0.0")) - .file("src/lib.rs", "") + .file("src/main.rs", "fn main() {}") .build(); + Package::new("bar", "0.0.1").alternative(true).publish(); - p.cargo("publish --no-verify --registry alternative -Z credential-process") - .masquerade_as_nightly_cargo(&["credential-process"]) - .with_status(101) + p.cargo("check -Z credential-process -Z registry-auth") + .masquerade_as_nightly_cargo(&["credential-process", "registry-auth"]) .with_stderr( "\ -[UPDATING] [..] -[ERROR] credential process `[..]test-cred[EXE]` returned more than one line of output; expected a single token +[UPDATING] `alternative` index +CARGO=Some([..]) +CARGO_REGISTRY_NAME_OPT=Some(\"alternative\") +CARGO_REGISTRY_INDEX_URL=Some([..]) +[DOWNLOADING] crates ... +[DOWNLOADED] bar v0.0.1 (registry `alternative`) +[CHECKING] bar v0.0.1 (registry `alternative`) +[CHECKING] foo v0.0.1 ([..]) +[FINISHED] [..] ", ) .run(); diff --git a/tests/testsuite/login.rs b/tests/testsuite/login.rs index 85b299f282a..ec4d6b8b6f0 100644 --- a/tests/testsuite/login.rs +++ b/tests/testsuite/login.rs @@ -109,12 +109,14 @@ fn empty_login_token() { cargo_process("login") .replace_crates_io(registry.index_url()) - .with_stdout("please paste the token found on [..]/me below") .with_stdin("\t\n") .with_stderr( "\ [UPDATING] crates.io index -[ERROR] please provide a non-empty token +[ERROR] credential provider `cargo:token` failed action `login` + +Caused by: + [ERROR] please provide a non-empty token ", ) .with_status(101) @@ -125,7 +127,10 @@ fn empty_login_token() { .arg("") .with_stderr( "\ -[ERROR] please provide a non-empty token +[ERROR] credential provider `cargo:token` failed action `login` + +Caused by: + [ERROR] please provide a non-empty token ", ) .with_status(101) @@ -143,7 +148,6 @@ fn invalid_login_token() { let check = |stdin: &str, stderr: &str, status: i32| { cargo_process("login") .replace_crates_io(registry.index_url()) - .with_stdout("please paste the token found on [..]/me below") .with_stdin(stdin) .with_stderr(stderr) .with_status(status) @@ -153,12 +157,15 @@ fn invalid_login_token() { let invalid = |stdin: &str| { check( stdin, - "[ERROR] token contains invalid characters. -Only printable ISO-8859-1 characters are allowed as it is sent in a HTTPS header.", + "[ERROR] credential provider `cargo:token` failed action `login` + +Caused by: + [ERROR] token contains invalid characters. + Only printable ISO-8859-1 characters are allowed as it is sent in a HTTPS header.", 101, ) }; - let valid = |stdin: &str| check(stdin, "[LOGIN] token for `crates.io` saved", 0); + let valid = |stdin: &str| check(stdin, "[LOGIN] token for `crates-io` saved", 0); // Update config.json so that the rest of the tests don't need to care // whether or not `Updating` is printed. @@ -166,7 +173,7 @@ Only printable ISO-8859-1 characters are allowed as it is sent in a HTTPS header "test", "\ [UPDATING] crates.io index -[LOGIN] token for `crates.io` saved +[LOGIN] token for `crates-io` saved ", 0, ); @@ -182,59 +189,6 @@ Only printable ISO-8859-1 characters are allowed as it is sent in a HTTPS header ); } -#[cargo_test] -fn bad_asymmetric_token_args() { - // These cases are kept brief as the implementation is covered by clap, so this is only smoke testing that we have clap configured correctly. - cargo_process("login --key-subject=foo tok") - .with_stderr_contains( - "error: the argument '--key-subject ' cannot be used with '[token]'", - ) - .with_status(1) - .run(); - - cargo_process("login --generate-keypair tok") - .with_stderr_contains( - "error: the argument '--generate-keypair' cannot be used with '[token]'", - ) - .with_status(1) - .run(); - - cargo_process("login --secret-key tok") - .with_stderr_contains("error: the argument '--secret-key' cannot be used with '[token]'") - .with_status(1) - .run(); - - cargo_process("login --generate-keypair --secret-key") - .with_stderr_contains( - "error: the argument '--generate-keypair' cannot be used with '--secret-key'", - ) - .with_status(1) - .run(); -} - -#[cargo_test] -fn asymmetric_requires_nightly() { - let registry = registry::init(); - cargo_process("login --key-subject=foo") - .replace_crates_io(registry.index_url()) - .with_status(101) - .with_stderr_contains("[ERROR] the `key-subject` flag is unstable, pass `-Z registry-auth` to enable it\n\ - See https://github.com/rust-lang/cargo/issues/10519 for more information about the `key-subject` flag.") - .run(); - cargo_process("login --generate-keypair") - .replace_crates_io(registry.index_url()) - .with_status(101) - .with_stderr_contains("[ERROR] the `generate-keypair` flag is unstable, pass `-Z registry-auth` to enable it\n\ - See https://github.com/rust-lang/cargo/issues/10519 for more information about the `generate-keypair` flag.") - .run(); - cargo_process("login --secret-key") - .replace_crates_io(registry.index_url()) - .with_status(101) - .with_stderr_contains("[ERROR] the `secret-key` flag is unstable, pass `-Z registry-auth` to enable it\n\ - See https://github.com/rust-lang/cargo/issues/10519 for more information about the `secret-key` flag.") - .run(); -} - #[cargo_test] fn login_with_no_cargo_dir() { // Create a config in the root directory because `login` requires the @@ -278,97 +232,45 @@ fn login_with_token_on_stdin() { .run(); cargo_process("login") .replace_crates_io(registry.index_url()) - .with_stdout("please paste the token found on [..]/me below") .with_stdin("some token") .run(); let credentials = fs::read_to_string(&credentials).unwrap(); assert_eq!(credentials, "[registry]\ntoken = \"some token\"\n"); } -#[cargo_test] -fn login_with_asymmetric_token_and_subject_on_stdin() { - let registry = registry::init(); - let credentials = credentials_toml(); - fs::remove_file(&credentials).unwrap(); - cargo_process("login --key-subject=foo --secret-key -v -Z registry-auth") - .masquerade_as_nightly_cargo(&["registry-auth"]) - .replace_crates_io(registry.index_url()) - .with_stdout( - "\ - please paste the API secret key below -k3.public.AmDwjlyf8jAV3gm5Z7Kz9xAOcsKslt_Vwp5v-emjFzBHLCtcANzTaVEghTNEMj9PkQ", - ) - .with_stdin("k3.secret.fNYVuMvBgOlljt9TDohnaYLblghqaHoQquVZwgR6X12cBFHZLFsaU3q7X3k1Zn36") - .run(); - let credentials = fs::read_to_string(&credentials).unwrap(); - assert!(credentials.starts_with("[registry]\n")); - assert!(credentials.contains("secret-key-subject = \"foo\"\n")); - assert!(credentials.contains("secret-key = \"k3.secret.fNYVuMvBgOlljt9TDohnaYLblghqaHoQquVZwgR6X12cBFHZLFsaU3q7X3k1Zn36\"\n")); -} - #[cargo_test] fn login_with_asymmetric_token_on_stdin() { - let registry = registry::init(); + let _registry = RegistryBuilder::new() + .credential_provider(&["cargo:paseto"]) + .alternative() + .no_configure_token() + .build(); let credentials = credentials_toml(); - fs::remove_file(&credentials).unwrap(); - cargo_process("login --secret-key -v -Z registry-auth") - .masquerade_as_nightly_cargo(&["registry-auth"]) - .replace_crates_io(registry.index_url()) - .with_stdout( + cargo_process("login -vZ credential-process --registry alternative") + .masquerade_as_nightly_cargo(&["credential-process"]) + .with_stderr( "\ - please paste the API secret key below -k3.public.AmDwjlyf8jAV3gm5Z7Kz9xAOcsKslt_Vwp5v-emjFzBHLCtcANzTaVEghTNEMj9PkQ", - ) - .with_stdin("k3.secret.fNYVuMvBgOlljt9TDohnaYLblghqaHoQquVZwgR6X12cBFHZLFsaU3q7X3k1Zn36") - .run(); - let credentials = fs::read_to_string(&credentials).unwrap(); - assert_eq!(credentials, "[registry]\nsecret-key = \"k3.secret.fNYVuMvBgOlljt9TDohnaYLblghqaHoQquVZwgR6X12cBFHZLFsaU3q7X3k1Zn36\"\n"); -} - -#[cargo_test] -fn login_with_asymmetric_key_subject_without_key() { - let registry = registry::init(); - let credentials = credentials_toml(); - fs::remove_file(&credentials).unwrap(); - cargo_process("login --key-subject=foo -Z registry-auth") - .masquerade_as_nightly_cargo(&["registry-auth"]) - .replace_crates_io(registry.index_url()) - .with_stderr_contains("error: need a secret_key to set a key_subject") - .with_status(101) - .run(); - - // ok so add a secret_key to the credentials - cargo_process("login --secret-key -v -Z registry-auth") - .masquerade_as_nightly_cargo(&["registry-auth"]) - .replace_crates_io(registry.index_url()) - .with_stdout( - "please paste the API secret key below +[UPDATING] [..] +[CREDENTIAL] cargo:paseto login alternative k3.public.AmDwjlyf8jAV3gm5Z7Kz9xAOcsKslt_Vwp5v-emjFzBHLCtcANzTaVEghTNEMj9PkQ", ) .with_stdin("k3.secret.fNYVuMvBgOlljt9TDohnaYLblghqaHoQquVZwgR6X12cBFHZLFsaU3q7X3k1Zn36") .run(); - - // and then it should work - cargo_process("login --key-subject=foo -Z registry-auth") - .masquerade_as_nightly_cargo(&["registry-auth"]) - .replace_crates_io(registry.index_url()) - .run(); - let credentials = fs::read_to_string(&credentials).unwrap(); - assert!(credentials.starts_with("[registry]\n")); - assert!(credentials.contains("secret-key-subject = \"foo\"\n")); - assert!(credentials.contains("secret-key = \"k3.secret.fNYVuMvBgOlljt9TDohnaYLblghqaHoQquVZwgR6X12cBFHZLFsaU3q7X3k1Zn36\"\n")); + assert_eq!(credentials, "[registries.alternative]\nsecret-key = \"k3.secret.fNYVuMvBgOlljt9TDohnaYLblghqaHoQquVZwgR6X12cBFHZLFsaU3q7X3k1Zn36\"\n"); } #[cargo_test] fn login_with_generate_asymmetric_token() { - let registry = registry::init(); + let _registry = RegistryBuilder::new() + .credential_provider(&["cargo:paseto"]) + .alternative() + .no_configure_token() + .build(); let credentials = credentials_toml(); - fs::remove_file(&credentials).unwrap(); - cargo_process("login --generate-keypair -Z registry-auth") - .masquerade_as_nightly_cargo(&["registry-auth"]) - .replace_crates_io(registry.index_url()) - .with_stdout("k3.public.[..]") + cargo_process("login -Z credential-process --registry alternative") + .masquerade_as_nightly_cargo(&["credential-process"]) + .with_stderr("[UPDATING] `alternative` index\nk3.public.[..]") .run(); let credentials = fs::read_to_string(&credentials).unwrap(); assert!(credentials.contains("secret-key = \"k3.secret.")); diff --git a/tests/testsuite/owner.rs b/tests/testsuite/owner.rs index 9fc960c9277..7b38bcc5ebb 100644 --- a/tests/testsuite/owner.rs +++ b/tests/testsuite/owner.rs @@ -117,8 +117,8 @@ fn simple_add_with_asymmetric() { // The http_api server will check that the authorization is correct. // If the authorization was not sent then we would get an unauthorized error. p.cargo("owner -a username") - .arg("-Zregistry-auth") - .masquerade_as_nightly_cargo(&["registry-auth"]) + .arg("-Zcredential-process") + .masquerade_as_nightly_cargo(&["credential-process"]) .replace_crates_io(registry.index_url()) .with_status(0) .run(); @@ -184,9 +184,9 @@ fn simple_remove_with_asymmetric() { // The http_api server will check that the authorization is correct. // If the authorization was not sent then we would get an unauthorized error. p.cargo("owner -r username") - .arg("-Zregistry-auth") + .arg("-Zcredential-process") .replace_crates_io(registry.index_url()) - .masquerade_as_nightly_cargo(&["registry-auth"]) + .masquerade_as_nightly_cargo(&["credential-process"]) .with_status(0) .run(); } diff --git a/tests/testsuite/publish.rs b/tests/testsuite/publish.rs index eb749ee206b..50ad697d59f 100644 --- a/tests/testsuite/publish.rs +++ b/tests/testsuite/publish.rs @@ -194,8 +194,8 @@ fn simple_publish_with_asymmetric() { .file("src/main.rs", "fn main() {}") .build(); - p.cargo("publish --no-verify -Zregistry-auth --registry dummy-registry") - .masquerade_as_nightly_cargo(&["registry-auth"]) + p.cargo("publish --no-verify -Zcredential-process --registry dummy-registry") + .masquerade_as_nightly_cargo(&["credential-process"]) .with_stderr( "\ [UPDATING] `dummy-registry` index @@ -338,7 +338,7 @@ fn git_deps() { .file("src/main.rs", "fn main() {}") .build(); - p.cargo("publish -v --no-verify") + p.cargo("publish --no-verify") .replace_crates_io(registry.index_url()) .with_status(101) .with_stderr( diff --git a/tests/testsuite/registry_auth.rs b/tests/testsuite/registry_auth.rs index 97cdf674812..4422c638a16 100644 --- a/tests/testsuite/registry_auth.rs +++ b/tests/testsuite/registry_auth.rs @@ -6,8 +6,9 @@ use cargo_test_support::{project, Execs, Project}; fn cargo(p: &Project, s: &str) -> Execs { let mut e = p.cargo(s); - e.masquerade_as_nightly_cargo(&["registry-auth"]) - .arg("-Zregistry-auth"); + e.masquerade_as_nightly_cargo(&["registry-auth", "credential-process"]) + .arg("-Zregistry-auth") + .arg("-Zcredential-process"); e } @@ -148,95 +149,6 @@ fn environment_token_with_asymmetric() { .run(); } -#[cargo_test] -fn warn_both_asymmetric_and_token() { - let _server = RegistryBuilder::new() - .alternative() - .no_configure_token() - .build(); - let p = project() - .file( - ".cargo/config", - r#" - [registries.alternative] - token = "sekrit" - secret-key = "k3.secret.fNYVuMvBgOlljt9TDohnaYLblghqaHoQquVZwgR6X12cBFHZLFsaU3q7X3k1Zn36" - "#, - ) - .file( - "Cargo.toml", - r#" - [package] - name = "foo" - version = "0.1.0" - description = "foo" - authors = [] - license = "MIT" - homepage = "https://example.com/" - "#, - ) - .file("src/lib.rs", "") - .build(); - - p.cargo("publish --no-verify --registry alternative") - .masquerade_as_nightly_cargo(&["credential-process", "registry-auth"]) - .arg("-Zregistry-auth") - .with_status(101) - .with_stderr( - "\ -[UPDATING] [..] -[ERROR] both `token` and `secret-key` were specified in the config for registry `alternative`. -Only one of these values may be set, remove one or the other to proceed. -", - ) - .run(); -} - -#[cargo_test] -fn warn_both_asymmetric_and_credential_process() { - let _server = RegistryBuilder::new() - .alternative() - .no_configure_token() - .build(); - let p = project() - .file( - ".cargo/config", - r#" - [registries.alternative] - credential-process = "false" - secret-key = "k3.secret.fNYVuMvBgOlljt9TDohnaYLblghqaHoQquVZwgR6X12cBFHZLFsaU3q7X3k1Zn36" - "#, - ) - .file( - "Cargo.toml", - r#" - [package] - name = "foo" - version = "0.1.0" - description = "foo" - authors = [] - license = "MIT" - homepage = "https://example.com/" - "#, - ) - .file("src/lib.rs", "") - .build(); - - p.cargo("publish --no-verify --registry alternative") - .masquerade_as_nightly_cargo(&["credential-process", "registry-auth"]) - .arg("-Zcredential-process") - .arg("-Zregistry-auth") - .with_status(101) - .with_stderr( - "\ -[UPDATING] [..] -[ERROR] both `credential-process` and `secret-key` were specified in the config for registry `alternative`. -Only one of these values may be set, remove one or the other to proceed. -", - ) - .run(); -} - #[cargo_test] fn bad_environment_token_with_asymmetric_subject() { let registry = RegistryBuilder::new() @@ -463,7 +375,6 @@ fn login() { let p = make_project(); cargo(&p, "login --registry alternative") - .with_stdout("please paste the token found on https://test-registry-login/me below") .with_stdin("sekrit") .run(); } @@ -478,7 +389,6 @@ fn login_existing_token() { let p = make_project(); cargo(&p, "login --registry alternative") - .with_stdout("please paste the token found on file://[..]/me below") .with_stdin("sekrit") .run(); } diff --git a/tests/testsuite/yank.rs b/tests/testsuite/yank.rs index 684a04508c9..c0bd2477600 100644 --- a/tests/testsuite/yank.rs +++ b/tests/testsuite/yank.rs @@ -76,14 +76,14 @@ fn explicit_version_with_asymmetric() { // The http_api server will check that the authorization is correct. // If the authorization was not sent then we would get an unauthorized error. p.cargo("yank --version 0.0.1") - .arg("-Zregistry-auth") - .masquerade_as_nightly_cargo(&["registry-auth"]) + .arg("-Zcredential-process") + .masquerade_as_nightly_cargo(&["credential-process"]) .replace_crates_io(registry.index_url()) .run(); p.cargo("yank --undo --version 0.0.1") - .arg("-Zregistry-auth") - .masquerade_as_nightly_cargo(&["registry-auth"]) + .arg("-Zcredential-process") + .masquerade_as_nightly_cargo(&["credential-process"]) .replace_crates_io(registry.index_url()) .run(); }