diff options
Diffstat (limited to 'src/tools/cargo/credential/cargo-credential')
9 files changed, 888 insertions, 74 deletions
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(); +} |