Auto merge of #8934 - ehuss:token-process, r=alexcrichton

Implement external credential process. (RFC 2730)

This adds a config setting for an external process to run to fetch the token for a registry. See `unstable.md` for more details.

As part of this, it adds a new `logout` command. This is currently gated on nightly with the appropriate `-Z` flag.

I have included four sample wrappers that integrate with the macOS Keychain, Windows Credential Manager, GNOME libsecret, and 1password. I'm not sure if we'll ultimately ship these, but I would like to. Primarily this provided a proof-of-concept to see if the design works.

**Patch Walkthrough**

This is a brief overview of the changes:

- Adds the `logout` command. With `cargo logout -Z unstable-options`, this allows removing the `token` from `.cargo/credentials`.  With `cargo logout -Z credential-process`, this launches the process with the `erase` argument to remove the token from storage.
- Credential-process handling is in the `ops/registry/auth.rs` module. I think it is pretty straightforward, it just launches the process with the appropriate store/get/erase argument.
- `ops::registry::registry()` now returns the `RegistryConfig` to make it easier to pass the config information around.
- `crates/credential/cargo-credential` is a helper crate for writing credential processes.
- A special shorthand of the `cargo:` prefix for a credential process will launch the named process from the `libexec` directory in the sysroot (or, more specifically, the `libexec` directory next to the `cargo` process). For example `credential-process = "cargo:macos-keychain"`. My intent is to bundle these in the pre-built rust-lang distributions, and this should "just work" when used with rustup. I'm not sure how that will work with other Rust distributions, but I'm guessing they can figure it out. This should make it much easier for users to get started, but does add some integration complexity.

**Questions**

- I'm on the fence about the name `credential-process` vs `credentials-process`, which sounds more natural? (Or something else?)
- I'm uneasy about the behavior when both `token` and `credential-process` is specified (see `warn_both_token_and_process` test). Currently it issues a warning and uses `token`. Does that make sense? What about the case where you have `registries.foo.token` for a specific registry, but then have a general `registry.credential-process` for the default (it currently warns and uses the token, maybe it should not warn?)?
- I am still pretty uneasy with writing FFI wrappers, so maybe those could get a little extra scrutiny? They seem to work, but I have not extensively tested them (I tried login, publish, and logout). I have not previously used these APIs, so I am not familiar with them.
- Testing the wrappers I think will be quite difficult, because some require TTY interaction (and 1password requires an online account). Or, for example in the macOS case, it has GUI dialog box where I can use my fingerprint scanner. Right now, I just build them in CI to make sure they compile.
- 1password is a little weird in that it passes the token on the command-line, which is not very secure on some systems (other processes can see these sometimes). The only alternative I can think of is to not support `cargo login` and require the user to manually enter the token in the 1password GUI. I don't think the concern is too large (1password themselves seem to think it is acceptable). Should this be OK?
- I'm a little uneasy with the design of `cargo login`, where it passes the token in stdin. If the wrapper requires stdin for user interaction (such as entering a password to unlock), this is quite awkward. There is a hack in the 1password example that demonstrates using `/dev/tty` and `CONIN$`, which *seems* to work, but I'm worried is fragile. I'm not very comfortable passing the token in environment variables, because those can be visible to other processes (like CLI args), but in some situations that shouldn't be too risky. Another option is to use a separate file descriptor/handle to pass the token in. Implementing that in Rust in a cross-platform way is not terribly easy, so I wanted to open this up for discussion first.
This commit is contained in:
bors 2020-12-14 16:22:53 +00:00
commit 8917837fb6
27 changed files with 2128 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,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.

View file

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

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

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

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,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"] }

View file

@ -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<u16> {
let mut wide: Vec<u16> = 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<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,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]

View file

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

View file

@ -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<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,67 @@ 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> {
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(&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 process = if config.cli_unstable().credential_process {
let mut proc_key = format!("registries.{}.credential-process", registry);
let mut process = config.get::<Option<config::PathAndArgs>>(&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::<Option<config::PathAndArgs>>(&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::<Option<config::PathAndArgs>>("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<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 +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 <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 +714,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 +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) = &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 +761,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 +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")?;

View file

@ -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<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') {
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<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,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

View file

@ -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__", &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();
}
#[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();
}

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;