summaryrefslogtreecommitdiffstats
path: root/src/tools/cargo/credential/cargo-credential
diff options
context:
space:
mode:
Diffstat (limited to 'src/tools/cargo/credential/cargo-credential')
-rw-r--r--src/tools/cargo/credential/cargo-credential/Cargo.toml18
-rw-r--r--src/tools/cargo/credential/cargo-credential/README.md2
-rw-r--r--src/tools/cargo/credential/cargo-credential/examples/file-provider.rs90
-rw-r--r--src/tools/cargo/credential/cargo-credential/examples/stdout-redirected.rs25
-rw-r--r--src/tools/cargo/credential/cargo-credential/src/error.rs206
-rw-r--r--src/tools/cargo/credential/cargo-credential/src/lib.rs312
-rw-r--r--src/tools/cargo/credential/cargo-credential/src/secret.rs101
-rw-r--r--src/tools/cargo/credential/cargo-credential/src/stdio.rs163
-rw-r--r--src/tools/cargo/credential/cargo-credential/tests/examples.rs45
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();
+}