From 68b6a6f14b49c2f2a04def33d3a0bf29cfe2b71c Mon Sep 17 00:00:00 2001 From: John Mumm Date: Wed, 16 Jul 2025 11:29:54 +0200 Subject: [PATCH] Update tests and examples --- crates/uv-keyring/examples/cli.rs | 335 ++++++++++++++++++++++++ crates/uv-keyring/examples/ios.rs | 116 ++++++++ crates/uv-keyring/src/lib.rs | 39 +-- crates/uv-keyring/src/secret_service.rs | 6 +- crates/uv-keyring/src/windows.rs | 6 +- crates/uv-keyring/tests/common/mod.rs | 8 +- 6 files changed, 475 insertions(+), 35 deletions(-) create mode 100644 crates/uv-keyring/examples/cli.rs create mode 100644 crates/uv-keyring/examples/ios.rs diff --git a/crates/uv-keyring/examples/cli.rs b/crates/uv-keyring/examples/cli.rs new file mode 100644 index 000000000..4b55914d2 --- /dev/null +++ b/crates/uv-keyring/examples/cli.rs @@ -0,0 +1,335 @@ +extern crate uv_keyring; + +use clap::{Args, Parser}; +use std::collections::HashMap; + +use uv_keyring::{Entry, Error, Result}; + +fn main() { + let mut args: Cli = Cli::parse(); + if args.user.eq_ignore_ascii_case("") { + args.user = whoami::username() + } + let entry = match args.entry_for() { + Ok(entry) => entry, + Err(err) => { + if args.verbose { + let description = args.description(); + eprintln!("Couldn't create entry for '{description}': {err}") + } + std::process::exit(1) + } + }; + match &args.command { + Command::Set { .. } => { + let value = args.get_password_and_attributes(); + match &value { + Value::Secret(secret) => match entry.set_secret(secret) { + Ok(()) => args.success_message_for(&value), + Err(err) => args.error_message_for(err), + }, + Value::Password(password) => match entry.set_password(password) { + Ok(()) => args.success_message_for(&value), + Err(err) => args.error_message_for(err), + }, + Value::Attributes(attributes) => { + let attrs: HashMap<&str, &str> = attributes + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + match entry.update_attributes(&attrs) { + Ok(()) => args.success_message_for(&value), + Err(err) => args.error_message_for(err), + } + } + _ => panic!("Can't set without a value"), + } + } + Command::Password => match entry.get_password() { + Ok(password) => { + println!("{password}"); + args.success_message_for(&Value::Password(password)); + } + Err(err) => args.error_message_for(err), + }, + Command::Secret => match entry.get_secret() { + Ok(secret) => { + println!("{}", secret_string(&secret)); + args.success_message_for(&Value::Secret(secret)); + } + Err(err) => args.error_message_for(err), + }, + Command::Attributes => match entry.get_attributes() { + Ok(attributes) => { + println!("{}", attributes_string(&attributes)); + args.success_message_for(&Value::Attributes(attributes)); + } + Err(err) => args.error_message_for(err), + }, + Command::Delete => match entry.delete_credential() { + Ok(()) => args.success_message_for(&Value::None), + Err(err) => args.error_message_for(err), + }, + } +} + +#[derive(Debug, Parser)] +#[clap(author = "github.com/hwchen/keyring-rs")] +/// Keyring CLI: A command-line interface to platform secure storage +pub struct Cli { + #[clap(short, long, action, verbatim_doc_comment)] + /// Write debugging info to stderr, including retrieved passwords and secrets. + /// If an operation fails, detailed error information is provided. + pub verbose: bool, + + #[clap(short, long, value_parser)] + /// The (optional) target for the entry. + pub target: Option, + + #[clap(short, long, value_parser, default_value = "keyring-cli")] + /// The service for the entry. + pub service: String, + + #[clap(short, long, value_parser, default_value = "")] + /// The user for the entry. + pub user: String, + + #[clap(subcommand)] + pub command: Command, +} + +#[derive(Debug, Parser)] +pub enum Command { + /// Set the password or update the attributes in the secure store + Set { + #[command(flatten)] + what: What, + + #[clap(value_parser)] + /// The input to parse. If not specified, it will be + /// read interactively from the terminal. Password/secret + /// input will not be echoed. + input: Option, + }, + /// Retrieve the (string) password from the secure store + /// and write it to the standard output. + Password, + /// Retrieve the (binary) secret from the secure store + /// and write it in base64 encoding to the standard output. + Secret, + /// Retrieve attributes available in the secure store. + Attributes, + /// Delete the credential from the secure store. + Delete, +} + +#[derive(Debug, Args)] +#[group(multiple = false, required = true)] +pub struct What { + #[clap(short, long, action, help = "The input is a password")] + password: bool, + + #[clap(short, long, action, help = "The input is a base64-encoded secret")] + secret: bool, + + #[clap( + short, + long, + action, + help = "The input is comma-separated, key=val attribute pairs" + )] + attributes: bool, +} + +enum Value { + Secret(Vec), + Password(String), + Attributes(HashMap), + None, +} + +impl Cli { + fn description(&self) -> String { + if let Some(target) = &self.target { + format!("{}@{}:{target}", &self.user, &self.service) + } else { + format!("{}@{}", &self.user, &self.service) + } + } + + fn entry_for(&self) -> Result { + if let Some(target) = &self.target { + Entry::new_with_target(target, &self.service, &self.user) + } else { + Entry::new(&self.service, &self.user) + } + } + + fn error_message_for(&self, err: Error) { + if self.verbose { + let description = self.description(); + match err { + Error::NoEntry => { + eprintln!("No credential found for '{description}'"); + } + Error::Ambiguous(creds) => { + eprintln!("More than one credential found for '{description}': {creds:?}"); + } + err => match self.command { + Command::Set { .. } => { + eprintln!("Couldn't set credential data for '{description}': {err}"); + } + Command::Password => { + eprintln!("Couldn't get password for '{description}': {err}"); + } + Command::Secret => { + eprintln!("Couldn't get secret for '{description}': {err}"); + } + Command::Attributes => { + eprintln!("Couldn't get attributes for '{description}': {err}"); + } + Command::Delete => { + eprintln!("Couldn't delete credential for '{description}': {err}"); + } + }, + } + } + std::process::exit(1) + } + + fn success_message_for(&self, value: &Value) { + if !self.verbose { + return; + } + let description = self.description(); + match self.command { + Command::Set { .. } => match value { + Value::Secret(secret) => { + let secret = secret_string(secret); + eprintln!("Set secret for '{description}' to decode of '{secret}'"); + } + Value::Password(password) => { + eprintln!("Set password for '{description}' to '{password}'"); + } + Value::Attributes(attributes) => { + eprintln!("The following attributes for '{description}' were sent for update:"); + eprint_attributes(attributes); + } + _ => panic!("Can't set without a value"), + }, + Command::Password => { + match value { + Value::Password(password) => { + eprintln!("Password for '{description}' is '{password}'"); + } + _ => panic!("Wrong value type for command"), + }; + } + Command::Secret => match value { + Value::Secret(secret) => { + let encoded = secret_string(secret); + eprintln!("Secret for '{description}' encodes as {encoded}"); + } + _ => panic!("Wrong value type for command"), + }, + Command::Attributes => match value { + Value::Attributes(attributes) => { + if attributes.is_empty() { + eprintln!("No attributes found for '{description}'"); + } else { + eprintln!("Attributes for '{description}' are:"); + eprint_attributes(attributes); + } + } + _ => panic!("Wrong value type for command"), + }, + Command::Delete => { + eprintln!("Successfully deleted credential for '{description}'"); + } + } + } + + fn get_password_and_attributes(&self) -> Value { + if let Command::Set { what, input } = &self.command { + if what.password { + Value::Password(read_password(input)) + } else if what.secret { + Value::Secret(decode_secret(input)) + } else { + Value::Attributes(parse_attributes(input)) + } + } else { + panic!("Can't happen: asking for password and attributes on non-set command") + } + } +} + +fn secret_string(secret: &[u8]) -> String { + use base64::prelude::*; + + BASE64_STANDARD.encode(secret) +} + +fn eprint_attributes(attributes: &HashMap) { + for (key, value) in attributes { + println!(" {key}: {value}"); + } +} + +fn decode_secret(input: &Option) -> Vec { + use base64::prelude::*; + + let encoded = if let Some(input) = input { + input.clone() + } else { + rpassword::prompt_password("Base64 encoding: ").unwrap_or_else(|_| String::new()) + }; + if encoded.is_empty() { + return Vec::new(); + } + match BASE64_STANDARD.decode(encoded) { + Ok(secret) => secret, + Err(err) => { + eprintln!("Sorry, the provided secret data is not base64-encoded: {err}"); + std::process::exit(1); + } + } +} + +fn read_password(input: &Option) -> String { + if let Some(input) = input { + input.clone() + } else { + rpassword::prompt_password("Password: ").unwrap_or_else(|_| String::new()) + } +} + +fn attributes_string(attributes: &HashMap) -> String { + let strings = attributes + .iter() + .map(|(k, v)| format!("{}={}", k, v)) + .collect::>(); + strings.join(",") +} + +fn parse_attributes(input: &Option) -> HashMap { + let input = if let Some(input) = input { + input.clone() + } else { + rprompt::prompt_reply("Attributes: ").unwrap_or_else(|_| String::new()) + }; + if input.is_empty() { + eprintln!("You must specify at least one key=value attribute pair to set") + } + let mut attributes = HashMap::new(); + let parts = input.split(','); + for s in parts.into_iter() { + let parts: Vec<&str> = s.split("=").collect(); + if parts.len() != 2 || parts[0].is_empty() { + eprintln!("Sorry, this part of the attributes string is not a key=val pair: {s}"); + std::process::exit(1); + } + attributes.insert(parts[0].to_string(), parts[1].to_string()); + } + attributes +} diff --git a/crates/uv-keyring/examples/ios.rs b/crates/uv-keyring/examples/ios.rs new file mode 100644 index 000000000..dc69cf4f5 --- /dev/null +++ b/crates/uv-keyring/examples/ios.rs @@ -0,0 +1,116 @@ +use uv_keyring::{Entry, Error}; + +#[unsafe(no_mangle)] +extern "C" fn test() { + test_invalid_parameter(); + test_empty_keyring(); + test_empty_password_input(); + test_round_trip_ascii_password(); + test_round_trip_non_ascii_password(); + test_update_password(); + #[cfg(target_os = "ios")] + test_get_credential(); +} + +fn test_invalid_parameter() { + let entry = Entry::new("", "user"); + assert!( + matches!(entry, Err(Error::Invalid(_, _))), + "Created entry with empty service" + ); + let entry = Entry::new("service", ""); + assert!( + matches!(entry, Err(Error::Invalid(_, _))), + "Created entry with empty user" + ); + let entry = Entry::new_with_target("test", "service", "user"); + assert!( + matches!(entry, Err(Error::Invalid(_, _))), + "Created entry with non-default target" + ); +} + +fn test_empty_keyring() { + let name = "test_empty_keyring".to_string(); + let entry = Entry::new(&name, &name).expect("Failed to create entry"); + assert!(matches!(entry.get_password(), Err(Error::NoEntry))) +} + +fn test_empty_password_input() { + let name = "test_empty_password_input".to_string(); + let entry = Entry::new(&name, &name).expect("Failed to create entry"); + let in_pass = ""; + entry + .set_password(in_pass) + .expect("Couldn't set empty password"); + let out_pass = entry.get_password().expect("Couldn't get empty password"); + assert_eq!(in_pass, out_pass); + entry + .delete_credential() + .expect("Couldn't delete credential with empty password"); + assert!( + matches!(entry.get_password(), Err(Error::NoEntry)), + "Able to read a deleted password" + ) +} + +fn test_round_trip_ascii_password() { + let name = "test_round_trip_ascii_password".to_string(); + let entry = Entry::new(&name, &name).expect("Failed to create entry"); + let password = "test ascii password"; + entry.set_password(password).unwrap(); + let stored_password = entry.get_password().unwrap(); + assert_eq!(stored_password, password); + entry.delete_credential().unwrap(); + assert!(matches!(entry.get_password(), Err(Error::NoEntry))) +} + +fn test_round_trip_non_ascii_password() { + let name = "test_round_trip_non_ascii_password".to_string(); + let entry = Entry::new(&name, &name).expect("Failed to create entry"); + let password = "このきれいな花は桜です"; + entry.set_password(password).unwrap(); + let stored_password = entry.get_password().unwrap(); + assert_eq!(stored_password, password); + entry.delete_credential().unwrap(); + assert!(matches!(entry.get_password(), Err(Error::NoEntry))) +} + +fn test_update_password() { + let name = "test_update_password".to_string(); + let entry = Entry::new(&name, &name).expect("Failed to create entry"); + let password = "test ascii password"; + entry.set_password(password).unwrap(); + let stored_password = entry.get_password().unwrap(); + assert_eq!(stored_password, password); + let password = "このきれいな花は桜です"; + entry.set_password(password).unwrap(); + let stored_password = entry.get_password().unwrap(); + assert_eq!(stored_password, password); + entry.delete_credential().unwrap(); + assert!(matches!(entry.get_password(), Err(Error::NoEntry))) +} + +#[cfg(target_os = "ios")] +fn test_get_credential() { + use keyring::ios::IosCredential; + let name = "test_get_credential".to_string(); + let entry = Entry::new(&name, &name).expect("Can't create entry for get_credential"); + let credential: &IosCredential = entry + .get_credential() + .downcast_ref() + .expect("Not an iOS credential"); + assert!( + credential.get_credential().is_err(), + "Platform credential shouldn't exist yet!" + ); + entry + .set_password("test get password for get_credential") + .expect("Can't get password for get_credential"); + assert!(credential.get_credential().is_ok()); + entry.delete_credential().unwrap(); + assert!( + matches!(entry.get_password(), Err(Error::NoEntry)), + "Platform credential exists after delete password" + ) +} diff --git a/crates/uv-keyring/src/lib.rs b/crates/uv-keyring/src/lib.rs index 763711372..0dd74a68d 100644 --- a/crates/uv-keyring/src/lib.rs +++ b/crates/uv-keyring/src/lib.rs @@ -122,7 +122,7 @@ store allows for pre-setting errors as well as password values to be returned from [Entry] method calls. If you want to use the mock credential store as your default in tests, make this call: ``` -keyring::set_default_credential_builder(keyring::mock::default_credential_builder()) +uv_keyring::set_default_credential_builder(uv_keyring::mock::default_credential_builder()) ``` ## Interoperability with Third Parties @@ -442,7 +442,7 @@ mod tests { use std::collections::HashMap; /// Create a platform-specific credential given the constructor, service, and user - pub fn entry_from_constructor(f: F, service: &str, user: &str) -> Entry + pub(crate) fn entry_from_constructor(f: F, service: &str, user: &str) -> Entry where F: FnOnce(Option<&str>, &str, &str) -> Result, T: 'static + CredentialApi + Send + Sync, @@ -456,7 +456,7 @@ mod tests { } /// Create a platform-specific credential given the constructor, service, user, and attributes - pub fn entry_from_constructor_and_attributes( + pub(crate) fn entry_from_constructor_and_attributes( f: F, service: &str, user: &str, @@ -488,7 +488,7 @@ mod tests { } /// A basic round-trip unit test given an entry and a password. - pub fn test_round_trip(case: &str, entry: &Entry, in_pass: &str) { + pub(crate) fn test_round_trip(case: &str, entry: &Entry, in_pass: &str) { test_round_trip_no_delete(case, entry, in_pass); entry .delete_credential() @@ -501,7 +501,7 @@ mod tests { } /// A basic round-trip unit test given an entry and a password. - pub fn test_round_trip_secret(case: &str, entry: &Entry, in_secret: &[u8]) { + pub(crate) fn test_round_trip_secret(case: &str, entry: &Entry, in_secret: &[u8]) { entry .set_secret(in_secret) .unwrap_or_else(|err| panic!("Can't set secret for {case}: {err:?}")); @@ -528,13 +528,13 @@ mod tests { /// to have tests use a random string for key names to avoid /// the conflicts, and then do any needed cleanup once everything /// is working correctly. So we export this function for tests to use. - pub fn generate_random_string_of_len(len: usize) -> String { + pub(crate) fn generate_random_string_of_len(len: usize) -> String { use fastrand; use std::iter::repeat_with; repeat_with(fastrand::alphanumeric).take(len).collect() } - pub fn generate_random_string() -> String { + pub(crate) fn generate_random_string() -> String { generate_random_string_of_len(30) } @@ -544,18 +544,7 @@ mod tests { repeat_with(|| fastrand::u8(..)).take(len).collect() } - pub fn test_empty_service_and_user(f: F) - where - F: Fn(&str, &str) -> Entry, - { - let name = generate_random_string(); - let in_pass = "doesn't matter"; - test_round_trip("empty user", &f(&name, ""), in_pass); - test_round_trip("empty service", &f("", &name), in_pass); - test_round_trip("empty service & user", &f("", ""), in_pass); - } - - pub fn test_missing_entry(f: F) + pub(crate) async fn test_missing_entry(f: F) where F: FnOnce(&str, &str) -> Entry, { @@ -567,7 +556,7 @@ mod tests { ) } - pub fn test_empty_password(f: F) + pub(crate) fn test_empty_password(f: F) where F: FnOnce(&str, &str) -> Entry, { @@ -576,7 +565,7 @@ mod tests { test_round_trip("empty password", &entry, ""); } - pub fn test_round_trip_ascii_password(f: F) + pub(crate) fn test_round_trip_ascii_password(f: F) where F: FnOnce(&str, &str) -> Entry, { @@ -585,7 +574,7 @@ mod tests { test_round_trip("ascii password", &entry, "test ascii password"); } - pub fn test_round_trip_non_ascii_password(f: F) + pub(crate) fn test_round_trip_non_ascii_password(f: F) where F: FnOnce(&str, &str) -> Entry, { @@ -594,7 +583,7 @@ mod tests { test_round_trip("non-ascii password", &entry, "このきれいな花は桜です"); } - pub fn test_round_trip_random_secret(f: F) + pub(crate) fn test_round_trip_random_secret(f: F) where F: FnOnce(&str, &str) -> Entry, { @@ -604,7 +593,7 @@ mod tests { test_round_trip_secret("non-ascii password", &entry, secret.as_slice()); } - pub fn test_update(f: F) + pub(crate) fn test_update(f: F) where F: FnOnce(&str, &str) -> Entry, { @@ -618,7 +607,7 @@ mod tests { ); } - pub fn test_noop_get_update_attributes(f: F) + pub(crate) fn test_noop_get_update_attributes(f: F) where F: FnOnce(&str, &str) -> Entry, { diff --git a/crates/uv-keyring/src/secret_service.rs b/crates/uv-keyring/src/secret_service.rs index 1c5e00f39..bd0f0d927 100644 --- a/crates/uv-keyring/src/secret_service.rs +++ b/crates/uv-keyring/src/secret_service.rs @@ -614,9 +614,9 @@ mod tests { crate::tests::test_empty_service_and_user(entry_new); } - #[test] - fn test_missing_entry() { - crate::tests::test_missing_entry(entry_new); + #[tokio::test] + async fn test_missing_entry() { + crate::tests::test_missing_entry(entry_new).await; } #[test] diff --git a/crates/uv-keyring/src/windows.rs b/crates/uv-keyring/src/windows.rs index e7b26f537..4e4b2b181 100644 --- a/crates/uv-keyring/src/windows.rs +++ b/crates/uv-keyring/src/windows.rs @@ -664,9 +664,9 @@ mod tests { crate::tests::test_empty_service_and_user(entry_new); } - #[test] - fn test_missing_entry() { - crate::tests::test_missing_entry(entry_new); + #[tokio::test] + async fn test_missing_entry() { + crate::tests::test_missing_entry(entry_new).await; } #[test] diff --git a/crates/uv-keyring/tests/common/mod.rs b/crates/uv-keyring/tests/common/mod.rs index 57c4b7079..0e2304db9 100644 --- a/crates/uv-keyring/tests/common/mod.rs +++ b/crates/uv-keyring/tests/common/mod.rs @@ -12,22 +12,22 @@ /// to have tests use a random string for key names to avoid /// the conflicts, and then do any needed cleanup once everything /// is working correctly. So we export this function for tests to use. -pub fn generate_random_string_of_len(len: usize) -> String { +pub(crate) fn generate_random_string_of_len(len: usize) -> String { use fastrand; use std::iter::repeat_with; repeat_with(fastrand::alphanumeric).take(len).collect() } -pub fn generate_random_string() -> String { +pub(crate) fn generate_random_string() -> String { generate_random_string_of_len(30) } -pub fn generate_random_bytes_of_len(len: usize) -> Vec { +pub(crate) fn generate_random_bytes_of_len(len: usize) -> Vec { use fastrand; use std::iter::repeat_with; repeat_with(|| fastrand::u8(..)).take(len).collect() } -pub fn init_logger() { +pub(crate) fn init_logger() { let _ = env_logger::builder().is_test(true).try_init(); }