Implement credential-process.

This commit is contained in:
Eric Huss 2020-12-01 11:16:16 -08:00
parent 6bc510d33a
commit cc6df1d7a5
25 changed files with 1999 additions and 96 deletions

View File

@ -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

View File

@ -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 &macros {

View File

@ -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)
}
}
}

View File

@ -0,0 +1,11 @@
[package]
name = "cargo-credential-1password"
version = "0.1.0"
authors = ["The Rust Project Developers"]
edition = "2018"
license = "MIT OR Apache-2.0"
[dependencies]
cargo-credential = { path = "../cargo-credential" }
serde = { version = "1.0.117", features = ["derive"] }
serde_json = "1.0.59"

View File

@ -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<String>,
vault: Option<String>,
sign_in_address: Option<String>,
email: Option<String>,
}
/// 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<Field>,
}
#[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<OnePasswordKeychain, Error> {
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<Option<String>, 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<String>, 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<String, Error> {
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<String>,
registry_name: &str,
) -> Result<Option<String>, Error> {
let cmd = self.make_cmd(
session,
&[
"list",
"items",
"--categories",
"Login",
"--tags",
CARGO_TAG,
],
);
let buffer = self.run_cmd(cmd)?;
let items: Vec<ListItem> = 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<String>, 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<String>,
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<String>, uuid: &str) -> Result<String, Error> {
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<String>, 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<String, Error> {
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);
}

View File

@ -0,0 +1,12 @@
[package]
name = "cargo-credential-gnome-secret"
version = "0.1.0"
authors = ["The Rust Project Developers"]
edition = "2018"
license = "MIT OR Apache-2.0"
[dependencies]
cargo-credential = { path = "../cargo-credential" }
[build-dependencies]
pkg-config = "0.3.19"

View File

@ -0,0 +1,3 @@
fn main() {
pkg_config::probe_library("libsecret-1").unwrap();
}

View File

@ -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<String, Error> {
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);
}

View File

@ -0,0 +1,10 @@
[package]
name = "cargo-credential-macos-keychain"
version = "0.1.0"
authors = ["The Rust Project Developers"]
edition = "2018"
license = "MIT OR Apache-2.0"
[dependencies]
cargo-credential = { path = "../cargo-credential" }
security-framework = "2.0.0"

View File

@ -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<String, Error> {
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);
}

View File

@ -0,0 +1,10 @@
[package]
name = "cargo-credential-wincred"
version = "0.1.0"
authors = ["The Rust Project Developers"]
edition = "2018"
license = "MIT OR Apache-2.0"
[dependencies]
cargo-credential = { path = "../cargo-credential" }
winapi = { version = "0.3.9", features = ["wincred", "winerror", "impl-default"] }

View File

@ -0,0 +1,93 @@
//! 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;
fn wstr(s: &str) -> Vec<u16> {
OsStr::new(s).encode_wide().chain(Some(0)).collect()
}
fn target_name(registry_name: &str) -> Vec<u16> {
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<String, Error> {
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);
}

View File

@ -0,0 +1,8 @@
[package]
name = "cargo-credential"
version = "0.1.0"
authors = ["The Rust Project Developers"]
edition = "2018"
license = "MIT OR Apache-2.0"
[dependencies]

View File

@ -0,0 +1,81 @@
//! 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);
//! }
//! ```
pub type Error = Box<dyn std::error::Error>;
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<String, Error>;
/// 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<String, Error> {
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(&registry_name, &api_url).and_then(|token| {
println!("{}", token);
Ok(())
}),
"store" => {
read_token().and_then(|token| credential.store(&registry_name, &api_url, &token))
}
"erase" => credential.erase(&registry_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<String, Error> {
let mut buffer = String::new();
std::io::stdin().read_line(&mut buffer)?;
if buffer.ends_with('\n') {
buffer.pop();
}
Ok(buffer)
}

View File

@ -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(())
}

View File

@ -15,6 +15,7 @@ pub fn builtin() -> Vec<App> {
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<fn(&mut Config, &ArgMatches<'_>) -> 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;

View File

@ -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<Option<Vec<String>>, 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),
}

View File

@ -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,

View File

