diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9ab148bf9f9..742fd3399c9 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -18,12 +18,12 @@ jobs: - run: rustup update stable && rustup default stable - run: rustup component add rustfmt - run: cargo fmt --all -- --check - - run: cd crates/cargo-test-macro && cargo fmt --all -- --check - - run: cd crates/cargo-test-support && cargo fmt --all -- --check - - run: cd crates/crates-io && cargo fmt --all -- --check - - run: cd crates/resolver-tests && cargo fmt --all -- --check - - run: cd crates/cargo-platform && cargo fmt --all -- --check - - run: cd crates/mdman && cargo fmt --all -- --check + - run: | + for manifest in `find crates -name Cargo.toml` + do + echo check fmt for $manifest + cargo fmt --all --manifest-path $manifest -- --check + done test: runs-on: ${{ matrix.os }} @@ -58,7 +58,7 @@ jobs: - run: rustup target add ${{ matrix.other }} - run: rustup component add rustc-dev llvm-tools-preview rust-docs if: startsWith(matrix.rust, 'nightly') - - run: sudo apt update -y && sudo apt install gcc-multilib -y + - run: sudo apt update -y && sudo apt install gcc-multilib libsecret-1-0 libsecret-1-dev -y if: matrix.os == 'ubuntu-latest' - run: rustup component add rustfmt || echo "rustfmt not available" @@ -67,6 +67,13 @@ jobs: - run: cargo test --features 'deny-warnings' -p cargo-test-support - run: cargo test -p cargo-platform - run: cargo test --manifest-path crates/mdman/Cargo.toml + - run: cargo build --manifest-path crates/credential/cargo-credential-1password/Cargo.toml + - run: cargo build --manifest-path crates/credential/cargo-credential-gnome-secret/Cargo.toml + if: matrix.os == 'ubuntu-latest' + - run: cargo build --manifest-path crates/credential/cargo-credential-macos-keychain/Cargo.toml + if: matrix.os == 'macos-latest' + - run: cargo build --manifest-path crates/credential/cargo-credential-wincred/Cargo.toml + if: matrix.os == 'windows-latest' resolver: runs-on: ubuntu-latest diff --git a/crates/cargo-test-support/src/lib.rs b/crates/cargo-test-support/src/lib.rs index bc02bf6b9db..14e8e8d27ff 100644 --- a/crates/cargo-test-support/src/lib.rs +++ b/crates/cargo-test-support/src/lib.rs @@ -1544,6 +1544,10 @@ fn substitute_macros(input: &str) -> String { ("[INSTALLED]", " Installed"), ("[REPLACED]", " Replaced"), ("[BUILDING]", " Building"), + ("[LOGIN]", " Login"), + ("[LOGOUT]", " Logout"), + ("[YANK]", " Yank"), + ("[OWNER]", " Owner"), ]; let mut result = input.to_owned(); for &(pat, subst) in ¯os { diff --git a/crates/cargo-test-support/src/paths.rs b/crates/cargo-test-support/src/paths.rs index c5067625a59..61f9c03aea4 100644 --- a/crates/cargo-test-support/src/paths.rs +++ b/crates/cargo-test-support/src/paths.rs @@ -110,14 +110,27 @@ pub trait CargoPathExt { } impl CargoPathExt for Path { - /* Technically there is a potential race condition, but we don't - * care all that much for our tests - */ fn rm_rf(&self) { - if self.exists() { + let meta = match self.symlink_metadata() { + Ok(meta) => meta, + Err(e) => { + if e.kind() == ErrorKind::NotFound { + return; + } + panic!("failed to remove {:?}, could not read: {:?}", self, e); + } + }; + // There is a race condition between fetching the metadata and + // actually performing the removal, but we don't care all that much + // for our tests. + if meta.is_dir() { if let Err(e) = remove_dir_all::remove_dir_all(self) { panic!("failed to remove {:?}: {:?}", self, e) } + } else { + if let Err(e) = fs::remove_file(self) { + panic!("failed to remove {:?}: {:?}", self, e) + } } } diff --git a/crates/credential/README.md b/crates/credential/README.md new file mode 100644 index 00000000000..168cc71c3d9 --- /dev/null +++ b/crates/credential/README.md @@ -0,0 +1,8 @@ +# Cargo Credential Packages + +This directory contains Cargo packages for handling storage of tokens in a +secure manner. + +`cargo-credential` is a generic library to assist writing a credential +process. The other directories contain implementations that integrate with +specific credential systems. diff --git a/crates/credential/cargo-credential-1password/Cargo.toml b/crates/credential/cargo-credential-1password/Cargo.toml new file mode 100644 index 00000000000..ea2bff4ec4b --- /dev/null +++ b/crates/credential/cargo-credential-1password/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "cargo-credential-1password" +version = "0.1.0" +authors = ["The Rust Project Developers"] +edition = "2018" +license = "MIT OR Apache-2.0" +repository = "https://github.com/rust-lang/cargo" +description = "A Cargo credential process that stores tokens in a 1password vault." + +[dependencies] +cargo-credential = { path = "../cargo-credential" } +serde = { version = "1.0.117", features = ["derive"] } +serde_json = "1.0.59" diff --git a/crates/credential/cargo-credential-1password/src/main.rs b/crates/credential/cargo-credential-1password/src/main.rs new file mode 100644 index 00000000000..7eea3a0b6ed --- /dev/null +++ b/crates/credential/cargo-credential-1password/src/main.rs @@ -0,0 +1,323 @@ +//! Cargo registry 1password credential process. + +use cargo_credential::{Credential, Error}; +use serde::Deserialize; +use std::io::Read; +use std::process::{Command, Stdio}; + +const CARGO_TAG: &str = "cargo-registry"; + +/// Implementation of 1password keychain access for Cargo registries. +struct OnePasswordKeychain { + account: Option, + vault: Option, + sign_in_address: Option, + email: Option, +} + +/// 1password Login item type, used for the JSON output of `op get item`. +#[derive(Deserialize)] +struct Login { + details: Details, +} + +#[derive(Deserialize)] +struct Details { + fields: Vec, +} + +#[derive(Deserialize)] +struct Field { + designation: String, + value: String, +} + +/// 1password item from `op list items`. +#[derive(Deserialize)] +struct ListItem { + uuid: String, + overview: Overview, +} + +#[derive(Deserialize)] +struct Overview { + title: String, +} + +impl OnePasswordKeychain { + fn new() -> Result { + let mut args = std::env::args().skip(1); + let mut action = false; + let mut account = None; + let mut vault = None; + let mut sign_in_address = None; + let mut email = None; + while let Some(arg) = args.next() { + match arg.as_str() { + "--account" => { + account = Some(args.next().ok_or("--account needs an arg")?); + } + "--vault" => { + vault = Some(args.next().ok_or("--vault needs an arg")?); + } + "--sign-in-address" => { + sign_in_address = Some(args.next().ok_or("--sign-in-address needs an arg")?); + } + "--email" => { + email = Some(args.next().ok_or("--email needs an arg")?); + } + s if s.starts_with('-') => { + return Err(format!("unknown option {}", s).into()); + } + _ => { + if action { + return Err("too many arguments".into()); + } else { + action = true; + } + } + } + } + if sign_in_address.is_none() && email.is_some() { + return Err("--email requires --sign-in-address".into()); + } + Ok(OnePasswordKeychain { + account, + vault, + sign_in_address, + email, + }) + } + + fn signin(&self) -> Result, Error> { + // If there are any session env vars, we'll assume that this is the + // correct account, and that the user knows what they are doing. + if std::env::vars().any(|(name, _)| name.starts_with("OP_SESSION_")) { + return Ok(None); + } + let mut cmd = Command::new("op"); + cmd.arg("signin"); + if let Some(addr) = &self.sign_in_address { + cmd.arg(addr); + if let Some(email) = &self.email { + cmd.arg(email); + } + } + cmd.arg("--raw"); + cmd.stdout(Stdio::piped()); + #[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); + let mut child = cmd + .spawn() + .map_err(|e| format!("failed to spawn `op`: {}", e))?; + let mut buffer = String::new(); + child + .stdout + .as_mut() + .unwrap() + .read_to_string(&mut buffer) + .map_err(|e| format!("failed to get session from `op`: {}", e))?; + if let Some(end) = buffer.find('\n') { + buffer.truncate(end); + } + let status = child + .wait() + .map_err(|e| format!("failed to wait for `op`: {}", e))?; + if !status.success() { + return Err(format!("failed to run `op signin`: {}", status).into()); + } + Ok(Some(buffer)) + } + + fn make_cmd(&self, session: &Option, args: &[&str]) -> Command { + let mut cmd = Command::new("op"); + cmd.args(args); + if let Some(account) = &self.account { + cmd.arg("--account"); + cmd.arg(account); + } + if let Some(vault) = &self.vault { + cmd.arg("--vault"); + cmd.arg(vault); + } + if let Some(session) = session { + cmd.arg("--session"); + cmd.arg(session); + } + cmd + } + + fn run_cmd(&self, mut cmd: Command) -> Result { + cmd.stdout(Stdio::piped()); + let mut child = cmd + .spawn() + .map_err(|e| format!("failed to spawn `op`: {}", e))?; + let mut buffer = String::new(); + child + .stdout + .as_mut() + .unwrap() + .read_to_string(&mut buffer) + .map_err(|e| format!("failed to read `op` output: {}", e))?; + let status = child + .wait() + .map_err(|e| format!("failed to wait for `op`: {}", e))?; + if !status.success() { + return Err(format!("`op` command exit error: {}", status).into()); + } + Ok(buffer) + } + + fn search( + &self, + session: &Option, + registry_name: &str, + ) -> Result, Error> { + let cmd = self.make_cmd( + session, + &[ + "list", + "items", + "--categories", + "Login", + "--tags", + CARGO_TAG, + ], + ); + let buffer = self.run_cmd(cmd)?; + let items: Vec = serde_json::from_str(&buffer) + .map_err(|e| format!("failed to deserialize JSON from 1password list: {}", e))?; + let mut matches = items + .into_iter() + .filter(|item| item.overview.title == registry_name); + match matches.next() { + Some(login) => { + // Should this maybe just sort on `updatedAt` and return the newest one? + if matches.next().is_some() { + return Err(format!( + "too many 1password logins match registry name {}, \ + consider deleting the excess entries", + registry_name + ) + .into()); + } + Ok(Some(login.uuid)) + } + None => Ok(None), + } + } + + fn modify(&self, session: &Option, uuid: &str, token: &str) -> Result<(), Error> { + let cmd = self.make_cmd( + session, + &["edit", "item", uuid, &format!("password={}", token)], + ); + self.run_cmd(cmd)?; + Ok(()) + } + + fn create( + &self, + session: &Option, + registry_name: &str, + api_url: &str, + token: &str, + ) -> Result<(), Error> { + let cmd = self.make_cmd( + session, + &[ + "create", + "item", + "Login", + &format!("password={}", token), + &format!("url={}", api_url), + "--title", + registry_name, + "--tags", + CARGO_TAG, + ], + ); + self.run_cmd(cmd)?; + Ok(()) + } + + fn get_token(&self, session: &Option, uuid: &str) -> Result { + let cmd = self.make_cmd(session, &["get", "item", uuid]); + let buffer = self.run_cmd(cmd)?; + let item: Login = serde_json::from_str(&buffer) + .map_err(|e| format!("failed to deserialize JSON from 1password get: {}", e))?; + let password = item + .details + .fields + .into_iter() + .find(|item| item.designation == "password"); + match password { + Some(password) => Ok(password.value), + None => Err("could not find password field".into()), + } + } + + fn delete(&self, session: &Option, uuid: &str) -> Result<(), Error> { + let cmd = self.make_cmd(session, &["delete", "item", uuid]); + self.run_cmd(cmd)?; + Ok(()) + } +} + +impl Credential for OnePasswordKeychain { + fn name(&self) -> &'static str { + env!("CARGO_PKG_NAME") + } + + fn get(&self, registry_name: &str, _api_url: &str) -> Result { + let session = self.signin()?; + if let Some(uuid) = self.search(&session, registry_name)? { + self.get_token(&session, &uuid) + } else { + return Err(format!( + "no 1password entry found for registry `{}`, try `cargo login` to add a token", + registry_name + ) + .into()); + } + } + + fn store(&self, registry_name: &str, api_url: &str, token: &str) -> Result<(), Error> { + let session = self.signin()?; + // Check if an item already exists. + if let Some(uuid) = self.search(&session, registry_name)? { + self.modify(&session, &uuid, token) + } else { + self.create(&session, registry_name, api_url, token) + } + } + + fn erase(&self, registry_name: &str, _api_url: &str) -> Result<(), Error> { + let session = self.signin()?; + // Check if an item already exists. + if let Some(uuid) = self.search(&session, registry_name)? { + self.delete(&session, &uuid)?; + } else { + eprintln!("not currently logged in to `{}`", registry_name); + } + 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/crates/credential/cargo-credential-gnome-secret/Cargo.toml b/crates/credential/cargo-credential-gnome-secret/Cargo.toml new file mode 100644 index 00000000000..11e4a1ccbe9 --- /dev/null +++ b/crates/credential/cargo-credential-gnome-secret/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "cargo-credential-gnome-secret" +version = "0.1.0" +authors = ["The Rust Project Developers"] +edition = "2018" +license = "MIT OR Apache-2.0" +repository = "https://github.com/rust-lang/cargo" +description = "A Cargo credential process that stores tokens with GNOME libsecret." + +[dependencies] +cargo-credential = { path = "../cargo-credential" } + +[build-dependencies] +pkg-config = "0.3.19" diff --git a/crates/credential/cargo-credential-gnome-secret/build.rs b/crates/credential/cargo-credential-gnome-secret/build.rs new file mode 100644 index 00000000000..9283535af36 --- /dev/null +++ b/crates/credential/cargo-credential-gnome-secret/build.rs @@ -0,0 +1,3 @@ +fn main() { + pkg_config::probe_library("libsecret-1").unwrap(); +} diff --git a/crates/credential/cargo-credential-gnome-secret/src/main.rs b/crates/credential/cargo-credential-gnome-secret/src/main.rs new file mode 100644 index 00000000000..582a5bfb371 --- /dev/null +++ b/crates/credential/cargo-credential-gnome-secret/src/main.rs @@ -0,0 +1,210 @@ +//! Cargo registry gnome libsecret credential process. + +use cargo_credential::{Credential, Error}; +use std::ffi::{CStr, CString}; +use std::os::raw::{c_char, c_int}; +use std::ptr::{null, null_mut}; + +#[allow(non_camel_case_types)] +type gchar = c_char; + +#[allow(non_camel_case_types)] +type gboolean = c_int; + +type GQuark = u32; + +#[repr(C)] +struct GError { + domain: GQuark, + code: c_int, + message: *mut gchar, +} + +#[repr(C)] +struct GCancellable { + _private: [u8; 0], +} + +#[repr(C)] +struct SecretSchema { + name: *const gchar, + flags: SecretSchemaFlags, + attributes: [SecretSchemaAttribute; 32], +} + +#[repr(C)] +#[derive(Copy, Clone)] +struct SecretSchemaAttribute { + name: *const gchar, + attr_type: SecretSchemaAttributeType, +} + +#[repr(C)] +enum SecretSchemaFlags { + None = 0, +} + +#[repr(C)] +#[derive(Copy, Clone)] +enum SecretSchemaAttributeType { + String = 0, +} + +extern "C" { + fn secret_password_store_sync( + schema: *const SecretSchema, + collection: *const gchar, + label: *const gchar, + password: *const gchar, + cancellable: *mut GCancellable, + error: *mut *mut GError, + ... + ) -> gboolean; + fn secret_password_clear_sync( + schema: *const SecretSchema, + cancellable: *mut GCancellable, + error: *mut *mut GError, + ... + ) -> gboolean; + fn secret_password_lookup_sync( + schema: *const SecretSchema, + cancellable: *mut GCancellable, + error: *mut *mut GError, + ... + ) -> *mut gchar; +} + +struct GnomeSecret; + +fn label(registry_name: &str) -> CString { + CString::new(format!("cargo-registry:{}", registry_name)).unwrap() +} + +fn schema() -> SecretSchema { + let mut attributes = [SecretSchemaAttribute { + name: null(), + attr_type: SecretSchemaAttributeType::String, + }; 32]; + attributes[0] = SecretSchemaAttribute { + name: b"registry\0".as_ptr() as *const gchar, + attr_type: SecretSchemaAttributeType::String, + }; + attributes[1] = SecretSchemaAttribute { + name: b"url\0".as_ptr() as *const gchar, + attr_type: SecretSchemaAttributeType::String, + }; + SecretSchema { + name: b"org.rust-lang.cargo.registry\0".as_ptr() as *const gchar, + flags: SecretSchemaFlags::None, + attributes, + } +} + +impl Credential for GnomeSecret { + fn name(&self) -> &'static str { + env!("CARGO_PKG_NAME") + } + + fn get(&self, registry_name: &str, api_url: &str) -> Result { + let mut error: *mut GError = null_mut(); + let attr_registry = CString::new("registry").unwrap(); + let attr_url = CString::new("url").unwrap(); + let registry_name_c = CString::new(registry_name).unwrap(); + let api_url_c = CString::new(api_url).unwrap(); + let schema = schema(); + unsafe { + let token_c = secret_password_lookup_sync( + &schema, + null_mut(), + &mut error, + attr_registry.as_ptr(), + registry_name_c.as_ptr(), + attr_url.as_ptr(), + api_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()); + } + if token_c.is_null() { + return Err(format!("cannot find token for {}", registry_name).into()); + } + let token = CStr::from_ptr(token_c) + .to_str() + .map_err(|e| format!("expected utf8 token: {}", e))? + .to_string(); + Ok(token) + } + } + + fn store(&self, registry_name: &str, api_url: &str, token: &str) -> Result<(), Error> { + let label = label(registry_name); + let token = CString::new(token).unwrap(); + let mut error: *mut GError = null_mut(); + let attr_registry = CString::new("registry").unwrap(); + let attr_url = CString::new("url").unwrap(); + let registry_name_c = CString::new(registry_name).unwrap(); + let api_url_c = CString::new(api_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_registry.as_ptr(), + registry_name_c.as_ptr(), + attr_url.as_ptr(), + api_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, registry_name: &str, api_url: &str) -> Result<(), Error> { + let schema = schema(); + let mut error: *mut GError = null_mut(); + let attr_registry = CString::new("registry").unwrap(); + let attr_url = CString::new("url").unwrap(); + let registry_name_c = CString::new(registry_name).unwrap(); + let api_url_c = CString::new(api_url).unwrap(); + unsafe { + secret_password_clear_sync( + &schema, + null_mut(), + &mut error, + attr_registry.as_ptr(), + registry_name_c.as_ptr(), + attr_url.as_ptr(), + api_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()); + } + } + Ok(()) + } +} + +fn main() { + cargo_credential::main(GnomeSecret); +} diff --git a/crates/credential/cargo-credential-macos-keychain/Cargo.toml b/crates/credential/cargo-credential-macos-keychain/Cargo.toml new file mode 100644 index 00000000000..afff5048b8b --- /dev/null +++ b/crates/credential/cargo-credential-macos-keychain/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "cargo-credential-macos-keychain" +version = "0.1.0" +authors = ["The Rust Project Developers"] +edition = "2018" +license = "MIT OR Apache-2.0" +repository = "https://github.com/rust-lang/cargo" +description = "A Cargo credential process that stores tokens in a macOS keychain." + +[dependencies] +cargo-credential = { path = "../cargo-credential" } +security-framework = "2.0.0" diff --git a/crates/credential/cargo-credential-macos-keychain/src/main.rs b/crates/credential/cargo-credential-macos-keychain/src/main.rs new file mode 100644 index 00000000000..4f12585ed18 --- /dev/null +++ b/crates/credential/cargo-credential-macos-keychain/src/main.rs @@ -0,0 +1,50 @@ +//! Cargo registry macos keychain credential process. + +use cargo_credential::{Credential, Error}; +use security_framework::os::macos::keychain::SecKeychain; + +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, registry_name: &str, _api_url: &str) -> Result { + let keychain = SecKeychain::default().unwrap(); + let service_name = registry(registry_name); + 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, registry_name: &str, _api_url: &str, token: &str) -> Result<(), Error> { + let keychain = SecKeychain::default().unwrap(); + let service_name = registry(registry_name); + 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, registry_name: &str, _api_url: &str) -> Result<(), Error> { + let keychain = SecKeychain::default().unwrap(); + let service_name = registry(registry_name); + let (_pass, item) = keychain.find_generic_password(&service_name, ACCOUNT)?; + item.delete(); + Ok(()) + } +} + +fn main() { + cargo_credential::main(MacKeychain); +} diff --git a/crates/credential/cargo-credential-wincred/Cargo.toml b/crates/credential/cargo-credential-wincred/Cargo.toml new file mode 100644 index 00000000000..65e2086874b --- /dev/null +++ b/crates/credential/cargo-credential-wincred/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "cargo-credential-wincred" +version = "0.1.0" +authors = ["The Rust Project Developers"] +edition = "2018" +license = "MIT OR Apache-2.0" +repository = "https://github.com/rust-lang/cargo" +description = "A Cargo credential process that stores tokens with Windows Credential Manager." + +[dependencies] +cargo-credential = { path = "../cargo-credential" } +winapi = { version = "0.3.9", features = ["wincred", "winerror", "impl-default"] } diff --git a/crates/credential/cargo-credential-wincred/src/main.rs b/crates/credential/cargo-credential-wincred/src/main.rs new file mode 100644 index 00000000000..5e534b3a673 --- /dev/null +++ b/crates/credential/cargo-credential-wincred/src/main.rs @@ -0,0 +1,99 @@ +//! Cargo registry windows credential process. + +use cargo_credential::{Credential, Error}; +use std::ffi::OsStr; +use std::os::windows::ffi::OsStrExt; +use winapi::shared::minwindef::{DWORD, FILETIME, LPBYTE, TRUE}; +use winapi::shared::winerror; +use winapi::um::wincred; +use winapi::um::winnt::LPWSTR; + +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, registry_name: &str, _api_url: &str) -> Result { + let target_name = target_name(registry_name); + let mut p_credential: wincred::PCREDENTIALW = std::ptr::null_mut(); + unsafe { + if wincred::CredReadW( + target_name.as_ptr(), + wincred::CRED_TYPE_GENERIC, + 0, + &mut p_credential, + ) != 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, registry_name: &str, _api_url: &str, token: &str) -> Result<(), Error> { + let token = token.as_bytes(); + let target_name = target_name(registry_name); + let comment = wstr("Cargo registry token"); + let mut credential = wincred::CREDENTIALW { + Flags: 0, + Type: wincred::CRED_TYPE_GENERIC, + TargetName: target_name.as_ptr() as LPWSTR, + Comment: comment.as_ptr() as LPWSTR, + LastWritten: FILETIME::default(), + CredentialBlobSize: token.len() as DWORD, + CredentialBlob: token.as_ptr() as LPBYTE, + Persist: wincred::CRED_PERSIST_LOCAL_MACHINE, + AttributeCount: 0, + Attributes: std::ptr::null_mut(), + TargetAlias: std::ptr::null_mut(), + UserName: std::ptr::null_mut(), + }; + let result = unsafe { wincred::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, registry_name: &str, _api_url: &str) -> Result<(), Error> { + let target_name = target_name(registry_name); + let result = + unsafe { wincred::CredDeleteW(target_name.as_ptr(), wincred::CRED_TYPE_GENERIC, 0) }; + if result != TRUE { + let err = std::io::Error::last_os_error(); + if err.raw_os_error() == Some(winerror::ERROR_NOT_FOUND as i32) { + eprintln!("not currently logged in to `{}`", registry_name); + return Ok(()); + } + return Err(format!("failed to remove token: {}", err).into()); + } + Ok(()) + } +} + +fn main() { + cargo_credential::main(WindowsCredential); +} diff --git a/crates/credential/cargo-credential/Cargo.toml b/crates/credential/cargo-credential/Cargo.toml new file mode 100644 index 00000000000..0a26160ff76 --- /dev/null +++ b/crates/credential/cargo-credential/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "cargo-credential" +version = "0.1.0" +authors = ["The Rust Project Developers"] +edition = "2018" +license = "MIT OR Apache-2.0" +repository = "https://github.com/rust-lang/cargo" +description = "A library to assist writing Cargo credential helpers." + +[dependencies] diff --git a/crates/credential/cargo-credential/README.md b/crates/credential/cargo-credential/README.md new file mode 100644 index 00000000000..1f75e598a3f --- /dev/null +++ b/crates/credential/cargo-credential/README.md @@ -0,0 +1,41 @@ +# cargo-credential + +This package is a library to assist writing a Cargo credential helper, which +provides an interface to store tokens for authorizing access to a registry +such as https://crates.io/. + +Documentation about credential processes may be found at +https://doc.rust-lang.org/nightly/cargo/reference/unstable.html#credential-process + +Example implementations may be found at +https://github.com/rust-lang/cargo/tree/master/crates/credential + +## Usage + +Create a Cargo project with this as a dependency: + +```toml +# Add this to your Cargo.toml: + +[dependencies] +cargo-credential = "0.1" +``` + +And then include a `main.rs` binary which implements the `Credential` trait, and calls +the `main` function which will call the appropriate method of the trait: + +```rust +// src/main.rs + +use cargo_credential::{Credential, Error}; + +struct MyCredential; + +impl Credential for MyCredential { + /// implement trait methods here... +} + +fn main() { + cargo_credential::main(MyCredential); +} +``` diff --git a/crates/credential/cargo-credential/src/lib.rs b/crates/credential/cargo-credential/src/lib.rs new file mode 100644 index 00000000000..43dc0ba3176 --- /dev/null +++ b/crates/credential/cargo-credential/src/lib.rs @@ -0,0 +1,86 @@ +//! Helper library for writing Cargo credential processes. +//! +//! A credential process should have a `struct` that implements the `Credential` trait. +//! The `main` function should be called with an instance of that struct, such as: +//! +//! ```rust,ignore +//! fn main() { +//! 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; + +pub trait Credential { + /// Returns the name of this credential process. + fn name(&self) -> &'static str; + + /// Retrieves a token for the given registry. + fn get(&self, registry_name: &str, api_url: &str) -> Result; + + /// Stores the given token for the given registry. + fn store(&self, registry_name: &str, api_url: &str, token: &str) -> Result<(), Error>; + + /// 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, registry_name: &str, api_url: &str) -> Result<(), Error>; +} + +/// 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); + } +} + +fn env(name: &str) -> Result { + std::env::var(name).map_err(|_| format!("environment variable `{}` is not set", name).into()) +} + +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 registry_name = env("CARGO_REGISTRY_NAME")?; + let api_url = env("CARGO_REGISTRY_API_URL")?; + let result = match which.as_ref() { + "get" => credential.get(®istry_name, &api_url).and_then(|token| { + println!("{}", token); + Ok(()) + }), + "store" => { + read_token().and_then(|token| credential.store(®istry_name, &api_url, &token)) + } + "erase" => credential.erase(®istry_name, &api_url), + _ => { + return Err(format!( + "unexpected command-line argument `{}`, expected get/store/erase", + which + ) + .into()) + } + }; + result.map_err(|e| format!("failed to `{}` token: {}", which, e).into()) +} + +fn read_token() -> Result { + let mut buffer = String::new(); + std::io::stdin().read_line(&mut buffer)?; + if buffer.ends_with('\n') { + buffer.pop(); + } + Ok(buffer) +} diff --git a/src/bin/cargo/commands/logout.rs b/src/bin/cargo/commands/logout.rs new file mode 100644 index 00000000000..5fcc4ea64c6 --- /dev/null +++ b/src/bin/cargo/commands/logout.rs @@ -0,0 +1,42 @@ +use crate::command_prelude::*; +use anyhow::format_err; +use cargo::core::features; +use cargo::ops; + +pub fn cli() -> App { + subcommand("logout") + .about("Remove an API token from the registry locally") + .arg(opt("quiet", "No output printed to stdout").short("q")) + .arg(opt("registry", "Registry to use").value_name("REGISTRY")) + .after_help("Run `cargo help logout` for more detailed information.\n") +} + +pub fn exec(config: &mut Config, args: &ArgMatches<'_>) -> CliResult { + let unstable = config.cli_unstable(); + if !(unstable.credential_process || unstable.unstable_options) { + const SEE: &str = "See https://github.com/rust-lang/cargo/issues/8933 for more \ + information about the `cargo logout` command."; + if features::nightly_features_allowed() { + return Err(format_err!( + "the `cargo logout` command is unstable, pass `-Z unstable-options` to enable it\n\ + {}", + SEE + ) + .into()); + } else { + return Err(format_err!( + "the `cargo logout` command is unstable, and only available on the \ + nightly channel of Cargo, but this is the `{}` channel\n\ + {}\n\ + {}", + features::channel(), + features::SEE_CHANNELS, + SEE + ) + .into()); + } + } + config.load_credentials()?; + ops::registry_logout(config, args.value_of("registry").map(String::from))?; + Ok(()) +} diff --git a/src/bin/cargo/commands/mod.rs b/src/bin/cargo/commands/mod.rs index d668809f060..eb72c955f05 100644 --- a/src/bin/cargo/commands/mod.rs +++ b/src/bin/cargo/commands/mod.rs @@ -15,6 +15,7 @@ pub fn builtin() -> Vec { install::cli(), locate_project::cli(), login::cli(), + logout::cli(), metadata::cli(), new::cli(), owner::cli(), @@ -52,6 +53,7 @@ pub fn builtin_exec(cmd: &str) -> Option) -> Cli "install" => install::exec, "locate-project" => locate_project::exec, "login" => login::exec, + "logout" => logout::exec, "metadata" => metadata::exec, "new" => new::exec, "owner" => owner::exec, @@ -90,6 +92,7 @@ pub mod init; pub mod install; pub mod locate_project; pub mod login; +pub mod logout; pub mod metadata; pub mod new; pub mod owner; diff --git a/src/cargo/core/features.rs b/src/cargo/core/features.rs index 24b9fdd0c11..487abcb5146 100644 --- a/src/cargo/core/features.rs +++ b/src/cargo/core/features.rs @@ -360,6 +360,7 @@ pub struct CliUnstable { pub namespaced_features: bool, pub weak_dep_features: bool, pub extra_link_arg: bool, + pub credential_process: bool, } fn deserialize_build_std<'de, D>(deserializer: D) -> Result>, D::Error> @@ -468,6 +469,7 @@ impl CliUnstable { "namespaced-features" => self.namespaced_features = parse_empty(k, v)?, "weak-dep-features" => self.weak_dep_features = parse_empty(k, v)?, "extra-link-arg" => self.extra_link_arg = parse_empty(k, v)?, + "credential-process" => self.credential_process = parse_empty(k, v)?, _ => bail!("unknown `-Z` flag specified: {}", k), } diff --git a/src/cargo/ops/mod.rs b/src/cargo/ops/mod.rs index dc45fc41dcb..4853993e4a5 100644 --- a/src/cargo/ops/mod.rs +++ b/src/cargo/ops/mod.rs @@ -20,9 +20,9 @@ pub use self::cargo_uninstall::uninstall; pub use self::fix::{fix, fix_maybe_exec_rustc, FixOptions}; pub use self::lockfile::{load_pkg_lockfile, resolve_to_string, write_pkg_lockfile}; pub use self::registry::HttpTimeout; -pub use self::registry::{configure_http_handle, http_handle_and_timeout}; -pub use self::registry::{http_handle, needs_custom_http_transport, registry_login, search}; +pub use self::registry::{configure_http_handle, http_handle, http_handle_and_timeout}; pub use self::registry::{modify_owners, yank, OwnersOptions, PublishOpts}; +pub use self::registry::{needs_custom_http_transport, registry_login, registry_logout, search}; pub use self::registry::{publish, registry_configuration, RegistryConfig}; pub use self::resolve::{ add_overrides, get_resolved_packages, resolve_with_previous, resolve_ws, resolve_ws_with_opts, diff --git a/src/cargo/ops/registry.rs b/src/cargo/ops/registry.rs index a96151fcd5a..1d1731a7da3 100644 --- a/src/cargo/ops/registry.rs +++ b/src/cargo/ops/registry.rs @@ -2,6 +2,7 @@ use std::collections::{BTreeMap, HashSet}; use std::fs::File; use std::io::{self, BufRead}; use std::iter::repeat; +use std::path::PathBuf; use std::str; use std::time::Duration; use std::{cmp, env}; @@ -25,14 +26,19 @@ use crate::util::IntoUrl; use crate::util::{paths, validate_package_name}; use crate::{drop_print, drop_println, version}; +mod auth; + /// Registry settings loaded from config files. /// /// This is loaded based on the `--registry` flag and the config settings. +#[derive(Debug)] pub struct RegistryConfig { /// The index URL. If `None`, use crates.io. pub index: Option, /// The authentication token. pub token: Option, + /// Process used for fetching a token. + pub credential_process: Option<(PathBuf, Vec)>, } pub struct PublishOpts<'cfg> { @@ -83,7 +89,7 @@ pub fn publish(ws: &Workspace<'_>, opts: &PublishOpts<'_>) -> CargoResult<()> { } } - let (mut registry, reg_id) = registry( + let (mut registry, _reg_cfg, reg_id) = registry( opts.config, opts.token.clone(), opts.index.clone(), @@ -346,27 +352,67 @@ fn transmit( /// `None`, `index` is set to `None` to indicate it should use crates.io. pub fn registry_configuration( config: &Config, - registry: Option, + registry: Option<&str>, ) -> CargoResult { + let err_both = |token_key: &str, proc_key: &str| { + Err(format_err!( + "both `{TOKEN_KEY}` and `{PROC_KEY}` \ + were specified in the config\n\ + Only one of these values may be set, remove one or the other to proceed.", + TOKEN_KEY = token_key, + PROC_KEY = proc_key, + )) + }; // `registry.default` is handled in command-line parsing. - let (index, token) = match registry { + let (index, token, process) = match registry { Some(registry) => { validate_package_name(®istry, "registry name", "")?; - ( - Some(config.get_registry_index(®istry)?.to_string()), - config - .get_string(&format!("registries.{}.token", registry))? - .map(|p| p.val), - ) + let index = Some(config.get_registry_index(®istry)?.to_string()); + let token_key = format!("registries.{}.token", registry); + let token = config.get_string(&token_key)?.map(|p| p.val); + let process = if config.cli_unstable().credential_process { + let mut proc_key = format!("registries.{}.credential-process", registry); + let mut process = config.get::>(&proc_key)?; + if process.is_none() && token.is_none() { + // This explicitly ignores the global credential-process if + // the token is set, as that is "more specific". + proc_key = String::from("registry.credential-process"); + process = config.get::>(&proc_key)?; + } else if process.is_some() && token.is_some() { + return err_both(&token_key, &proc_key); + } + process + } else { + None + }; + (index, token, process) } None => { // Use crates.io default. config.check_registry_index_not_set()?; - (None, config.get_string("registry.token")?.map(|p| p.val)) + let token = config.get_string("registry.token")?.map(|p| p.val); + let process = if config.cli_unstable().credential_process { + let process = + config.get::>("registry.credential-process")?; + if token.is_some() && process.is_some() { + return err_both("registry.token", "registry.credential-process"); + } + process + } else { + None + }; + (None, token, process) } }; - Ok(RegistryConfig { index, token }) + let credential_process = + process.map(|process| (process.path.resolve_program(config), process.args)); + + Ok(RegistryConfig { + index, + token, + credential_process, + }) } /// Returns the `Registry` and `Source` based on command-line and config settings. @@ -387,17 +433,14 @@ fn registry( registry: Option, force_update: bool, validate_token: bool, -) -> CargoResult<(Registry, SourceId)> { +) -> CargoResult<(Registry, RegistryConfig, SourceId)> { if index.is_some() && registry.is_some() { // Otherwise we would silently ignore one or the other. bail!("both `--index` and `--registry` should not be set at the same time"); } // Parse all configuration options - let RegistryConfig { - token: token_config, - index: index_config, - } = registry_configuration(config, registry.clone())?; - let opt_index = index_config.as_ref().or_else(|| index.as_ref()); + let reg_cfg = registry_configuration(config, registry.as_deref())?; + let opt_index = reg_cfg.index.as_ref().or_else(|| index.as_ref()); let sid = get_source_id(config, opt_index, registry.as_ref())?; if !sid.is_remote_registry() { bail!( @@ -426,52 +469,49 @@ fn registry( cfg.and_then(|cfg| cfg.api) .ok_or_else(|| format_err!("{} does not support API commands", sid))? }; - let token = match (&index, &token, &token_config) { - // No token. - (None, None, None) => { - if validate_token { - bail!("no upload token found, please run `cargo login` or pass `--token`"); + let token = if validate_token { + if index.is_some() { + if !token.is_some() { + bail!("command-line argument --index requires --token to be specified"); } - None - } - // Token on command-line. - (_, Some(_), _) => token, - // Token in config, no --index, loading from config is OK for crates.io. - (None, None, Some(_)) => { + token + } else { // Check `is_default_registry` so that the crates.io index can // change config.json's "api" value, and this won't affect most // people. It will affect those using source replacement, but // hopefully that's a relatively small set of users. - if registry.is_none() + if token.is_none() + && reg_cfg.token.is_some() + && registry.is_none() && !sid.is_default_registry() && !crates_io::is_url_crates_io(&api_host) { - if validate_token { - config.shell().warn( - "using `registry.token` config value with source \ + config.shell().warn( + "using `registry.token` config value with source \ replacement is deprecated\n\ This may become a hard error in the future; \ see .\n\ Use the --token command-line flag to remove this warning.", - )?; - token_config - } else { - None - } + )?; + reg_cfg.token.clone() } else { - token_config + let token = auth::auth_token( + config, + token.as_deref(), + reg_cfg.token.as_deref(), + reg_cfg.credential_process.as_ref(), + registry.as_deref(), + &api_host, + )?; + log::debug!("found token {:?}", token); + Some(token) } } - // --index, no --token - (Some(_), None, _) => { - if validate_token { - bail!("command-line argument --index requires --token to be specified") - } - None - } + } else { + None }; let handle = http_handle(config)?; - Ok((Registry::new_handle(api_host, token, handle), sid)) + Ok((Registry::new_handle(api_host, token, handle), reg_cfg, sid)) } /// Creates a new HTTP handle with appropriate global configuration for cargo. @@ -674,7 +714,7 @@ pub fn registry_login( token: Option, reg: Option, ) -> CargoResult<()> { - let (registry, _) = registry(config, token.clone(), None, reg.clone(), false, false)?; + let (registry, reg_cfg, _) = registry(config, token.clone(), None, reg.clone(), false, false)?; let token = match token { Some(token) => token, @@ -696,18 +736,21 @@ pub fn registry_login( } }; - let RegistryConfig { - token: old_token, .. - } = registry_configuration(config, reg.clone())?; - - if let Some(old_token) = old_token { - if old_token == token { + if let Some(old_token) = ®_cfg.token { + if old_token == &token { config.shell().status("Login", "already logged in")?; return Ok(()); } } - config::save_credentials(config, token, reg.clone())?; + auth::login( + config, + token, + reg_cfg.credential_process.as_ref(), + reg.as_deref(), + registry.host(), + )?; + config.shell().status( "Login", format!( @@ -718,6 +761,32 @@ pub fn registry_login( Ok(()) } +pub fn registry_logout(config: &Config, reg: Option) -> CargoResult<()> { + let (registry, reg_cfg, _) = registry(config, None, None, reg.clone(), false, false)?; + let reg_name = reg.as_deref().unwrap_or("crates.io"); + if reg_cfg.credential_process.is_none() && reg_cfg.token.is_none() { + config.shell().status( + "Logout", + format!("not currently logged in to `{}`", reg_name), + )?; + return Ok(()); + } + auth::logout( + config, + reg_cfg.credential_process.as_ref(), + reg.as_deref(), + registry.host(), + )?; + config.shell().status( + "Logout", + format!( + "token for `{}` has been removed from local storage", + reg_name + ), + )?; + Ok(()) +} + pub struct OwnersOptions { pub krate: Option, pub token: Option, @@ -738,7 +807,7 @@ pub fn modify_owners(config: &Config, opts: &OwnersOptions) -> CargoResult<()> { } }; - let (mut registry, _) = registry( + let (mut registry, _, _) = registry( config, opts.token.clone(), opts.index.clone(), @@ -805,7 +874,7 @@ pub fn yank( None => bail!("a version must be specified to yank"), }; - let (mut registry, _) = registry(config, token, index, reg, true, true)?; + let (mut registry, _, _) = registry(config, token, index, reg, true, true)?; if undo { config @@ -865,7 +934,7 @@ pub fn search( prefix } - let (mut registry, source_id) = registry(config, None, index, reg, false, false)?; + let (mut registry, _, source_id) = registry(config, None, index, reg, false, false)?; let (crates, total_crates) = registry .search(query, limit) .chain_err(|| "failed to retrieve search results from the registry")?; diff --git a/src/cargo/ops/registry/auth.rs b/src/cargo/ops/registry/auth.rs new file mode 100644 index 00000000000..660daa40d95 --- /dev/null +++ b/src/cargo/ops/registry/auth.rs @@ -0,0 +1,236 @@ +//! Registry authentication support. + +use crate::sources::CRATES_IO_REGISTRY; +use crate::util::{config, process_error, CargoResult, CargoResultExt, Config}; +use anyhow::bail; +use anyhow::format_err; +use std::io::{Read, Write}; +use std::path::PathBuf; +use std::process::{Command, Stdio}; + +enum Action { + Get, + Store(String), + Erase, +} + +/// Returns the token to use for the given registry. +pub(super) fn auth_token( + config: &Config, + cli_token: Option<&str>, + config_token: Option<&str>, + credential_process: Option<&(PathBuf, Vec)>, + registry_name: Option<&str>, + api_url: &str, +) -> CargoResult { + let token = match (cli_token, config_token, credential_process) { + (None, None, None) => { + bail!("no upload token found, please run `cargo login` or pass `--token`"); + } + (Some(cli_token), _, _) => cli_token.to_string(), + (None, Some(config_token), _) => config_token.to_string(), + (None, None, Some(process)) => { + let registry_name = registry_name.unwrap_or(CRATES_IO_REGISTRY); + run_command(config, process, registry_name, api_url, Action::Get)?.unwrap() + } + }; + Ok(token) +} + +/// Saves the given token. +pub(super) fn login( + config: &Config, + token: String, + credential_process: Option<&(PathBuf, Vec)>, + registry_name: Option<&str>, + api_url: &str, +) -> CargoResult<()> { + if let Some(process) = credential_process { + let registry_name = registry_name.unwrap_or(CRATES_IO_REGISTRY); + run_command( + config, + process, + registry_name, + api_url, + Action::Store(token), + )?; + } else { + config::save_credentials(config, Some(token), registry_name)?; + } + Ok(()) +} + +/// Removes the token for the given registry. +pub(super) fn logout( + config: &Config, + credential_process: Option<&(PathBuf, Vec)>, + registry_name: Option<&str>, + api_url: &str, +) -> CargoResult<()> { + if let Some(process) = credential_process { + let registry_name = registry_name.unwrap_or(CRATES_IO_REGISTRY); + run_command(config, process, registry_name, api_url, Action::Erase)?; + } else { + config::save_credentials(config, None, registry_name)?; + } + Ok(()) +} + +fn run_command( + config: &Config, + process: &(PathBuf, Vec), + name: &str, + api_url: &str, + action: Action, +) -> CargoResult> { + 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")), + } + } + 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("{name}", name) + .replace("{api_url}", api_url) + }) + .collect(); + + let mut cmd = Command::new(&exe); + cmd.args(args) + .env("CARGO", config.cargo_exe()?) + .env("CARGO_REGISTRY_NAME", name) + .env("CARGO_REGISTRY_API_URL", api_url); + match action { + Action::Get => { + cmd.stdout(Stdio::piped()); + } + Action::Store(_) => { + cmd.stdin(Stdio::piped()); + } + Action::Erase => {} + } + let mut child = cmd.spawn().chain_err(|| { + 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, + 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) + .chain_err(|| { + 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); + } + token = Some(buffer); + } + Action::Store(token) => { + writeln!(child.stdin.as_ref().unwrap(), "{}", token).chain_err(|| { + format!( + "failed to send token to registry credential process `{}`", + exe.display() + ) + })?; + } + Action::Erase => {} + } + let status = child.wait().chain_err(|| { + 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(process_error( + &format!( + "registry credential process `{}` {} `{}`", + exe.display(), + msg, + name + ), + Some(status), + None, + ) + .into()); + } + Ok(token) +} + +/// 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)) +} diff --git a/src/cargo/util/config/mod.rs b/src/cargo/util/config/mod.rs index 9a3c5b0df43..c8f8a42ed4a 100644 --- a/src/cargo/util/config/mod.rs +++ b/src/cargo/util/config/mod.rs @@ -63,7 +63,7 @@ use std::str::FromStr; use std::sync::Once; use std::time::Instant; -use anyhow::{anyhow, bail}; +use anyhow::{anyhow, bail, format_err}; use curl::easy::Easy; use lazycell::LazyCell; use serde::Deserialize; @@ -1620,7 +1620,11 @@ pub fn homedir(cwd: &Path) -> Option { ::home::cargo_home_with_cwd(cwd).ok() } -pub fn save_credentials(cfg: &Config, token: String, registry: Option) -> CargoResult<()> { +pub fn save_credentials( + cfg: &Config, + token: Option, + registry: Option<&str>, +) -> CargoResult<()> { // If 'credentials.toml' exists, we should write to that, otherwise // use the legacy 'credentials'. There's no need to print the warning // here, because it would already be printed at load time. @@ -1639,25 +1643,6 @@ pub fn save_credentials(cfg: &Config, token: String, registry: Option) - .open_rw(filename, cfg, "credentials' config file")? }; - let (key, mut value) = { - let key = "token".to_string(); - let value = ConfigValue::String(token, Definition::Path(file.path().to_path_buf())); - let mut map = HashMap::new(); - map.insert(key, value); - let table = CV::Table(map, Definition::Path(file.path().to_path_buf())); - - if let Some(registry) = registry.clone() { - let mut map = HashMap::new(); - map.insert(registry, table); - ( - "registries".into(), - CV::Table(map, Definition::Path(file.path().to_path_buf())), - ) - } else { - ("registry".into(), table) - } - }; - let mut contents = String::new(); file.read_to_string(&mut contents).chain_err(|| { format!( @@ -1677,13 +1662,55 @@ pub fn save_credentials(cfg: &Config, token: String, registry: Option) - .insert("registry".into(), map.into()); } - if registry.is_some() { - if let Some(table) = toml.as_table_mut().unwrap().remove("registries") { - let v = CV::from_toml(Definition::Path(file.path().to_path_buf()), table)?; - value.merge(v, false)?; + if let Some(token) = token { + // login + let (key, mut value) = { + let key = "token".to_string(); + let value = ConfigValue::String(token, Definition::Path(file.path().to_path_buf())); + let mut map = HashMap::new(); + map.insert(key, value); + let table = CV::Table(map, Definition::Path(file.path().to_path_buf())); + + if let Some(registry) = registry { + let mut map = HashMap::new(); + map.insert(registry.to_string(), table); + ( + "registries".into(), + CV::Table(map, Definition::Path(file.path().to_path_buf())), + ) + } else { + ("registry".into(), table) + } + }; + + if registry.is_some() { + if let Some(table) = toml.as_table_mut().unwrap().remove("registries") { + let v = CV::from_toml(Definition::Path(file.path().to_path_buf()), table)?; + value.merge(v, false)?; + } + } + toml.as_table_mut().unwrap().insert(key, value.into_toml()); + } else { + // logout + let table = toml.as_table_mut().unwrap(); + if let Some(registry) = registry { + if let Some(registries) = table.get_mut("registries") { + if let Some(reg) = registries.get_mut(registry) { + let rtable = reg.as_table_mut().ok_or_else(|| { + format_err!("expected `[registries.{}]` to be a table", registry) + })?; + rtable.remove("token"); + } + } + } else { + if let Some(registry) = table.get_mut("registry") { + let reg_table = registry + .as_table_mut() + .ok_or_else(|| format_err!("expected `[registry]` to be a table"))?; + reg_table.remove("token"); + } } } - toml.as_table_mut().unwrap().insert(key, value.into_toml()); let contents = toml.to_string(); file.seek(SeekFrom::Start(0))?; diff --git a/src/doc/src/reference/unstable.md b/src/doc/src/reference/unstable.md index 85f17071f56..4ffc13f450f 100644 --- a/src/doc/src/reference/unstable.md +++ b/src/doc/src/reference/unstable.md @@ -961,3 +961,179 @@ std = ["serde?/std"] In this example, the `std` feature enables the `std` feature on the `serde` dependency. However, unlike the normal `serde/std` syntax, it will not enable the optional dependency `serde` unless something else has included it. + +### credential-process +* Tracking Issue: [#8933](https://github.com/rust-lang/cargo/issues/8933) +* RFC: [#2730](https://github.com/rust-lang/rfcs/pull/2730) + +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`], and [`cargo yank`] commands. Additionally, this feature adds +a new `cargo logout` command. + +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` file] (which can be done with the new `logout` command). + +#### `credential-process` Configuration + +To configure which process to run to fetch the token, specify the process in +the `registry` table in a [config file]: + +```toml +[registry] +credential-process = "/usr/bin/cargo-creds" +``` + +If you want to use a different process for a specific registry, it can be +specified in the `registries` table: + +```toml +[registries.my-registry] +credential-process = "/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: + +```toml +[registry] +credential-process = "cargo:macos-keychain" +``` + +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. +* `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 + signin` at least once with the appropriate arguments (such as `op signin + my.1password.com user@example.com`), unless you provide the sign-in-address + and email arguments. The master password will be required on each request + unless the appropriate `OP_SESSION` environment variable is set. It supports + the following command-line arguments: + * `--account`: The account shorthand name to use. + * `--vault`: The vault name to use. + * `--sign-in-address`: The sign-in-address, which is a web address such as `my.1password.com`. + * `--email`: The email address to sign in with. + +A wrapper is available for GNOME +[libsecret](https://wiki.gnome.org/Projects/Libsecret) to store tokens on +Linux systems. Due to build limitations, this wrapper is not available as a +pre-compiled binary. This can be built and installed manually. First, install +libsecret using your system package manager (for example, `sudo apt install +libsecret-1-dev`). Then build and install the wrapper with `cargo install +--git https://github.com/rust-lang/cargo.git cargo-credential-gnome-secret`. +In the config, use a path to the binary like this: + +```toml +[registry] +credential-process = "cargo-credential-gnome-secret {action}" +``` + +#### `credential-process` Interface + +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. + +##### Basic authenticator + +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. + +* `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. + + If the process is unable to fulfill the request, it should exit with a + nonzero exit code. + +* `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. + +##### Environment + +The following environment variables will be provided to the executed command: + +* `CARGO` — Path to the `cargo` binary executing the command. +* `CARGO_REGISTRY_NAME` — Name of the registry the authentication token is for. +* `CARGO_REGISTRY_API_URL` — The URL of the registry API. + +#### `cargo logout` + +A new `cargo logout` command has been added to make it easier to remove a +token from storage. This supports both [`credentials` file] tokens and +`credential-process` tokens. + +When used with `credentials` file tokens, it needs the `-Z unstable-options` +command-line option: + +```console +cargo logout -Z unstable-options` +``` + +When used with the `credential-process` config, use the `-Z +credential-process` command-line option: + + +```console +cargo logout -Z credential-process` +``` + +[`cargo login`]: ../commands/cargo-login.md +[`cargo publish`]: ../commands/cargo-publish.md +[`cargo owner`]: ../commands/cargo-owner.md +[`cargo yank`]: ../commands/cargo-yank.md +[`credentials` file]: config.md#credentials +[crates.io]: https://crates.io/ +[config file]: config.md diff --git a/tests/testsuite/credential_process.rs b/tests/testsuite/credential_process.rs new file mode 100644 index 00000000000..8360ae4c627 --- /dev/null +++ b/tests/testsuite/credential_process.rs @@ -0,0 +1,488 @@ +//! Tests for credential-process. + +use cargo_test_support::paths::CargoPathExt; +use cargo_test_support::{basic_manifest, cargo_process, paths, project, registry, Project}; +use std::fs; +use std::io::{BufRead, BufReader, Write}; +use std::net::TcpListener; +use std::thread; +use url::Url; + +fn toml_bin(proj: &Project, name: &str) -> String { + proj.bin(name).display().to_string().replace('\\', "\\\\") +} + +#[cargo_test] +fn gated() { + registry::init(); + + paths::home().join(".cargo/credentials").rm_rf(); + + let p = project() + .file( + ".cargo/config", + r#" + [registry] + credential-process = "false" + "#, + ) + .file("Cargo.toml", &basic_manifest("foo", "1.0.0")) + .file("src/lib.rs", "") + .build(); + + p.cargo("publish --no-verify") + .masquerade_as_nightly_cargo() + .with_status(101) + .with_stderr( + "\ +[UPDATING] [..] +[ERROR] no upload token found, please run `cargo login` or pass `--token` +", + ) + .run(); + + p.change_file( + ".cargo/config", + r#" + [registry.alternative] + credential-process = "false" + "#, + ); + + p.cargo("publish --no-verify --registry alternative") + .masquerade_as_nightly_cargo() + .with_status(101) + .with_stderr( + "\ +[UPDATING] [..] +[ERROR] no upload token found, please run `cargo login` or pass `--token` +", + ) + .run(); +} + +#[cargo_test] +fn warn_both_token_and_process() { + // Specifying both credential-process and a token in config should issue a warning. + registry::init(); + paths::home().join(".cargo/credentials").rm_rf(); + let p = project() + .file( + ".cargo/config", + r#" + [registries.alternative] + token = "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() + .with_status(101) + .with_stderr( + "\ +[ERROR] both `registries.alternative.token` and `registries.alternative.credential-process` \ +were specified in the config\n\ +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 = "sekrit" + "#, + ); + p.cargo("publish --no-verify --registry alternative -Z credential-process") + .masquerade_as_nightly_cargo() + .with_stderr( + "\ +[UPDATING] [..] +[PACKAGING] foo v0.1.0 [..] +[UPLOADING] foo v0.1.0 [..] +", + ) + .run(); +} + +/// Setup for a test that will issue a command that needs to fetch a token. +/// +/// This does the following: +/// +/// * Spawn a thread that will act as an API server. +/// * Create a simple credential-process that will generate a fake token. +/// * Create a simple `foo` project to run the test against. +/// * Configure the credential-process config. +/// +/// Returns a thread handle for the API server, the test should join it when +/// finished. Also returns the simple `foo` project to test against. +fn get_token_test() -> (Project, thread::JoinHandle<()>) { + let server = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = server.local_addr().unwrap(); + let api_url = format!("http://{}", addr); + + registry::init_registry( + registry::alt_registry_path(), + registry::alt_dl_url(), + Url::parse(&api_url).unwrap(), + registry::alt_api_path(), + ); + + // API server that checks that the token is included correctly. + let t = thread::spawn(move || { + let mut conn = BufReader::new(server.accept().unwrap().0); + let headers: Vec<_> = (&mut conn) + .lines() + .map(|s| s.unwrap()) + .take_while(|s| s.len() > 2) + .map(|s| s.trim().to_string()) + .collect(); + assert!(headers + .iter() + .any(|header| header == "Authorization: sekrit")); + conn.get_mut() + .write_all( + b"HTTP/1.1 200\r\n\ + Content-Length: 33\r\n\ + \r\n\ + {\"ok\": true, \"msg\": \"completed!\"}\r\n", + ) + .unwrap(); + }); + + // 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#"fn main() { println!("sekrit"); } "#) + .build(); + cred_proj.cargo("build").run(); + + let p = project() + .file( + ".cargo/config", + &format!( + r#" + [registries.alternative] + index = "{}" + credential-process = ["{}"] + "#, + registry::alt_registry_url(), + toml_bin(&cred_proj, "test-cred") + ), + ) + .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, t) +} + +#[cargo_test] +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") + .masquerade_as_nightly_cargo() + .with_stderr( + "\ +[UPDATING] [..] +[PACKAGING] foo v0.1.0 [..] +[UPLOADING] foo v0.1.0 [..] +", + ) + .run(); + + t.join().ok().unwrap(); +} + +#[cargo_test] +fn basic_unsupported() { + // Non-action commands don't support login/logout. + registry::init(); + // If both `credential-process` and `token` are specified, it will ignore + // `credential-process`, so remove the default tokens. + paths::home().join(".cargo/credentials").rm_rf(); + cargo::util::paths::append( + &paths::home().join(".cargo/config"), + br#" + [registry] + credential-process = "false" + "#, + ) + .unwrap(); + + cargo_process("login -Z credential-process abcdefg") + .masquerade_as_nightly_cargo() + .with_status(101) + .with_stderr( + "\ +[UPDATING] [..] +[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 +", + ) + .run(); + + cargo_process("logout -Z credential-process") + .masquerade_as_nightly_cargo() + .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 +", + ) + .run(); +} + +#[cargo_test] +fn login() { + registry::init(); + // 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::io::Read; + + fn main() { + assert_eq!(std::env::var("CARGO_REGISTRY_NAME").unwrap(), "crates-io"); + assert_eq!(std::env::var("CARGO_REGISTRY_API_URL").unwrap(), "__API__"); + 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(); + } + "# + .replace("__API__", ®istry::api_url().to_string()), + ) + .build(); + cred_proj.cargo("build").run(); + + cargo::util::paths::append( + &paths::home().join(".cargo/config"), + format!( + r#" + [registry] + credential-process = ["{}", "{{action}}"] + "#, + toml_bin(&cred_proj, "test-cred") + ) + .as_bytes(), + ) + .unwrap(); + + cargo_process("login -Z credential-process abcdefg") + .masquerade_as_nightly_cargo() + .with_stderr( + "\ +[UPDATING] [..] +[LOGIN] token for `crates.io` saved +", + ) + .run(); + assert_eq!( + fs::read_to_string(paths::root().join("token-store")).unwrap(), + "abcdefg\n" + ); +} + +#[cargo_test] +fn logout() { + registry::init(); + // If both `credential-process` and `token` are specified, it will ignore + // `credential-process`, so remove the default tokens. + paths::home().join(".cargo/credentials").rm_rf(); + // 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::io::Read; + + fn main() { + assert_eq!(std::env::var("CARGO_REGISTRY_NAME").unwrap(), "crates-io"); + assert_eq!(std::env::args().skip(1).next().unwrap(), "erase"); + std::fs::write("token-store", "").unwrap(); + eprintln!("token for `{}` has been erased!", + std::env::var("CARGO_REGISTRY_NAME").unwrap()); + } + "#, + ) + .build(); + cred_proj.cargo("build").run(); + + cargo::util::paths::append( + &paths::home().join(".cargo/config"), + format!( + r#" + [registry] + credential-process = ["{}", "{{action}}"] + "#, + toml_bin(&cred_proj, "test-cred") + ) + .as_bytes(), + ) + .unwrap(); + + cargo_process("logout -Z credential-process") + .masquerade_as_nightly_cargo() + .with_stderr( + "\ +[UPDATING] [..] +token for `crates-io` has been erased! +[LOGOUT] token for `crates.io` has been removed from local storage +", + ) + .run(); + assert_eq!( + fs::read_to_string(paths::root().join("token-store")).unwrap(), + "" + ); +} + +#[cargo_test] +fn yank() { + let (p, t) = get_token_test(); + + p.cargo("yank --vers 0.1.0 --registry alternative -Z credential-process") + .masquerade_as_nightly_cargo() + .with_stderr( + "\ +[UPDATING] [..] +[YANK] foo:0.1.0 +", + ) + .run(); + + t.join().ok().unwrap(); +} + +#[cargo_test] +fn owner() { + let (p, t) = get_token_test(); + + p.cargo("owner --add username --registry alternative -Z credential-process") + .masquerade_as_nightly_cargo() + .with_stderr( + "\ +[UPDATING] [..] +[OWNER] completed! +", + ) + .run(); + + t.join().ok().unwrap(); +} + +#[cargo_test] +fn libexec_path() { + // cargo: prefixed names use the sysroot + registry::init(); + + paths::home().join(".cargo/credentials").rm_rf(); + 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() + .with_status(101) + .with_stderr( + &format!("\ +[UPDATING] [..] +[ERROR] failed to execute `[..]libexec/cargo-credential-doesnotexist[EXE]` to store authentication token for registry `crates-io` + +Caused by: + {} +", cargo_test_support::no_such_file_err_msg()), + ) + .run(); +} + +#[cargo_test] +fn invalid_token_output() { + // Error when credential process does not output the expected format for a token. + registry::init(); + paths::home().join(".cargo/credentials").rm_rf(); + 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"); } "#) + .build(); + cred_proj.cargo("build").run(); + + cargo::util::paths::append( + &paths::home().join(".cargo/config"), + format!( + r#" + [registry] + credential-process = ["{}"] + "#, + 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(); + + p.cargo("publish --no-verify --registry alternative -Z credential-process") + .masquerade_as_nightly_cargo() + .with_status(101) + .with_stderr( + "\ +[UPDATING] [..] +[ERROR] credential process `[..]test-cred[EXE]` returned more than one line of output; expected a single token +", + ) + .run(); +} diff --git a/tests/testsuite/logout.rs b/tests/testsuite/logout.rs new file mode 100644 index 00000000000..041d3fb5976 --- /dev/null +++ b/tests/testsuite/logout.rs @@ -0,0 +1,82 @@ +//! Tests for the `cargo logout` command. + +use cargo_test_support::install::cargo_home; +use cargo_test_support::{cargo_process, registry}; +use std::fs; + +#[cargo_test] +fn gated() { + registry::init(); + cargo_process("logout") + .masquerade_as_nightly_cargo() + .with_status(101) + .with_stderr( + "\ +[ERROR] the `cargo logout` command is unstable, pass `-Z unstable-options` to enable it +See https://github.com/rust-lang/cargo/issues/8933 for more information about \ +the `cargo logout` command. +", + ) + .run(); +} + +/// Checks whether or not the token is set for the given token. +fn check_config_token(registry: Option<&str>, should_be_set: bool) { + let credentials = cargo_home().join("credentials"); + let contents = fs::read_to_string(&credentials).unwrap(); + let toml: toml::Value = contents.parse().unwrap(); + if let Some(registry) = registry { + assert_eq!( + toml.get("registries") + .and_then(|registries| registries.get(registry)) + .and_then(|registry| registry.get("token")) + .is_some(), + should_be_set + ); + } else { + assert_eq!( + toml.get("registry") + .and_then(|registry| registry.get("token")) + .is_some(), + should_be_set + ); + } +} + +fn simple_logout_test(reg: Option<&str>, flag: &str) { + registry::init(); + let msg = reg.unwrap_or("crates.io"); + check_config_token(reg, true); + cargo_process(&format!("logout -Z unstable-options {}", flag)) + .masquerade_as_nightly_cargo() + .with_stderr(&format!( + "\ +[UPDATING] [..] +[LOGOUT] token for `{}` has been removed from local storage +", + msg + )) + .run(); + check_config_token(reg, false); + + cargo_process(&format!("logout -Z unstable-options {}", flag)) + .masquerade_as_nightly_cargo() + .with_stderr(&format!( + "\ +[LOGOUT] not currently logged in to `{}` +", + msg + )) + .run(); + check_config_token(reg, false); +} + +#[cargo_test] +fn default_registry() { + simple_logout_test(None, ""); +} + +#[cargo_test] +fn other_registry() { + simple_logout_test(Some("alternative"), "--registry alternative"); +} diff --git a/tests/testsuite/main.rs b/tests/testsuite/main.rs index 80d3d860c2d..8af5858b373 100644 --- a/tests/testsuite/main.rs +++ b/tests/testsuite/main.rs @@ -35,6 +35,7 @@ mod config; mod config_cli; mod config_include; mod corrupt_git; +mod credential_process; mod cross_compile; mod cross_publish; mod custom_target; @@ -65,6 +66,7 @@ mod local_registry; mod locate_project; mod lockfile_compat; mod login; +mod logout; mod lto; mod member_discovery; mod member_errors;