diff --git a/Cargo.lock b/Cargo.lock index 0d997e2c215..9642569fa11 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -339,6 +339,7 @@ dependencies = [ "anyhow", "serde", "serde_json", + "snapbox", "thiserror", "time", ] @@ -867,6 +868,18 @@ dependencies = [ "libc", ] +[[package]] +name = "escargot" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "768064bd3a0e2bedcba91dc87ace90beea91acc41b6a01a3ca8e9aa8827461bf" +dependencies = [ + "log", + "once_cell", + "serde", + "serde_json", +] + [[package]] name = "fastrand" version = "1.9.0" @@ -3003,6 +3016,7 @@ dependencies = [ "anstyle", "content_inspector", "dunce", + "escargot", "filetime", "normalize-line-endings", "similar", diff --git a/credential/cargo-credential/Cargo.toml b/credential/cargo-credential/Cargo.toml index 9ac9168ffe6..b765d529e53 100644 --- a/credential/cargo-credential/Cargo.toml +++ b/credential/cargo-credential/Cargo.toml @@ -12,3 +12,6 @@ serde = { workspace = true, features = ["derive"] } serde_json.workspace = true thiserror.workspace = true time.workspace = true + +[dev-dependencies] +snapbox = { workspace = true, features = ["examples"] } diff --git a/credential/cargo-credential/examples/file-provider.rs b/credential/cargo-credential/examples/file-provider.rs new file mode 100644 index 00000000000..d119585360d --- /dev/null +++ b/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/credential/cargo-credential/src/lib.rs b/credential/cargo-credential/src/lib.rs index bbd437617aa..02564dd2a29 100644 --- a/credential/cargo-credential/src/lib.rs +++ b/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: @@ -8,6 +8,34 @@ //! cargo_credential::main(MyCredential); //! } //! ``` +//! +//! 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")] +//! ``` use serde::{Deserialize, Serialize}; use std::{ diff --git a/credential/cargo-credential/tests/examples.rs b/credential/cargo-credential/tests/examples.rs new file mode 100644 index 00000000000..d31a50cb64e --- /dev/null +++ b/credential/cargo-credential/tests/examples.rs @@ -0,0 +1,28 @@ +use std::path::Path; + +use snapbox::cmd::Command; + +#[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(); +}