@ -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<String>,
/// The authentication token.
pub token: Option<String>,
/// Process used for fetching a token.
pub credential_process: Option<(PathBuf, Vec<String>)>,
}
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,64 @@ fn transmit(
/// `None`, `index` is set to `None` to indicate it should use crates.io.
pub fn registry_configuration(
config: &Config,
registry: Option<String>,
registry: Option<&str>,
) -> CargoResult<RegistryConfig> {
// `registry.default` is handled in command-line parsing.
let (index, token) = match registry {
let (index, token, process, token_key, proc_key) = match registry {
Some(registry) => {
validate_package_name(&registry, "registry name", "")?;
(
Some(config.get_registry_index(&registry)?.to_string()),
config
.get_string(&format!("registries.{}.token", registry))?
.map(|p| p.val),
)
let index = Some(config.get_registry_index(&registry)?.to_string());
let token_key = format!("registries.{}.token", registry);
let token = config.get_string(&token_key)?.map(|p| p.val);
let mut proc_key = format!("registries.{}.credential-process", registry);
let mut process = config.get::<Option<config::PathAndArgs>>(&proc_key)?;
if process.is_none() {
proc_key = String::from("registry.credential-process");
process = config.get::<Option<config::PathAndArgs>>(&proc_key)?;
}
(index, token, process, token_key, proc_key)
}
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 =
config.get::<Option<config::PathAndArgs>>("registry.credential-process")?;
(
None,
token,
process,
String::from("registry.token"),
String::from("registry.credential-process"),
)
}
};
Ok(RegistryConfig { index, token })
let process = if config.cli_unstable().credential_process {
if token.is_some() && process.is_some() {
config.shell().warn(format!(
"both `{TOKEN_KEY}` and `{PROC_KEY}` \
were specified in the config, only `{TOKEN_KEY}` will be used\n\
Specify only one value to silence this warning.",
TOKEN_KEY = token_key,
PROC_KEY = proc_key,
))?;
None
} else {
process
}
} else {
None
};
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 +430,14 @@ fn registry(
registry: Option<String>,
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 +466,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 <https://github.com/rust-lang/cargo/issues/xxx>.\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 +711,7 @@ pub fn registry_login(
token: Option<String>,
reg: Option<String>,
) -> 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 +733,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) = &reg_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 +758,32 @@ pub fn registry_login(
Ok(())
}
pub fn registry_logout(config: &Config, reg: Option<String>) -> 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<String>,
pub token: Option<String>,
@ -738,7 +804,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 +871,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 +931,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")?;

View File

@ -0,0 +1,229 @@
//! 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<String>)>,
registry_name: Option<&str>,
api_url: &str,
) -> CargoResult<String> {
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<String>)>,
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<String>)>,
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<String>),
name: &str,
api_url: &str,
action: Action,
) -> CargoResult<Option<String>> {
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') {
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<String>),
) -> CargoResult<(PathBuf, Vec<String>)> {
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))
}

View File

@ -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<PathBuf> {
::home::cargo_home_with_cwd(cwd).ok()
}
pub fn save_credentials(cfg: &Config, token: String, registry: Option<String>) -> CargoResult<()> {
pub fn save_credentials(
cfg: &Config,
token: Option<String>,
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<String>) -
.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<String>) -
.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))?;

View File

@ -961,3 +961,168 @@ 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: [#XXXX](https://github.com/rust-lang/cargo/issues/XXXX)
* 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:gnome-secret`: Uses
[libsecret](https://wiki.gnome.org/Projects/Libsecret) to store the token on
Linux systems running GNOME.
* `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.
#### `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

View File

@ -0,0 +1,450 @@
//! 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_stderr(
"\
[WARNING] both `registries.alternative.token` and `registries.alternative.credential-process` \
were specified in the config, only `registries.alternative.token` will be used
Specify only one value to silence this warning.
[UPDATING] [..]
[PACKAGING] foo v0.1.0 [..]
[UPLOADING] foo v0.1.0 [..]
",
)
.run();
// Try with global credential-process, and registry-specific `token`.
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(
"\
[WARNING] both `registries.alternative.token` and `registry.credential-process` \
were specified in the config, only `registries.alternative.token` will be used
Specify only one value to silence this warning.
[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__", &registry::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();
}

82
tests/testsuite/logout.rs Normal file
View File

@ -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");
}

View File

@ -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;