diff options
author | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-30 03:59:35 +0000 |
---|---|---|
committer | Daniel Baumann <daniel.baumann@progress-linux.org> | 2024-05-30 03:59:35 +0000 |
commit | d1b2d29528b7794b41e66fc2136e395a02f8529b (patch) | |
tree | a4a17504b260206dec3cf55b2dca82929a348ac2 /src/tools/cargo/credential | |
parent | Releasing progress-linux version 1.72.1+dfsg1-1~progress7.99u1. (diff) | |
download | rustc-d1b2d29528b7794b41e66fc2136e395a02f8529b.tar.xz rustc-d1b2d29528b7794b41e66fc2136e395a02f8529b.zip |
Merging upstream version 1.73.0+dfsg1.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
Diffstat (limited to 'src/tools/cargo/credential')
23 files changed, 1412 insertions, 555 deletions
diff --git a/src/tools/cargo/credential/cargo-credential-1password/Cargo.toml b/src/tools/cargo/credential/cargo-credential-1password/Cargo.toml index 8db40e577..a607e6da1 100644 --- a/src/tools/cargo/credential/cargo-credential-1password/Cargo.toml +++ b/src/tools/cargo/credential/cargo-credential-1password/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "cargo-credential-1password" -version = "0.2.0" -edition = "2021" -license = "MIT OR Apache-2.0" +version = "0.3.0" +edition.workspace = true +license.workspace = true repository = "https://github.com/rust-lang/cargo" description = "A Cargo credential process that stores tokens in a 1password vault." diff --git a/src/tools/cargo/credential/cargo-credential-1password/src/main.rs b/src/tools/cargo/credential/cargo-credential-1password/src/main.rs index 4f512b717..a2607fd2f 100644 --- a/src/tools/cargo/credential/cargo-credential-1password/src/main.rs +++ b/src/tools/cargo/credential/cargo-credential-1password/src/main.rs @@ -1,6 +1,8 @@ //! Cargo registry 1password credential process. -use cargo_credential::{Credential, Error}; +use cargo_credential::{ + Action, CacheControl, Credential, CredentialResponse, Error, RegistryInfo, Secret, +}; use serde::Deserialize; use std::io::Read; use std::process::{Command, Stdio}; @@ -38,13 +40,13 @@ struct Url { } impl OnePasswordKeychain { - fn new() -> Result<OnePasswordKeychain, Error> { - let mut args = std::env::args().skip(1); + fn new(args: &[&str]) -> Result<OnePasswordKeychain, Error> { + let mut args = args.iter(); let mut action = false; let mut account = None; let mut vault = None; while let Some(arg) = args.next() { - match arg.as_str() { + match *arg { "--account" => { account = Some(args.next().ok_or("--account needs an arg")?); } @@ -63,7 +65,10 @@ impl OnePasswordKeychain { } } } - Ok(OnePasswordKeychain { account, vault }) + Ok(OnePasswordKeychain { + account: account.map(|s| s.to_string()), + vault: vault.map(|s| s.to_string()), + }) } fn signin(&self) -> Result<Option<String>, Error> { @@ -73,9 +78,8 @@ impl OnePasswordKeychain { return Ok(None); } let mut cmd = Command::new("op"); - cmd.args(&["signin", "--raw"]); + cmd.args(["signin", "--raw"]); cmd.stdout(Stdio::piped()); - self.with_tty(&mut cmd)?; let mut child = cmd .spawn() .map_err(|e| format!("failed to spawn `op`: {}", e))?; @@ -121,19 +125,6 @@ impl OnePasswordKeychain { cmd } - fn with_tty(&self, cmd: &mut Command) -> Result<(), Error> { - #[cfg(unix)] - const IN_DEVICE: &str = "/dev/tty"; - #[cfg(windows)] - const IN_DEVICE: &str = "CONIN$"; - let stdin = std::fs::OpenOptions::new() - .read(true) - .write(true) - .open(IN_DEVICE)?; - cmd.stdin(stdin); - Ok(()) - } - fn run_cmd(&self, mut cmd: Command) -> Result<String, Error> { cmd.stdout(Stdio::piped()); let mut child = cmd @@ -196,12 +187,12 @@ impl OnePasswordKeychain { &self, session: &Option<String>, id: &str, - token: &str, + token: Secret<&str>, _name: Option<&str>, ) -> Result<(), Error> { let cmd = self.make_cmd( session, - &["item", "edit", id, &format!("password={}", token)], + &["item", "edit", id, &format!("password={}", token.expose())], ); self.run_cmd(cmd)?; Ok(()) @@ -211,21 +202,21 @@ impl OnePasswordKeychain { &self, session: &Option<String>, index_url: &str, - token: &str, + token: Secret<&str>, name: Option<&str>, ) -> Result<(), Error> { let title = match name { Some(name) => format!("Cargo registry token for {}", name), None => "Cargo registry token".to_string(), }; - let mut cmd = self.make_cmd( + let cmd = self.make_cmd( session, &[ "item", "create", "--category", "Login", - &format!("password={}", token), + &format!("password={}", token.expose()), &format!("url={}", index_url), "--title", &title, @@ -233,15 +224,11 @@ impl OnePasswordKeychain { CARGO_TAG, ], ); - // For unknown reasons, `op item create` seems to not be happy if - // stdin is not a tty. Otherwise it returns with a 0 exit code without - // doing anything. - self.with_tty(&mut cmd)?; self.run_cmd(cmd)?; Ok(()) } - fn get_token(&self, session: &Option<String>, id: &str) -> Result<String, Error> { + fn get_token(&self, session: &Option<String>, id: &str) -> Result<Secret<String>, Error> { let cmd = self.make_cmd(session, &["item", "get", "--format=json", id]); let buffer = self.run_cmd(cmd)?; let item: Login = serde_json::from_str(&buffer) @@ -250,7 +237,8 @@ impl OnePasswordKeychain { match password { Some(password) => password .value - .ok_or_else(|| format!("missing password value for entry").into()), + .map(Secret::from) + .ok_or("missing password value for entry".into()), None => Err("could not find password field".into()), } } @@ -262,53 +250,58 @@ impl OnePasswordKeychain { } } -impl Credential for OnePasswordKeychain { - fn name(&self) -> &'static str { - env!("CARGO_PKG_NAME") - } - - fn get(&self, index_url: &str) -> Result<String, Error> { - let session = self.signin()?; - if let Some(id) = self.search(&session, index_url)? { - self.get_token(&session, &id) - } else { - return Err(format!( - "no 1password entry found for registry `{}`, try `cargo login` to add a token", - index_url - ) - .into()); - } - } - - fn store(&self, index_url: &str, token: &str, name: Option<&str>) -> Result<(), Error> { - let session = self.signin()?; - // Check if an item already exists. - if let Some(id) = self.search(&session, index_url)? { - self.modify(&session, &id, token, name) - } else { - self.create(&session, index_url, token, name) - } - } +pub struct OnePasswordCredential {} - fn erase(&self, index_url: &str) -> Result<(), Error> { - let session = self.signin()?; - // Check if an item already exists. - if let Some(id) = self.search(&session, index_url)? { - self.delete(&session, &id)?; - } else { - eprintln!("not currently logged in to `{}`", index_url); +impl Credential for OnePasswordCredential { + fn perform( + &self, + registry: &RegistryInfo, + action: &Action, + args: &[&str], + ) -> Result<CredentialResponse, Error> { + let op = OnePasswordKeychain::new(args)?; + match action { + Action::Get(_) => { + let session = op.signin()?; + if let Some(id) = op.search(&session, registry.index_url)? { + op.get_token(&session, &id) + .map(|token| CredentialResponse::Get { + token, + cache: CacheControl::Session, + operation_independent: true, + }) + } else { + Err(Error::NotFound) + } + } + Action::Login(options) => { + let session = op.signin()?; + // Check if an item already exists. + if let Some(id) = op.search(&session, registry.index_url)? { + eprintln!("note: token already exists for `{}`", registry.index_url); + let token = cargo_credential::read_token(options, registry)?; + op.modify(&session, &id, token.as_deref(), None)?; + } else { + let token = cargo_credential::read_token(options, registry)?; + op.create(&session, registry.index_url, token.as_deref(), None)?; + } + Ok(CredentialResponse::Login) + } + Action::Logout => { + let session = op.signin()?; + // Check if an item already exists. + if let Some(id) = op.search(&session, registry.index_url)? { + op.delete(&session, &id)?; + Ok(CredentialResponse::Logout) + } else { + Err(Error::NotFound) + } + } + _ => Err(Error::OperationNotSupported), } - Ok(()) } } fn main() { - let op = match OnePasswordKeychain::new() { - Ok(op) => op, - Err(e) => { - eprintln!("error: {}", e); - std::process::exit(1); - } - }; - cargo_credential::main(op); + cargo_credential::main(OnePasswordCredential {}); } diff --git a/src/tools/cargo/credential/cargo-credential-gnome-secret/build.rs b/src/tools/cargo/credential/cargo-credential-gnome-secret/build.rs deleted file mode 100644 index 8bb86ee43..000000000 --- a/src/tools/cargo/credential/cargo-credential-gnome-secret/build.rs +++ /dev/null @@ -1,8 +0,0 @@ -fn main() { - if cfg!(target_os = "linux") { - // TODO: Consider ignoring errors when libsecret is not installed and - // switching the impl to UnsupportedCredential (possibly along with a - // warning?). - pkg_config::probe_library("libsecret-1").unwrap(); - } -} diff --git a/src/tools/cargo/credential/cargo-credential-gnome-secret/src/libsecret.rs b/src/tools/cargo/credential/cargo-credential-gnome-secret/src/libsecret.rs deleted file mode 100644 index c584eeecf..000000000 --- a/src/tools/cargo/credential/cargo-credential-gnome-secret/src/libsecret.rs +++ /dev/null @@ -1,190 +0,0 @@ -//! Implementation of the libsecret credential helper. - -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; -} - -pub struct GnomeSecret; - -fn label(index_url: &str) -> CString { - CString::new(format!("cargo-registry:{}", index_url)).unwrap() -} - -fn schema() -> SecretSchema { - let mut attributes = [SecretSchemaAttribute { - name: null(), - attr_type: SecretSchemaAttributeType::String, - }; 32]; - attributes[0] = 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, index_url: &str) -> Result<String, Error> { - let mut error: *mut GError = null_mut(); - let attr_url = CString::new("url").unwrap(); - let index_url_c = CString::new(index_url).unwrap(); - let schema = schema(); - unsafe { - let token_c = secret_password_lookup_sync( - &schema, - null_mut(), - &mut error, - attr_url.as_ptr(), - index_url_c.as_ptr(), - null() as *const gchar, - ); - if !error.is_null() { - return Err(format!( - "failed to get token: {}", - CStr::from_ptr((*error).message).to_str()? - ) - .into()); - } - if token_c.is_null() { - return Err(format!("cannot find token for {}", index_url).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, index_url: &str, token: &str, name: Option<&str>) -> Result<(), Error> { - let label = label(name.unwrap_or(index_url)); - let token = CString::new(token).unwrap(); - let mut error: *mut GError = null_mut(); - let attr_url = CString::new("url").unwrap(); - let index_url_c = CString::new(index_url).unwrap(); - let schema = schema(); - unsafe { - secret_password_store_sync( - &schema, - b"default\0".as_ptr() as *const gchar, - label.as_ptr(), - token.as_ptr(), - null_mut(), - &mut error, - attr_url.as_ptr(), - index_url_c.as_ptr(), - null() as *const gchar, - ); - if !error.is_null() { - return Err(format!( - "failed to store token: {}", - CStr::from_ptr((*error).message).to_str()? - ) - .into()); - } - } - Ok(()) - } - - fn erase(&self, index_url: &str) -> Result<(), Error> { - let schema = schema(); - let mut error: *mut GError = null_mut(); - let attr_url = CString::new("url").unwrap(); - let index_url_c = CString::new(index_url).unwrap(); - unsafe { - secret_password_clear_sync( - &schema, - null_mut(), - &mut error, - attr_url.as_ptr(), - index_url_c.as_ptr(), - null() as *const gchar, - ); - if !error.is_null() { - return Err(format!( - "failed to erase token: {}", - CStr::from_ptr((*error).message).to_str()? - ) - .into()); - } - } - Ok(()) - } -} diff --git a/src/tools/cargo/credential/cargo-credential-gnome-secret/src/main.rs b/src/tools/cargo/credential/cargo-credential-gnome-secret/src/main.rs deleted file mode 100644 index 1d2ecc61f..000000000 --- a/src/tools/cargo/credential/cargo-credential-gnome-secret/src/main.rs +++ /dev/null @@ -1,12 +0,0 @@ -//! Cargo registry gnome libsecret credential process. - -#[cfg(target_os = "linux")] -mod libsecret; -#[cfg(not(target_os = "linux"))] -use cargo_credential::UnsupportedCredential as GnomeSecret; -#[cfg(target_os = "linux")] -use libsecret::GnomeSecret; - -fn main() { - cargo_credential::main(GnomeSecret); -} diff --git a/src/tools/cargo/credential/cargo-credential-gnome-secret/Cargo.toml b/src/tools/cargo/credential/cargo-credential-libsecret/Cargo.toml index 63b3e95cc..1bd4bb7d0 100644 --- a/src/tools/cargo/credential/cargo-credential-gnome-secret/Cargo.toml +++ b/src/tools/cargo/credential/cargo-credential-libsecret/Cargo.toml @@ -1,13 +1,12 @@ [package] -name = "cargo-credential-gnome-secret" -version = "0.2.0" -edition = "2021" -license = "MIT OR Apache-2.0" +name = "cargo-credential-libsecret" +version = "0.3.1" +edition.workspace = true +license.workspace = true repository = "https://github.com/rust-lang/cargo" description = "A Cargo credential process that stores tokens with GNOME libsecret." [dependencies] +anyhow.workspace = true cargo-credential.workspace = true - -[build-dependencies] -pkg-config.workspace = true +libloading.workspace = true diff --git a/src/tools/cargo/credential/cargo-credential-gnome-secret/README.md b/src/tools/cargo/credential/cargo-credential-libsecret/README.md index 7a4b02838..f169323e0 100644 --- a/src/tools/cargo/credential/cargo-credential-gnome-secret/README.md +++ b/src/tools/cargo/credential/cargo-credential-libsecret/README.md @@ -1,4 +1,4 @@ -# cargo-credential-gnome-secret +# cargo-credential-libsecret This is the implementation for the Cargo credential helper for [GNOME libsecret]. See the [credential-process] documentation for how to use this. diff --git a/src/tools/cargo/credential/cargo-credential-libsecret/src/lib.rs b/src/tools/cargo/credential/cargo-credential-libsecret/src/lib.rs new file mode 100644 index 000000000..f83b424ee --- /dev/null +++ b/src/tools/cargo/credential/cargo-credential-libsecret/src/lib.rs @@ -0,0 +1,235 @@ +#[cfg(target_os = "linux")] +mod linux { + //! Implementation of the libsecret credential helper. + + use anyhow::Context; + use cargo_credential::{ + read_token, Action, CacheControl, Credential, CredentialResponse, Error, RegistryInfo, + Secret, + }; + use libloading::{Library, Symbol}; + 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, + } + + type SecretPasswordStoreSync = extern "C" fn( + schema: *const SecretSchema, + collection: *const gchar, + label: *const gchar, + password: *const gchar, + cancellable: *mut GCancellable, + error: *mut *mut GError, + ... + ) -> gboolean; + type SecretPasswordClearSync = extern "C" fn( + schema: *const SecretSchema, + cancellable: *mut GCancellable, + error: *mut *mut GError, + ... + ) -> gboolean; + type SecretPasswordLookupSync = extern "C" fn( + schema: *const SecretSchema, + cancellable: *mut GCancellable, + error: *mut *mut GError, + ... + ) -> *mut gchar; + + pub struct LibSecretCredential; + + fn label(index_url: &str) -> CString { + CString::new(format!("cargo-registry:{}", index_url)).unwrap() + } + + fn schema() -> SecretSchema { + let mut attributes = [SecretSchemaAttribute { + name: null(), + attr_type: SecretSchemaAttributeType::String, + }; 32]; + attributes[0] = 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 LibSecretCredential { + fn perform( + &self, + registry: &RegistryInfo, + action: &Action, + _args: &[&str], + ) -> Result<CredentialResponse, Error> { + // Dynamically load libsecret to avoid users needing to install + // additional -dev packages when building this provider. + let lib; + let secret_password_lookup_sync: Symbol<SecretPasswordLookupSync>; + let secret_password_store_sync: Symbol<SecretPasswordStoreSync>; + let secret_password_clear_sync: Symbol<SecretPasswordClearSync>; + unsafe { + lib = Library::new("libsecret-1.so").context( + "failed to load libsecret: try installing the `libsecret` \ + or `libsecret-1-0` package with the system package manager", + )?; + secret_password_lookup_sync = lib + .get(b"secret_password_lookup_sync\0") + .map_err(Box::new)?; + secret_password_store_sync = + lib.get(b"secret_password_store_sync\0").map_err(Box::new)?; + secret_password_clear_sync = + lib.get(b"secret_password_clear_sync\0").map_err(Box::new)?; + } + + let index_url_c = CString::new(registry.index_url).unwrap(); + match action { + cargo_credential::Action::Get(_) => { + let mut error: *mut GError = null_mut(); + let attr_url = CString::new("url").unwrap(); + let schema = schema(); + unsafe { + let token_c = secret_password_lookup_sync( + &schema, + null_mut(), + &mut error, + attr_url.as_ptr(), + index_url_c.as_ptr(), + null() as *const gchar, + ); + if !error.is_null() { + return Err(format!( + "failed to get token: {}", + CStr::from_ptr((*error).message) + .to_str() + .unwrap_or_default() + ) + .into()); + } + if token_c.is_null() { + return Err(Error::NotFound); + } + let token = Secret::from( + CStr::from_ptr(token_c) + .to_str() + .map_err(|e| format!("expected utf8 token: {}", e))? + .to_string(), + ); + Ok(CredentialResponse::Get { + token, + cache: CacheControl::Session, + operation_independent: true, + }) + } + } + cargo_credential::Action::Login(options) => { + let label = label(registry.name.unwrap_or(registry.index_url)); + let token = CString::new(read_token(options, registry)?.expose()).unwrap(); + let mut error: *mut GError = null_mut(); + let attr_url = CString::new("url").unwrap(); + let schema = schema(); + unsafe { + secret_password_store_sync( + &schema, + b"default\0".as_ptr() as *const gchar, + label.as_ptr(), + token.as_ptr(), + null_mut(), + &mut error, + attr_url.as_ptr(), + index_url_c.as_ptr(), + null() as *const gchar, + ); + if !error.is_null() { + return Err(format!( + "failed to store token: {}", + CStr::from_ptr((*error).message) + .to_str() + .unwrap_or_default() + ) + .into()); + } + } + Ok(CredentialResponse::Login) + } + cargo_credential::Action::Logout => { + let schema = schema(); + let mut error: *mut GError = null_mut(); + let attr_url = CString::new("url").unwrap(); + unsafe { + secret_password_clear_sync( + &schema, + null_mut(), + &mut error, + attr_url.as_ptr(), + index_url_c.as_ptr(), + null() as *const gchar, + ); + if !error.is_null() { + return Err(format!( + "failed to erase token: {}", + CStr::from_ptr((*error).message) + .to_str() + .unwrap_or_default() + ) + .into()); + } + } + Ok(CredentialResponse::Logout) + } + _ => Err(Error::OperationNotSupported), + } + } + } +} + +#[cfg(not(target_os = "linux"))] +pub use cargo_credential::UnsupportedCredential as LibSecretCredential; +#[cfg(target_os = "linux")] +pub use linux::LibSecretCredential; diff --git a/src/tools/cargo/credential/cargo-credential-macos-keychain/Cargo.toml b/src/tools/cargo/credential/cargo-credential-macos-keychain/Cargo.toml index 6311b71de..342c771b5 100644 --- a/src/tools/cargo/credential/cargo-credential-macos-keychain/Cargo.toml +++ b/src/tools/cargo/credential/cargo-credential-macos-keychain/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "cargo-credential-macos-keychain" -version = "0.2.0" -edition = "2021" -license = "MIT OR Apache-2.0" +version = "0.3.0" +edition.workspace = true +license.workspace = true repository = "https://github.com/rust-lang/cargo" description = "A Cargo credential process that stores tokens in a macOS keychain." diff --git a/src/tools/cargo/credential/cargo-credential-macos-keychain/src/lib.rs b/src/tools/cargo/credential/cargo-credential-macos-keychain/src/lib.rs new file mode 100644 index 000000000..9e6d55472 --- /dev/null +++ b/src/tools/cargo/credential/cargo-credential-macos-keychain/src/lib.rs @@ -0,0 +1,81 @@ +//! Cargo registry macos keychain credential process. + +#[cfg(target_os = "macos")] +mod macos { + use cargo_credential::{ + read_token, Action, CacheControl, Credential, CredentialResponse, Error, RegistryInfo, + }; + use security_framework::os::macos::keychain::SecKeychain; + + pub struct MacKeychain; + + /// The account name is not used. + const ACCOUNT: &'static str = ""; + const NOT_FOUND: i32 = -25300; // errSecItemNotFound + + fn registry(index_url: &str) -> String { + format!("cargo-registry:{}", index_url) + } + + impl Credential for MacKeychain { + fn perform( + &self, + reg: &RegistryInfo<'_>, + action: &Action<'_>, + _args: &[&str], + ) -> Result<CredentialResponse, Error> { + let keychain = SecKeychain::default().unwrap(); + let service_name = registry(reg.index_url); + let not_found = security_framework::base::Error::from(NOT_FOUND).code(); + match action { + Action::Get(_) => match keychain.find_generic_password(&service_name, ACCOUNT) { + Err(e) if e.code() == not_found => Err(Error::NotFound), + Err(e) => Err(Box::new(e).into()), + Ok((pass, _)) => { + let token = String::from_utf8(pass.as_ref().to_vec()).map_err(Box::new)?; + Ok(CredentialResponse::Get { + token: token.into(), + cache: CacheControl::Session, + operation_independent: true, + }) + } + }, + Action::Login(options) => { + let token = read_token(options, reg)?; + match keychain.find_generic_password(&service_name, ACCOUNT) { + Err(e) => { + if e.code() == not_found { + keychain + .add_generic_password( + &service_name, + ACCOUNT, + token.expose().as_bytes(), + ) + .map_err(Box::new)?; + } + } + Ok((_, mut item)) => { + item.set_password(token.expose().as_bytes()) + .map_err(Box::new)?; + } + } + Ok(CredentialResponse::Login) + } + Action::Logout => match keychain.find_generic_password(&service_name, ACCOUNT) { + Err(e) if e.code() == not_found => Err(Error::NotFound), + Err(e) => Err(Box::new(e).into()), + Ok((_, item)) => { + item.delete(); + Ok(CredentialResponse::Logout) + } + }, + _ => Err(Error::OperationNotSupported), + } + } + } +} + +#[cfg(not(target_os = "macos"))] +pub use cargo_credential::UnsupportedCredential as MacKeychain; +#[cfg(target_os = "macos")] +pub use macos::MacKeychain; diff --git a/src/tools/cargo/credential/cargo-credential-macos-keychain/src/main.rs b/src/tools/cargo/credential/cargo-credential-macos-keychain/src/main.rs deleted file mode 100644 index 4d6ea96d0..000000000 --- a/src/tools/cargo/credential/cargo-credential-macos-keychain/src/main.rs +++ /dev/null @@ -1,58 +0,0 @@ -//! Cargo registry macos keychain credential process. - -#[cfg(target_os = "macos")] -mod macos { - use cargo_credential::{Credential, Error}; - use security_framework::os::macos::keychain::SecKeychain; - - pub(crate) struct MacKeychain; - - /// The account name is not used. - const ACCOUNT: &'static str = ""; - - fn registry(registry_name: &str) -> String { - format!("cargo-registry:{}", registry_name) - } - - impl Credential for MacKeychain { - fn name(&self) -> &'static str { - env!("CARGO_PKG_NAME") - } - - fn get(&self, index_url: &str) -> Result<String, Error> { - let keychain = SecKeychain::default().unwrap(); - let service_name = registry(index_url); - let (pass, _item) = keychain.find_generic_password(&service_name, ACCOUNT)?; - String::from_utf8(pass.as_ref().to_vec()) - .map_err(|_| "failed to convert token to UTF8".into()) - } - - fn store(&self, index_url: &str, token: &str, name: Option<&str>) -> Result<(), Error> { - let keychain = SecKeychain::default().unwrap(); - let service_name = registry(name.unwrap_or(index_url)); - if let Ok((_pass, mut item)) = keychain.find_generic_password(&service_name, ACCOUNT) { - item.set_password(token.as_bytes())?; - } else { - keychain.add_generic_password(&service_name, ACCOUNT, token.as_bytes())?; - } - Ok(()) - } - - fn erase(&self, index_url: &str) -> Result<(), Error> { - let keychain = SecKeychain::default().unwrap(); - let service_name = registry(index_url); - let (_pass, item) = keychain.find_generic_password(&service_name, ACCOUNT)?; - item.delete(); - Ok(()) - } - } -} - -#[cfg(not(target_os = "macos"))] -use cargo_credential::UnsupportedCredential as MacKeychain; -#[cfg(target_os = "macos")] -use macos::MacKeychain; - -fn main() { - cargo_credential::main(MacKeychain); -} diff --git a/src/tools/cargo/credential/cargo-credential-wincred/Cargo.toml b/src/tools/cargo/credential/cargo-credential-wincred/Cargo.toml index cd168a8a3..8c609dc4e 100644 --- a/src/tools/cargo/credential/cargo-credential-wincred/Cargo.toml +++ b/src/tools/cargo/credential/cargo-credential-wincred/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "cargo-credential-wincred" -version = "0.2.0" -edition = "2021" -license = "MIT OR Apache-2.0" +version = "0.3.0" +edition.workspace = true +license.workspace = true repository = "https://github.com/rust-lang/cargo" description = "A Cargo credential process that stores tokens with Windows Credential Manager." diff --git a/src/tools/cargo/credential/cargo-credential-wincred/src/lib.rs b/src/tools/cargo/credential/cargo-credential-wincred/src/lib.rs new file mode 100644 index 000000000..9200ca58f --- /dev/null +++ b/src/tools/cargo/credential/cargo-credential-wincred/src/lib.rs @@ -0,0 +1,125 @@ +//! Cargo registry windows credential process. + +#[cfg(windows)] +mod win { + use cargo_credential::{read_token, Action, CacheControl, CredentialResponse, RegistryInfo}; + use cargo_credential::{Credential, Error}; + use std::ffi::OsStr; + + use std::os::windows::ffi::OsStrExt; + + use windows_sys::core::PWSTR; + use windows_sys::Win32::Foundation::ERROR_NOT_FOUND; + use windows_sys::Win32::Foundation::FILETIME; + use windows_sys::Win32::Foundation::TRUE; + use windows_sys::Win32::Security::Credentials::CredReadW; + use windows_sys::Win32::Security::Credentials::CredWriteW; + use windows_sys::Win32::Security::Credentials::CREDENTIALW; + use windows_sys::Win32::Security::Credentials::CRED_PERSIST_LOCAL_MACHINE; + use windows_sys::Win32::Security::Credentials::CRED_TYPE_GENERIC; + use windows_sys::Win32::Security::Credentials::{CredDeleteW, CredFree}; + + pub struct WindowsCredential; + + /// Converts a string to a nul-terminated wide UTF-16 byte sequence. + fn wstr(s: &str) -> Vec<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(index_url: &str) -> Vec<u16> { + wstr(&format!("cargo-registry:{}", index_url)) + } + + impl Credential for WindowsCredential { + fn perform( + &self, + registry: &RegistryInfo, + action: &Action, + _args: &[&str], + ) -> Result<CredentialResponse, Error> { + match action { + Action::Get(_) => { + let target_name = target_name(registry.index_url); + let mut p_credential: *mut CREDENTIALW = std::ptr::null_mut() as *mut _; + let bytes = unsafe { + if CredReadW( + target_name.as_ptr(), + CRED_TYPE_GENERIC, + 0, + &mut p_credential as *mut _, + ) != TRUE + { + let err = std::io::Error::last_os_error(); + if err.raw_os_error() == Some(ERROR_NOT_FOUND as i32) { + return Err(Error::NotFound); + } + return Err(Box::new(err).into()); + } + std::slice::from_raw_parts( + (*p_credential).CredentialBlob, + (*p_credential).CredentialBlobSize as usize, + ) + }; + let token = String::from_utf8(bytes.to_vec()).map_err(Box::new); + unsafe { CredFree(p_credential as *mut _) }; + Ok(CredentialResponse::Get { + token: token?.into(), + cache: CacheControl::Session, + operation_independent: true, + }) + } + Action::Login(options) => { + let token = read_token(options, registry)?.expose(); + let target_name = target_name(registry.index_url); + let comment = wstr("Cargo registry token"); + let credential = CREDENTIALW { + Flags: 0, + Type: CRED_TYPE_GENERIC, + TargetName: target_name.as_ptr() as PWSTR, + Comment: comment.as_ptr() as PWSTR, + LastWritten: FILETIME { + dwLowDateTime: 0, + dwHighDateTime: 0, + }, + CredentialBlobSize: token.len() as u32, + CredentialBlob: token.as_bytes().as_ptr() as *mut u8, + Persist: CRED_PERSIST_LOCAL_MACHINE, + AttributeCount: 0, + Attributes: std::ptr::null_mut(), + TargetAlias: std::ptr::null_mut(), + UserName: std::ptr::null_mut(), + }; + let result = unsafe { CredWriteW(&credential, 0) }; + if result != TRUE { + let err = std::io::Error::last_os_error(); + return Err(Box::new(err).into()); + } + Ok(CredentialResponse::Login) + } + Action::Logout => { + let target_name = target_name(registry.index_url); + let result = unsafe { CredDeleteW(target_name.as_ptr(), CRED_TYPE_GENERIC, 0) }; + if result != TRUE { + let err = std::io::Error::last_os_error(); + if err.raw_os_error() == Some(ERROR_NOT_FOUND as i32) { + return Err(Error::NotFound); + } + return Err(Box::new(err).into()); + } + Ok(CredentialResponse::Logout) + } + _ => Err(Error::OperationNotSupported), + } + } + } +} + +#[cfg(not(windows))] +pub use cargo_credential::UnsupportedCredential as WindowsCredential; +#[cfg(windows)] +pub use win::WindowsCredential; diff --git a/src/tools/cargo/credential/cargo-credential-wincred/src/main.rs b/src/tools/cargo/credential/cargo-credential-wincred/src/main.rs deleted file mode 100644 index 4377172e8..000000000 --- a/src/tools/cargo/credential/cargo-credential-wincred/src/main.rs +++ /dev/null @@ -1,122 +0,0 @@ -//! Cargo registry windows credential process. - -#[cfg(windows)] -mod win { - use cargo_credential::{Credential, Error}; - use std::ffi::OsStr; - use std::os::windows::ffi::OsStrExt; - - use windows_sys::core::PWSTR; - use windows_sys::Win32::Foundation::ERROR_NOT_FOUND; - use windows_sys::Win32::Foundation::FILETIME; - use windows_sys::Win32::Foundation::TRUE; - use windows_sys::Win32::Security::Credentials::CredDeleteW; - use windows_sys::Win32::Security::Credentials::CredReadW; - use windows_sys::Win32::Security::Credentials::CredWriteW; - use windows_sys::Win32::Security::Credentials::CREDENTIALW; - use windows_sys::Win32::Security::Credentials::CRED_PERSIST_LOCAL_MACHINE; - use windows_sys::Win32::Security::Credentials::CRED_TYPE_GENERIC; - - pub(crate) struct WindowsCredential; - - /// Converts a string to a nul-terminated wide UTF-16 byte sequence. - fn wstr(s: &str) -> Vec<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, index_url: &str) -> Result<String, Error> { - let target_name = target_name(index_url); - let p_credential: *mut CREDENTIALW = std::ptr::null_mut() as *mut _; - unsafe { - if CredReadW( - target_name.as_ptr(), - CRED_TYPE_GENERIC, - 0, - p_credential as *mut _ as *mut _, - ) != TRUE - { - return Err(format!( - "failed to fetch token: {}", - std::io::Error::last_os_error() - ) - .into()); - } - let bytes = std::slice::from_raw_parts( - (*p_credential).CredentialBlob, - (*p_credential).CredentialBlobSize as usize, - ); - String::from_utf8(bytes.to_vec()) - .map_err(|_| "failed to convert token to UTF8".into()) - } - } - - fn store(&self, index_url: &str, token: &str, name: Option<&str>) -> Result<(), Error> { - let token = token.as_bytes(); - let target_name = target_name(index_url); - let comment = match name { - Some(name) => wstr(&format!("Cargo registry token for {}", name)), - None => wstr("Cargo registry token"), - }; - let mut credential = CREDENTIALW { - Flags: 0, - Type: CRED_TYPE_GENERIC, - TargetName: target_name.as_ptr() as PWSTR, - Comment: comment.as_ptr() as PWSTR, - LastWritten: FILETIME { - dwLowDateTime: 0, - dwHighDateTime: 0, - }, - CredentialBlobSize: token.len() as u32, - CredentialBlob: token.as_ptr() as *mut u8, - Persist: CRED_PERSIST_LOCAL_MACHINE, - AttributeCount: 0, - Attributes: std::ptr::null_mut(), - TargetAlias: std::ptr::null_mut(), - UserName: std::ptr::null_mut(), - }; - let result = unsafe { CredWriteW(&mut credential, 0) }; - if result != TRUE { - let err = std::io::Error::last_os_error(); - return Err(format!("failed to store token: {}", err).into()); - } - Ok(()) - } - - fn erase(&self, index_url: &str) -> Result<(), Error> { - let target_name = target_name(index_url); - let result = unsafe { CredDeleteW(target_name.as_ptr(), CRED_TYPE_GENERIC, 0) }; - if result != TRUE { - let err = std::io::Error::last_os_error(); - if err.raw_os_error() == Some(ERROR_NOT_FOUND as i32) { - eprintln!("not currently logged in to `{}`", index_url); - return Ok(()); - } - return Err(format!("failed to remove token: {}", err).into()); - } - Ok(()) - } - } -} - -#[cfg(not(windows))] -use cargo_credential::UnsupportedCredential as WindowsCredential; -#[cfg(windows)] -use win::WindowsCredential; - -fn main() { - cargo_credential::main(WindowsCredential); -} diff --git a/src/tools/cargo/credential/cargo-credential/Cargo.toml b/src/tools/cargo/credential/cargo-credential/Cargo.toml index 2addaf5af..8cd1348be 100644 --- a/src/tools/cargo/credential/cargo-credential/Cargo.toml +++ b/src/tools/cargo/credential/cargo-credential/Cargo.toml @@ -1,9 +1,21 @@ [package] name = "cargo-credential" -version = "0.2.0" -edition = "2021" -license = "MIT OR Apache-2.0" +version = "0.3.0" +edition.workspace = true +license.workspace = true repository = "https://github.com/rust-lang/cargo" description = "A library to assist writing Cargo credential helpers." [dependencies] +anyhow.workspace = true +libc.workspace = true +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +thiserror.workspace = true +time.workspace = true + +[target.'cfg(windows)'.dependencies] +windows-sys = { workspace = true, features = ["Win32_System_Console", "Win32_Foundation"] } + +[dev-dependencies] +snapbox = { workspace = true, features = ["examples"] } diff --git a/src/tools/cargo/credential/cargo-credential/README.md b/src/tools/cargo/credential/cargo-credential/README.md index 53dc8e6b7..049b3ba55 100644 --- a/src/tools/cargo/credential/cargo-credential/README.md +++ b/src/tools/cargo/credential/cargo-credential/README.md @@ -18,7 +18,7 @@ Create a Cargo project with this as a dependency: # Add this to your Cargo.toml: [dependencies] -cargo-credential = "0.1" +cargo-credential = "0.3" ``` And then include a `main.rs` binary which implements the `Credential` trait, and calls diff --git a/src/tools/cargo/credential/cargo-credential/examples/file-provider.rs b/src/tools/cargo/credential/cargo-credential/examples/file-provider.rs new file mode 100644 index 000000000..d11958536 --- /dev/null +++ b/src/tools/cargo/credential/cargo-credential/examples/file-provider.rs @@ -0,0 +1,90 @@ +//! Example credential provider that stores credentials in a JSON file. +//! This is not secure + +use cargo_credential::{ + Action, CacheControl, Credential, CredentialResponse, RegistryInfo, Secret, +}; +use std::{collections::HashMap, fs::File, io::ErrorKind}; +type Error = Box<dyn std::error::Error + Send + Sync + 'static>; + +struct FileCredential; + +impl Credential for FileCredential { + fn perform( + &self, + registry: &RegistryInfo, + action: &Action, + _args: &[&str], + ) -> Result<CredentialResponse, cargo_credential::Error> { + if registry.index_url != "https://github.com/rust-lang/crates.io-index" { + // Restrict this provider to only work for crates.io. Cargo will skip it and attempt + // another provider for any other registry. + // + // If a provider supports any registry, then this check should be omitted. + return Err(cargo_credential::Error::UrlNotSupported); + } + + // `Error::Other` takes a boxed `std::error::Error` type that causes Cargo to show the error. + let mut creds = FileCredential::read().map_err(cargo_credential::Error::Other)?; + + match action { + Action::Get(_) => { + // Cargo requested a token, look it up. + if let Some(token) = creds.get(registry.index_url) { + Ok(CredentialResponse::Get { + token: token.clone(), + cache: CacheControl::Session, + operation_independent: true, + }) + } else { + // Credential providers should respond with `NotFound` when a credential can not be + // found, allowing Cargo to attempt another provider. + Err(cargo_credential::Error::NotFound) + } + } + Action::Login(login_options) => { + // The token for `cargo login` can come from the `login_options` parameter or i + // interactively reading from stdin. + // + // `cargo_credential::read_token` automatically handles this. + let token = cargo_credential::read_token(login_options, registry)?; + creds.insert(registry.index_url.to_string(), token); + + FileCredential::write(&creds).map_err(cargo_credential::Error::Other)?; + + // Credentials were successfully stored. + Ok(CredentialResponse::Login) + } + Action::Logout => { + if creds.remove(registry.index_url).is_none() { + // If the user attempts to log out from a registry that has no credentials + // stored, then NotFound is the appropriate error. + Err(cargo_credential::Error::NotFound) + } else { + // Credentials were successfully erased. + Ok(CredentialResponse::Logout) + } + } + // If a credential provider doesn't support a given operation, it should respond with `OperationNotSupported`. + _ => Err(cargo_credential::Error::OperationNotSupported), + } + } +} + +impl FileCredential { + fn read() -> Result<HashMap<String, Secret<String>>, Error> { + match File::open("cargo-credentials.json") { + Ok(f) => Ok(serde_json::from_reader(f)?), + Err(e) if e.kind() == ErrorKind::NotFound => Ok(HashMap::new()), + Err(e) => Err(e)?, + } + } + fn write(value: &HashMap<String, Secret<String>>) -> Result<(), Error> { + let file = File::create("cargo-credentials.json")?; + Ok(serde_json::to_writer_pretty(file, value)?) + } +} + +fn main() { + cargo_credential::main(FileCredential); +} diff --git a/src/tools/cargo/credential/cargo-credential/examples/stdout-redirected.rs b/src/tools/cargo/credential/cargo-credential/examples/stdout-redirected.rs new file mode 100644 index 000000000..0b9bcc2f7 --- /dev/null +++ b/src/tools/cargo/credential/cargo-credential/examples/stdout-redirected.rs @@ -0,0 +1,25 @@ +//! Provider used for testing redirection of stdout. + +use cargo_credential::{Action, Credential, CredentialResponse, Error, RegistryInfo}; + +struct MyCredential; + +impl Credential for MyCredential { + fn perform( + &self, + _registry: &RegistryInfo, + _action: &Action, + _args: &[&str], + ) -> Result<CredentialResponse, Error> { + // Informational messages should be sent on stderr. + eprintln!("message on stderr should be sent the the parent process"); + + // Reading from stdin and writing to stdout will go to the attached console (tty). + println!("message from test credential provider"); + Err(Error::OperationNotSupported) + } +} + +fn main() { + cargo_credential::main(MyCredential); +} diff --git a/src/tools/cargo/credential/cargo-credential/src/error.rs b/src/tools/cargo/credential/cargo-credential/src/error.rs new file mode 100644 index 000000000..2ebaf9977 --- /dev/null +++ b/src/tools/cargo/credential/cargo-credential/src/error.rs @@ -0,0 +1,206 @@ +use serde::{Deserialize, Serialize}; +use std::error::Error as StdError; +use thiserror::Error as ThisError; + +/// Credential provider error type. +/// +/// `UrlNotSupported` and `NotFound` errors both cause Cargo +/// to attempt another provider, if one is available. The other +/// variants are fatal. +/// +/// Note: Do not add a tuple variant, as it cannot be serialized. +#[derive(Serialize, Deserialize, ThisError, Debug)] +#[serde(rename_all = "kebab-case", tag = "kind")] +#[non_exhaustive] +pub enum Error { + /// Registry URL is not supported. This should be used if + /// the provider only works for some registries. Cargo will + /// try another provider, if available + #[error("registry not supported")] + UrlNotSupported, + + /// Credentials could not be found. Cargo will try another + /// provider, if available + #[error("credential not found")] + NotFound, + + /// The provider doesn't support this operation, such as + /// a provider that can't support 'login' / 'logout' + #[error("requested operation not supported")] + OperationNotSupported, + + /// The provider failed to perform the operation. Other + /// providers will not be attempted + #[error(transparent)] + #[serde(with = "error_serialize")] + Other(Box<dyn StdError + Sync + Send>), + + /// A new variant was added to this enum since Cargo was built + #[error("unknown error kind; try updating Cargo?")] + #[serde(other)] + Unknown, +} + +impl From<String> for Error { + fn from(err: String) -> Self { + Box::new(StringTypedError { + message: err.to_string(), + source: None, + }) + .into() + } +} + +impl From<&str> for Error { + fn from(err: &str) -> Self { + err.to_string().into() + } +} + +impl From<anyhow::Error> for Error { + fn from(value: anyhow::Error) -> Self { + let mut prev = None; + for e in value.chain().rev() { + prev = Some(Box::new(StringTypedError { + message: e.to_string(), + source: prev, + })); + } + Error::Other(prev.unwrap()) + } +} + +impl<T: StdError + Send + Sync + 'static> From<Box<T>> for Error { + fn from(value: Box<T>) -> Self { + Error::Other(value) + } +} + +/// String-based error type with an optional source +#[derive(Debug)] +struct StringTypedError { + message: String, + source: Option<Box<StringTypedError>>, +} + +impl StdError for StringTypedError { + fn source(&self) -> Option<&(dyn StdError + 'static)> { + self.source.as_ref().map(|err| err as &dyn StdError) + } +} + +impl std::fmt::Display for StringTypedError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.message.fmt(f) + } +} + +/// Serializer / deserializer for any boxed error. +/// The string representation of the error, and its `source` chain can roundtrip across +/// the serialization. The actual types are lost (downcast will not work). +mod error_serialize { + use std::error::Error as StdError; + use std::ops::Deref; + + use serde::{ser::SerializeStruct, Deserialize, Deserializer, Serializer}; + + use crate::error::StringTypedError; + + pub fn serialize<S>( + e: &Box<dyn StdError + Send + Sync>, + serializer: S, + ) -> Result<S::Ok, S::Error> + where + S: Serializer, + { + let mut state = serializer.serialize_struct("StringTypedError", 2)?; + state.serialize_field("message", &format!("{}", e))?; + + // Serialize the source error chain recursively + let mut current_source: &dyn StdError = e.deref(); + let mut sources = Vec::new(); + while let Some(err) = current_source.source() { + sources.push(err.to_string()); + current_source = err; + } + state.serialize_field("caused-by", &sources)?; + state.end() + } + + pub fn deserialize<'de, D>(deserializer: D) -> Result<Box<dyn StdError + Sync + Send>, D::Error> + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(rename_all = "kebab-case")] + struct ErrorData { + message: String, + caused_by: Option<Vec<String>>, + } + let data = ErrorData::deserialize(deserializer)?; + let mut prev = None; + if let Some(source) = data.caused_by { + for e in source.into_iter().rev() { + prev = Some(Box::new(StringTypedError { + message: e, + source: prev, + })); + } + } + let e = Box::new(StringTypedError { + message: data.message, + source: prev, + }); + Ok(e) + } +} + +#[cfg(test)] +mod tests { + use super::Error; + + #[test] + pub fn unknown_kind() { + let json = r#"{ + "kind": "unexpected-kind", + "unexpected-content": "test" + }"#; + let e: Error = serde_json::from_str(&json).unwrap(); + assert!(matches!(e, Error::Unknown)); + } + + #[test] + pub fn roundtrip() { + // Construct an error with context + let e = anyhow::anyhow!("E1").context("E2").context("E3"); + // Convert to a string with contexts. + let s1 = format!("{:?}", e); + // Convert the error into an `Error` + let e: Error = e.into(); + // Convert that error into JSON + let json = serde_json::to_string_pretty(&e).unwrap(); + // Convert that error back to anyhow + let e: anyhow::Error = e.into(); + let s2 = format!("{:?}", e); + assert_eq!(s1, s2); + + // Convert the error back from JSON + let e: Error = serde_json::from_str(&json).unwrap(); + // Convert to back to anyhow + let e: anyhow::Error = e.into(); + let s3 = format!("{:?}", e); + assert_eq!(s2, s3); + + assert_eq!( + r#"{ + "kind": "other", + "message": "E3", + "caused-by": [ + "E2", + "E1" + ] +}"#, + json + ); + } +} diff --git a/src/tools/cargo/credential/cargo-credential/src/lib.rs b/src/tools/cargo/credential/cargo-credential/src/lib.rs index c75172242..0fb495ed3 100644 --- a/src/tools/cargo/credential/cargo-credential/src/lib.rs +++ b/src/tools/cargo/credential/cargo-credential/src/lib.rs @@ -1,4 +1,4 @@ -//! Helper library for writing Cargo credential processes. +//! Helper library for writing Cargo credential providers. //! //! 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: @@ -9,98 +9,270 @@ //! } //! ``` //! -//! 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>; +//! While in the `perform` function, stdin and stdout will be re-attached to the +//! active console. This allows credential providers to be interactive if necessary. +//! +//! ## Error handling +//! ### [`Error::UrlNotSupported`] +//! A credential provider may only support some registry URLs. If this is the case +//! and an unsupported index URL is passed to the provider, it should respond with +//! [`Error::UrlNotSupported`]. Other credential providers may be attempted by Cargo. +//! +//! ### [`Error::NotFound`] +//! When attempting an [`Action::Get`] or [`Action::Logout`], if a credential can not +//! be found, the provider should respond with [`Error::NotFound`]. Other credential +//! providers may be attempted by Cargo. +//! +//! ### [`Error::OperationNotSupported`] +//! A credential provider might not support all operations. For example if the provider +//! only supports [`Action::Get`], [`Error::OperationNotSupported`] should be returned +//! for all other requests. +//! +//! ### [`Error::Other`] +//! All other errors go here. The error will be shown to the user in Cargo, including +//! the full error chain using [`std::error::Error::source`]. +//! +//! ## Example +//! ```rust,ignore +#![doc = include_str!("../examples/file-provider.rs")] +//! ``` -pub trait Credential { - /// Returns the name of this credential process. - fn name(&self) -> &'static str; +use serde::{Deserialize, Serialize}; +use std::{fmt::Display, io}; +use time::OffsetDateTime; - /// Retrieves a token for the given registry. - fn get(&self, index_url: &str) -> Result<String, Error>; +mod error; +mod secret; +mod stdio; - /// Stores the given token for the given registry. - fn store(&self, index_url: &str, token: &str, name: Option<&str>) -> Result<(), Error>; +pub use error::Error; +pub use secret::Secret; +use stdio::stdin_stdout_to_console; - /// Removes the token for the given registry. - /// - /// If the user is not logged in, this should print a message to stderr if - /// possible indicating that the user is not currently logged in, and - /// return `Ok`. - fn erase(&self, index_url: &str) -> Result<(), Error>; +/// Message sent by the credential helper on startup +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct CredentialHello { + // Protocol versions supported by the credential process. + pub v: Vec<u32>, } +/// Credential provider that doesn't support any registries. pub struct UnsupportedCredential; - impl Credential for UnsupportedCredential { - fn name(&self) -> &'static str { - "unsupported" + fn perform( + &self, + _registry: &RegistryInfo, + _action: &Action, + _args: &[&str], + ) -> Result<CredentialResponse, Error> { + Err(Error::UrlNotSupported) } +} - fn get(&self, _index_url: &str) -> Result<String, Error> { - Err("unsupported".into()) - } +/// Message sent by Cargo to the credential helper after the hello +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "kebab-case")] +pub struct CredentialRequest<'a> { + // Cargo will respond with the highest common protocol supported by both. + pub v: u32, + #[serde(borrow)] + pub registry: RegistryInfo<'a>, + #[serde(borrow, flatten)] + pub action: Action<'a>, + /// Additional command-line arguments passed to the credential provider. + pub args: Vec<&'a str>, +} - fn store(&self, _index_url: &str, _token: &str, _name: Option<&str>) -> Result<(), Error> { - Err("unsupported".into()) - } +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "kebab-case")] +pub struct RegistryInfo<'a> { + /// Registry index url + pub index_url: &'a str, + /// Name of the registry in configuration. May not be available. + /// The crates.io registry will be `crates-io` (`CRATES_IO_REGISTRY`). + pub name: Option<&'a str>, + /// Headers from attempting to access a registry that resulted in a HTTP 401. + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub headers: Vec<String>, +} - fn erase(&self, _index_url: &str) -> Result<(), Error> { - Err("unsupported".into()) +#[derive(Serialize, Deserialize, Clone, Debug)] +#[non_exhaustive] +#[serde(tag = "kind", rename_all = "kebab-case")] +pub enum Action<'a> { + #[serde(borrow)] + Get(Operation<'a>), + Login(LoginOptions<'a>), + Logout, + #[serde(other)] + Unknown, +} + +impl<'a> Display for Action<'a> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Action::Get(_) => f.write_str("get"), + Action::Login(_) => f.write_str("login"), + Action::Logout => f.write_str("logout"), + Action::Unknown => f.write_str("<unknown>"), + } } } -/// Runs the credential interaction by processing the command-line and -/// environment variables. +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "kebab-case")] +pub struct LoginOptions<'a> { + /// Token passed on the command line via --token or from stdin + pub token: Option<Secret<&'a str>>, + /// Optional URL that the user can visit to log in to the registry + pub login_url: Option<&'a str>, +} + +/// A record of what kind of operation is happening that we should generate a token for. +#[derive(Serialize, Deserialize, Clone, Debug)] +#[non_exhaustive] +#[serde(tag = "operation", rename_all = "kebab-case")] +pub enum Operation<'a> { + /// The user is attempting to fetch a crate. + Read, + /// The user is attempting to publish a crate. + Publish { + /// The name of the crate + name: &'a str, + /// The version of the crate + vers: &'a str, + /// The checksum of the crate file being uploaded + cksum: &'a str, + }, + /// The user is attempting to yank a crate. + Yank { + /// The name of the crate + name: &'a str, + /// The version of the crate + vers: &'a str, + }, + /// The user is attempting to unyank a crate. + Unyank { + /// The name of the crate + name: &'a str, + /// The version of the crate + vers: &'a str, + }, + /// The user is attempting to modify the owners of a crate. + Owners { + /// The name of the crate + name: &'a str, + }, + #[serde(other)] + Unknown, +} + +/// Message sent by the credential helper +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(tag = "kind", rename_all = "kebab-case")] +#[non_exhaustive] +pub enum CredentialResponse { + Get { + token: Secret<String>, + cache: CacheControl, + operation_independent: bool, + }, + Login, + Logout, + #[serde(other)] + Unknown, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +#[serde(rename_all = "kebab-case")] +#[non_exhaustive] +pub enum CacheControl { + /// Do not cache this result. + Never, + /// Cache this result and use it for subsequent requests in the current Cargo invocation until the specified time. + Expires(#[serde(with = "time::serde::timestamp")] OffsetDateTime), + /// Cache this result and use it for all subsequent requests in the current Cargo invocation. + Session, + #[serde(other)] + Unknown, +} + +/// Credential process JSON protocol version. Incrementing +/// this version will prevent new credential providers +/// from working with older versions of Cargo. +pub const PROTOCOL_VERSION_1: u32 = 1; +pub trait Credential { + /// Retrieves a token for the given registry. + fn perform( + &self, + registry: &RegistryInfo, + action: &Action, + args: &[&str], + ) -> Result<CredentialResponse, Error>; +} + +/// Runs the credential interaction pub fn main(credential: impl Credential) { - let name = credential.name(); - if let Err(e) = doit(credential) { - eprintln!("{} error: {}", name, e); - std::process::exit(1); + let result = doit(credential).map_err(|e| Error::Other(e)); + if result.is_err() { + serde_json::to_writer(std::io::stdout(), &result) + .expect("failed to serialize credential provider error"); + println!(); } } -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<(), Box<dyn std::error::Error + Send + Sync + 'static>> { + let hello = CredentialHello { + v: vec![PROTOCOL_VERSION_1], + }; + serde_json::to_writer(std::io::stdout(), &hello)?; + println!(); -fn doit(credential: impl Credential) -> Result<(), Error> { - let which = std::env::args() - .skip(1) - .skip_while(|arg| arg.starts_with('-')) - .next() - .ok_or_else(|| "first argument must be the {action}")?; - let index_url = env("CARGO_REGISTRY_INDEX_URL")?; - let name = std::env::var("CARGO_REGISTRY_NAME_OPT").ok(); - let result = match which.as_ref() { - "get" => credential.get(&index_url).and_then(|token| { - println!("{}", token); - Ok(()) - }), - "store" => { - read_token().and_then(|token| credential.store(&index_url, &token, name.as_deref())) + loop { + let mut buffer = String::new(); + let len = std::io::stdin().read_line(&mut buffer)?; + if len == 0 { + return Ok(()); } - "erase" => credential.erase(&index_url), - _ => { - return Err(format!( - "unexpected command-line argument `{}`, expected get/store/erase", - which - ) - .into()) + let request: CredentialRequest = serde_json::from_str(&buffer)?; + if request.v != PROTOCOL_VERSION_1 { + return Err(format!("unsupported protocol version {}", request.v).into()); } - }; - result.map_err(|e| format!("failed to `{}` token: {}", which, e).into()) + + let response = stdin_stdout_to_console(|| { + credential.perform(&request.registry, &request.action, &request.args) + })?; + + serde_json::to_writer(std::io::stdout(), &response)?; + println!(); + } } -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(); +/// Read a line of text from stdin. +pub fn read_line() -> Result<String, io::Error> { + let mut buf = String::new(); + io::stdin().read_line(&mut buf)?; + Ok(buf.trim().to_string()) +} + +/// Prompt the user for a token. +pub fn read_token( + login_options: &LoginOptions, + registry: &RegistryInfo, +) -> Result<Secret<String>, Error> { + if let Some(token) = &login_options.token { + return Ok(token.to_owned()); } - Ok(buffer) + + if let Some(url) = login_options.login_url { + eprintln!("please paste the token found on {url} below"); + } else if let Some(name) = registry.name { + eprintln!("please paste the token for {name} below"); + } else { + eprintln!("please paste the token for {} below", registry.index_url); + } + + Ok(Secret::from(read_line().map_err(Box::new)?)) } diff --git a/src/tools/cargo/credential/cargo-credential/src/secret.rs b/src/tools/cargo/credential/cargo-credential/src/secret.rs new file mode 100644 index 000000000..1c2314d8e --- /dev/null +++ b/src/tools/cargo/credential/cargo-credential/src/secret.rs @@ -0,0 +1,101 @@ +use std::fmt; +use std::ops::Deref; + +use serde::{Deserialize, Serialize}; + +/// A wrapper for values that should not be printed. +/// +/// This type does not implement `Display`, and has a `Debug` impl that hides +/// the contained value. +/// +/// ``` +/// # use cargo_credential::Secret; +/// let token = Secret::from("super secret string"); +/// assert_eq!(format!("{:?}", token), "Secret { inner: \"REDACTED\" }"); +/// ``` +/// +/// Currently, we write a borrowed `Secret<T>` as `Secret<&T>`. +/// The [`as_deref`](Secret::as_deref) and [`to_owned`](Secret::to_owned) methods can +/// be used to convert back and forth between `Secret<String>` and `Secret<&str>`. +#[derive(Default, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct Secret<T> { + inner: T, +} + +impl<T> Secret<T> { + /// Unwraps the contained value. + /// + /// Use of this method marks the boundary of where the contained value is + /// hidden. + pub fn expose(self) -> T { + self.inner + } + + /// Converts a `Secret<T>` to a `Secret<&T::Target>`. + /// ``` + /// # use cargo_credential::Secret; + /// let owned: Secret<String> = Secret::from(String::from("token")); + /// let borrowed: Secret<&str> = owned.as_deref(); + /// ``` + pub fn as_deref(&self) -> Secret<&<T as Deref>::Target> + where + T: Deref, + { + Secret::from(self.inner.deref()) + } + + /// Converts a `Secret<T>` to a `Secret<&T>`. + pub fn as_ref(&self) -> Secret<&T> { + Secret::from(&self.inner) + } + + /// Converts a `Secret<T>` to a `Secret<U>` by applying `f` to the contained value. + pub fn map<U, F>(self, f: F) -> Secret<U> + where + F: FnOnce(T) -> U, + { + Secret::from(f(self.inner)) + } +} + +impl<T: ToOwned + ?Sized> Secret<&T> { + /// Converts a `Secret` containing a borrowed type to a `Secret` containing the + /// corresponding owned type. + /// ``` + /// # use cargo_credential::Secret; + /// let borrowed: Secret<&str> = Secret::from("token"); + /// let owned: Secret<String> = borrowed.to_owned(); + /// ``` + pub fn to_owned(&self) -> Secret<<T as ToOwned>::Owned> { + Secret::from(self.inner.to_owned()) + } +} + +impl<T, E> Secret<Result<T, E>> { + /// Converts a `Secret<Result<T, E>>` to a `Result<Secret<T>, E>`. + pub fn transpose(self) -> Result<Secret<T>, E> { + self.inner.map(|v| Secret::from(v)) + } +} + +impl<T: AsRef<str>> Secret<T> { + /// Checks if the contained value is empty. + pub fn is_empty(&self) -> bool { + self.inner.as_ref().is_empty() + } +} + +impl<T> From<T> for Secret<T> { + fn from(inner: T) -> Self { + Self { inner } + } +} + +impl<T> fmt::Debug for Secret<T> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Secret") + .field("inner", &"REDACTED") + .finish() + } +} diff --git a/src/tools/cargo/credential/cargo-credential/src/stdio.rs b/src/tools/cargo/credential/cargo-credential/src/stdio.rs new file mode 100644 index 000000000..25435056f --- /dev/null +++ b/src/tools/cargo/credential/cargo-credential/src/stdio.rs @@ -0,0 +1,163 @@ +use std::{fs::File, io::Error}; + +/// Reset stdin and stdout to the attached console / tty for the duration of the closure. +/// If no console is available, stdin and stdout will be redirected to null. +pub fn stdin_stdout_to_console<F, T>(f: F) -> Result<T, Error> +where + F: FnOnce() -> T, +{ + let open_write = |f| std::fs::OpenOptions::new().write(true).open(f); + + let mut stdin = File::open(imp::IN_DEVICE).or_else(|_| File::open(imp::NULL_DEVICE))?; + let mut stdout = open_write(imp::OUT_DEVICE).or_else(|_| open_write(imp::NULL_DEVICE))?; + + let _stdin_guard = imp::ReplacementGuard::new(Stdio::Stdin, &mut stdin)?; + let _stdout_guard = imp::ReplacementGuard::new(Stdio::Stdout, &mut stdout)?; + Ok(f()) +} + +enum Stdio { + Stdin, + Stdout, +} + +#[cfg(windows)] +mod imp { + use super::Stdio; + use std::{fs::File, io::Error, os::windows::prelude::AsRawHandle}; + use windows_sys::Win32::{ + Foundation::{HANDLE, INVALID_HANDLE_VALUE}, + System::Console::{ + GetStdHandle, SetStdHandle, STD_HANDLE, STD_INPUT_HANDLE, STD_OUTPUT_HANDLE, + }, + }; + pub const OUT_DEVICE: &str = "CONOUT$"; + pub const IN_DEVICE: &str = "CONIN$"; + pub const NULL_DEVICE: &str = "NUL"; + + /// Restores previous stdio when dropped. + pub struct ReplacementGuard { + std_handle: STD_HANDLE, + previous: HANDLE, + } + + impl ReplacementGuard { + pub(super) fn new(stdio: Stdio, replacement: &mut File) -> Result<ReplacementGuard, Error> { + let std_handle = match stdio { + Stdio::Stdin => STD_INPUT_HANDLE, + Stdio::Stdout => STD_OUTPUT_HANDLE, + }; + + let previous; + unsafe { + // Make a copy of the current handle + previous = GetStdHandle(std_handle); + if previous == INVALID_HANDLE_VALUE { + return Err(std::io::Error::last_os_error()); + } + + // Replace stdin with the replacement handle + if SetStdHandle(std_handle, replacement.as_raw_handle() as HANDLE) == 0 { + return Err(std::io::Error::last_os_error()); + } + } + + Ok(ReplacementGuard { + previous, + std_handle, + }) + } + } + + impl Drop for ReplacementGuard { + fn drop(&mut self) { + unsafe { + // Put previous handle back in to stdin + SetStdHandle(self.std_handle, self.previous); + } + } + } +} + +#[cfg(unix)] +mod imp { + use super::Stdio; + use libc::{close, dup, dup2, STDIN_FILENO, STDOUT_FILENO}; + use std::{fs::File, io::Error, os::fd::AsRawFd}; + pub const IN_DEVICE: &str = "/dev/tty"; + pub const OUT_DEVICE: &str = "/dev/tty"; + pub const NULL_DEVICE: &str = "/dev/null"; + + /// Restores previous stdio when dropped. + pub struct ReplacementGuard { + std_fileno: i32, + previous: i32, + } + + impl ReplacementGuard { + pub(super) fn new(stdio: Stdio, replacement: &mut File) -> Result<ReplacementGuard, Error> { + let std_fileno = match stdio { + Stdio::Stdin => STDIN_FILENO, + Stdio::Stdout => STDOUT_FILENO, + }; + + let previous; + unsafe { + // Duplicate the existing stdin file to a new descriptor + previous = dup(std_fileno); + if previous == -1 { + return Err(std::io::Error::last_os_error()); + } + // Replace stdin with the replacement file + if dup2(replacement.as_raw_fd(), std_fileno) == -1 { + return Err(std::io::Error::last_os_error()); + } + } + + Ok(ReplacementGuard { + previous, + std_fileno, + }) + } + } + + impl Drop for ReplacementGuard { + fn drop(&mut self) { + unsafe { + // Put previous file back in to stdin + dup2(self.previous, self.std_fileno); + // Close the file descriptor we used as a backup + close(self.previous); + } + } + } +} + +#[cfg(test)] +mod test { + use std::fs::OpenOptions; + use std::io::{Seek, Write}; + + use super::imp::ReplacementGuard; + use super::Stdio; + + #[test] + fn stdin() { + let tempdir = snapbox::path::PathFixture::mutable_temp().unwrap(); + let file = tempdir.path().unwrap().join("stdin"); + let mut file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .open(file) + .unwrap(); + + writeln!(&mut file, "hello").unwrap(); + file.seek(std::io::SeekFrom::Start(0)).unwrap(); + { + let _guard = ReplacementGuard::new(Stdio::Stdin, &mut file).unwrap(); + let line = std::io::stdin().lines().next().unwrap().unwrap(); + assert_eq!(line, "hello"); + } + } +} diff --git a/src/tools/cargo/credential/cargo-credential/tests/examples.rs b/src/tools/cargo/credential/cargo-credential/tests/examples.rs new file mode 100644 index 000000000..87fdb8de3 --- /dev/null +++ b/src/tools/cargo/credential/cargo-credential/tests/examples.rs @@ -0,0 +1,45 @@ +use std::path::Path; + +use snapbox::cmd::Command; + +#[test] +fn stdout_redirected() { + let bin = snapbox::cmd::compile_example("stdout-redirected", []).unwrap(); + + let hello = r#"{"v":[1]}"#; + let get_request = r#"{"v": 1, "registry": {"index-url":"sparse+https://test/","name":"alternative"},"kind": "get","operation": "read","args": []}"#; + let err_not_supported = r#"{"Err":{"kind":"operation-not-supported"}}"#; + + Command::new(bin) + .stdin(format!("{get_request}\n")) + .arg("--cargo-plugin") + .assert() + .stdout_eq(format!("{hello}\n{err_not_supported}\n")) + .stderr_eq("message on stderr should be sent the the parent process\n") + .success(); +} + +#[test] +fn file_provider() { + let bin = snapbox::cmd::compile_example("file-provider", []).unwrap(); + + let hello = r#"{"v":[1]}"#; + let login_request = r#"{"v": 1,"registry": {"index-url":"https://github.com/rust-lang/crates.io-index","name":"crates-io"},"kind": "login","token": "s3krit","args": []}"#; + let login_response = r#"{"Ok":{"kind":"login"}}"#; + + let get_request = r#"{"v": 1,"registry": {"index-url":"https://github.com/rust-lang/crates.io-index","name":"crates-io"},"kind": "get","operation": "read","args": []}"#; + let get_response = + r#"{"Ok":{"kind":"get","token":"s3krit","cache":"session","operation_independent":true}}"#; + + let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join("cargo-credential-tests"); + std::fs::create_dir(&dir).unwrap(); + Command::new(bin) + .current_dir(&dir) + .stdin(format!("{login_request}\n{get_request}\n")) + .arg("--cargo-plugin") + .assert() + .stdout_eq(format!("{hello}\n{login_response}\n{get_response}\n")) + .stderr_eq("") + .success(); + std::fs::remove_dir_all(&dir).unwrap(); +} |