diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml index 76706ed5..a2506520 100644 --- a/.github/workflows/integration.yml +++ b/.github/workflows/integration.yml @@ -151,10 +151,10 @@ jobs: - name: Verify that keys have been deleted run: | - bin/rust/vault --exists secret-python | grep doesn\'t - bin/rust/vault --exists secret-go | grep doesn\'t - bin/rust/vault --exists secret-rust | grep doesn\'t - bin/rust/vault --exists secret-nodejs | grep doesn\'t + bin/rust/vault --exists secret-python | grep -q "key 'secret-python' does not exist" + bin/rust/vault --exists secret-go | grep -q "key 'secret-go' does not exist" + bin/rust/vault --exists secret-rust | grep -q "key 'secret-rust' does not exist" + bin/rust/vault --exists secret-nodejs | grep -q "key 'secret-nodejs' does not exist" - name: Create dummy text file run: echo "Vault test ${{ github.sha }} ${{ github.ref_name }}" > test.txt @@ -194,4 +194,4 @@ jobs: - name: Verify that keys have been deleted run: | - bin/rust/vault --exists secret-${{github.sha}}.zip | grep doesn\'t + bin/rust/vault --exists secret-${{github.sha}}.zip | grep -q "does not exist" diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 3de89691..3142389f 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -685,6 +685,15 @@ dependencies = [ "strsim", ] +[[package]] +name = "clap_complete" +version = "4.5.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07a13ab5b8cb13dbe35e68b83f6c12f9293b2f601797b71bc9f23befdb329feb" +dependencies = [ + "clap", +] + [[package]] name = "clap_derive" version = "4.5.18" @@ -840,6 +849,27 @@ dependencies = [ "subtle", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.48.0", +] + [[package]] name = "ecdsa" version = "0.14.8" @@ -1226,6 +1256,16 @@ version = "0.2.161" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1" +[[package]] +name = "libredox" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ff37bd590ca25063e35af745c343cb7a0271906fb7b37e4813e8f79f00268d" +dependencies = [ + "bitflags", + "libc", +] + [[package]] name = "lock_api" version = "0.4.12" @@ -1301,7 +1341,9 @@ dependencies = [ "aws-sdk-sts", "base64 0.22.1", "clap", + "clap_complete", "colored", + "dirs", "rand", "serde", "serde_json", @@ -1360,6 +1402,12 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff011a302c396a5197692431fc1948019154afc178baf7d8e37367442a4601cf" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "outref" version = "0.5.1" @@ -1408,9 +1456,9 @@ checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" [[package]] name = "pin-project-lite" -version = "0.2.14" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bda66fc9667c18cb2758a2ac84d1167245054bcf85d5d1aaa6923f45801bdd02" +checksum = "915a1e146535de9163f3987b8944ed8cf49a18bb0056bcebcdcece385cece4ff" [[package]] name = "pin-utils" @@ -1512,6 +1560,17 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom", + "libredox", + "thiserror", +] + [[package]] name = "regex-lite" version = "0.1.6" @@ -1810,9 +1869,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.82" +version = "2.0.85" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83540f837a8afc019423a8edb95b52a8effe46957ee402287f4292fae35be021" +checksum = "5023162dfcd14ef8f32034d8bcd4cc5ddc61ef7a247c024a33e24e1f24d21b56" dependencies = [ "proc-macro2", "quote", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 016e99f5..bd8e5117 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -21,7 +21,9 @@ aws-sdk-s3 = "1.57.0" aws-sdk-sts = { version = "1.46.0", features = ["behavior-version-latest"] } base64 = "0.22.1" clap = { version = "4.5.20", features = ["derive", "env"] } +clap_complete = "4.5.33" colored = "2.1.0" +dirs = "5.0.1" rand = "0.8.5" serde = { version = "1.0.213", features = ["derive"] } serde_json = "1.0.132" diff --git a/rust/README.md b/rust/README.md index 54daa23a..26568dd2 100644 --- a/rust/README.md +++ b/rust/README.md @@ -22,20 +22,21 @@ Encrypted AWS key-value storage utility. Usage: vault [OPTIONS] [COMMAND] Commands: - all, -a, --all List available secrets - delete, -d, --delete Delete an existing key from the store - describe, --describe Describe CloudFormation stack parameters for current configuration - decrypt, -y, --decrypt Directly decrypt given value - encrypt, -e, --encrypt Directly encrypt given value - exists, --exists Check if a key exists - info, --info Print vault information - id, --id Print AWS user account information - status, --status Print vault stack information - init, -i, --init Initialize a new KMS key and S3 bucket - update, -u, --update Update the vault CloudFormation stack - lookup, -l, --lookup Output secret value for given key - store, -s, --store Store a new key-value pair - help Print this message or the help of the given subcommand(s) + all, -a, --all List available secrets + completion, --completion Generate shell completion + delete, -d, --delete Delete an existing key from the store + describe, --describe Describe CloudFormation stack parameters for current configuration + decrypt, -y, --decrypt Directly decrypt given value + encrypt, -e, --encrypt Directly encrypt given value + exists, --exists Check if a key exists + info, --info Print vault information + id, --id Print AWS user account information + status, --status Print vault stack information + init, -i, --init Initialize a new KMS key and S3 bucket + update, -u, --update Update the vault CloudFormation stack + lookup, -l, --lookup Output secret value for given key + store, -s, --store Store a new key-value pair + help Print this message or the help of the given subcommand(s) Options: -b, --bucket Override the bucket name [env: VAULT_BUCKET=] @@ -43,6 +44,7 @@ Options: -p, --prefix Optional prefix for key name [env: VAULT_PREFIX=] -r, --region Specify AWS region for the bucket [env: AWS_REGION=] --vault-stack Specify CloudFormation stack name to use [env: VAULT_STACK=] + -q, --quiet Suppress additional output and error messages -h, --help Print help (see more with '--help') -V, --version Print version ``` @@ -69,6 +71,45 @@ fn main() -> anyhow::Result<()> { } ``` +## Shell completion + +Use the `completion` command to generate auto-completion scripts. + +```console +Generate shell completion + +Usage: vault {completion|--completion} [OPTIONS] + +Arguments: + [possible values: bash, elvish, fish, powershell, zsh] + +Options: + -i, --install Output completion directly to the correct directory instead of stdout + -h, --help Print help +``` + +### Oh My Zsh + +If the `~/.oh-my-zsh/custom/plugins` dir is found when outputting for `zsh`, +the completions will be outputted as a custom plugin called `vault`. +Enable the completions by adding `vault` to the plugin list in `~/.zshrc` config. + +### Powershell + +A `completions` subdirectory will be created under the default profile directory path for the current user. +This will need to be loaded in the user profile, for example: + +```powershell +# Load all completions scripts in the completions directory +$completionScriptsPath = "$HOME/.config/powershell/completions/" +if (Test-Path $completionScriptsPath) +{ + Get-ChildItem -Path $completionScriptsPath -Filter *.ps1 | ForEach-Object { + . $_.FullName + } +} +``` + ## Development ### Build @@ -167,5 +208,4 @@ Try publishing with `cargo publish --dry-run` and then run with `cargo publish`. ## TODO -- Direct encrypt and decrypt to match Python implementation - Add test cases with mocking: https://docs.aws.amazon.com/sdk-for-rust/latest/dg/testing.html diff --git a/rust/src/cli.rs b/rust/src/cli.rs index 68246291..2e203fa6 100644 --- a/rust/src/cli.rs +++ b/rust/src/cli.rs @@ -1,14 +1,17 @@ use std::io::Write; use std::path::{Path, PathBuf}; -use anyhow::{Context, Result}; +use anyhow::{anyhow, Context, Result}; use aws_sdk_cloudformation::types::StackStatus; +use clap::Command; +use clap_complete::Shell; use colored::Colorize; use tokio::time::Duration; use nitor_vault::{cloudformation, CreateStackResult, UpdateStackResult, Value, Vault}; static WAIT_ANIMATION_DURATION: Duration = Duration::from_millis(500); +static QUIET_WAIT_DURATION: Duration = Duration::from_secs(1); static CLEAR_LINE: &str = "\x1b[2K"; static WAIT_DOTS: [&str; 4] = [".", "..", "...", ""]; @@ -17,11 +20,14 @@ pub async fn init_vault_stack( stack_name: Option, region: Option, bucket: Option, + quiet: bool, ) -> Result<()> { match Vault::init(stack_name, region, bucket).await? { CreateStackResult::Exists { data } => { - println!("Vault stack already initialized"); - println!("{data}"); + if !quiet { + println!("{}", "Vault stack already initialized:".bold()); + println!("{data}"); + } } CreateStackResult::ExistsWithFailedState { data } => { anyhow::bail!( @@ -34,24 +40,28 @@ pub async fn init_vault_stack( stack_id, region, } => { - println!("Stack created with ID: {stack_id}"); + if !quiet { + println!("Stack created with ID: {stack_id}"); + } let config = aws_config::from_env().region(region).load().await; - wait_for_stack_creation_to_finish(&config, &stack_name).await?; + wait_for_stack_creation_to_finish(&config, &stack_name, quiet).await?; } } Ok(()) } /// Update existing Cloudformation vault stack and wait for update to finish. -pub async fn update_vault_stack(vault: &Vault) -> Result<()> { +pub async fn update_vault_stack(vault: &Vault, quiet: bool) -> Result<()> { match vault .update_stack() .await .with_context(|| "Failed to update vault stack".red())? { UpdateStackResult::UpToDate { data } => { - println!("{}", "Vault stack is up to date:".bold()); - println!("{data}"); + if !quiet { + println!("{}", "Vault stack is up to date:".bold()); + println!("{data}"); + } Ok(()) } UpdateStackResult::Updated { @@ -59,13 +69,15 @@ pub async fn update_vault_stack(vault: &Vault) -> Result<()> { previous_version: current_version, new_version, } => { - println!( - "{}", - format!("Updating vault stack from version {current_version} to {new_version}") - .bold() - ); - println!("{stack_id}"); - wait_for_stack_update_to_finish(vault).await + if !quiet { + println!( + "{}", + format!("Updating vault stack from version {current_version} to {new_version}") + .bold() + ); + println!("{stack_id}"); + } + wait_for_stack_update_to_finish(vault, quiet).await } } } @@ -78,6 +90,7 @@ pub async fn store( file: Option, value_argument: Option, overwrite: bool, + quiet: bool, ) -> Result<()> { let key = { if let Some(key) = key { @@ -87,7 +100,9 @@ pub async fn store( anyhow::bail!("Key cannot be empty when reading from stdin".red()) } let key = get_filename_from_path(file_name)?; - println!("Using filename as key: '{key}'"); + if !quiet { + println!("Using filename as key: '{key}'"); + } key } else { anyhow::bail!( @@ -159,21 +174,25 @@ pub async fn list_all_keys(vault: &Vault) -> Result<()> { } /// Check if key exists. -pub async fn exists(vault: &Vault, key: &str) -> Result<()> { +pub async fn exists(vault: &Vault, key: &str, quiet: bool) -> Result { if key.trim().is_empty() { anyhow::bail!(format!("Empty key: '{key}'").red()) } - vault + + let exists = vault .exists(key) .await - .with_context(|| format!("Failed to check if key '{key}' exists").red()) - .map(|result| { - if result { - println!("key '{key}' exists"); - } else { - println!("{}", format!("key '{key}' doesn't exist").red()); - } - }) + .with_context(|| format!("Failed to check if key '{key}' exists").red())?; + + if !quiet { + if exists { + println!("key '{key}' exists"); + } else { + println!("{}", format!("key '{key}' does not exist").red()); + } + } + + Ok(exists) } /// Directly encrypt given value with KMS. @@ -238,6 +257,7 @@ pub async fn print_aws_account(region: Option) -> Result<()> { async fn wait_for_stack_creation_to_finish( config: &aws_config::SdkConfig, stack_name: &str, + quiet: bool, ) -> Result<()> { let client = aws_sdk_cloudformation::Client::new(config); let mut last_status: Option = None; @@ -246,24 +266,32 @@ async fn wait_for_stack_creation_to_finish( if let Some(ref status) = stack_data.status { match status { StackStatus::CreateComplete => { - println!("{CLEAR_LINE}{stack_data}"); - println!("{}", "Stack creation completed successfully".green()); + if !quiet { + println!("{CLEAR_LINE}{stack_data}"); + println!("{}", "Stack creation completed successfully".green()); + } break; } StackStatus::CreateFailed | StackStatus::RollbackFailed | StackStatus::RollbackComplete => { - println!("{CLEAR_LINE}{stack_data}"); + if !quiet { + println!("{CLEAR_LINE}{stack_data}"); + } anyhow::bail!("Stack creation failed"); } _ => { - // Print status if it has changed - if last_status.as_ref() != Some(status) { - last_status = Some(status.clone()); - println!("status: {status}"); + if quiet { + tokio::time::sleep(QUIET_WAIT_DURATION).await; + } else { + // Print status if it has changed + if last_status.as_ref() != Some(status) { + last_status = Some(status.clone()); + println!("status: {status}"); + } + // Continue waiting for stack creation to complete + print_wait_animation().await?; } - // Continue waiting for stack creation to complete - print_wait_animation().await?; } } } else { @@ -274,29 +302,37 @@ async fn wait_for_stack_creation_to_finish( } /// Poll Cloudformation for stack status until it has been updated or update failed. -async fn wait_for_stack_update_to_finish(vault: &Vault) -> Result<()> { +async fn wait_for_stack_update_to_finish(vault: &Vault, quiet: bool) -> Result<()> { let mut last_status: Option = None; loop { let stack_data = vault.stack_status().await?; if let Some(ref status) = stack_data.status { match status { StackStatus::UpdateComplete => { - println!("{CLEAR_LINE}{stack_data}"); - println!("{}", "Stack update completed successfully".green()); + if !quiet { + println!("{CLEAR_LINE}{stack_data}"); + println!("{}", "Stack update completed successfully".green()); + } break; } StackStatus::UpdateFailed | StackStatus::RollbackFailed => { - println!("{CLEAR_LINE}{stack_data}"); + if !quiet { + println!("{CLEAR_LINE}{stack_data}"); + } anyhow::bail!("Stack update failed".red()); } _ => { - // Print status if it has changed - if last_status.as_ref() != Some(status) { - last_status = Some(status.clone()); - println!("status: {status}"); + if quiet { + tokio::time::sleep(QUIET_WAIT_DURATION).await; + } else { + // Print status if it has changed + if last_status.as_ref() != Some(status) { + last_status = Some(status.clone()); + println!("status: {status}"); + } + // Continue waiting for stack update to complete + print_wait_animation().await?; } - // Continue waiting for stack update to complete - print_wait_animation().await?; } } } else { @@ -309,6 +345,26 @@ async fn wait_for_stack_update_to_finish(vault: &Vault) -> Result<()> { Ok(()) } +/// Generate a shell completion script for the given shell. +pub fn generate_shell_completion( + shell: Shell, + mut command: Command, + install: bool, + quiet: bool, +) -> Result<()> { + if install { + let out_dir = get_shell_completion_dir(shell)?; + let path = clap_complete::generate_to(shell, &mut command, "vault", out_dir)?; + if !quiet { + println!("Completion file generated to: {}", path.display()); + } + } else { + clap_complete::generate(shell, &mut command, "vault", &mut std::io::stdout()); + } + + Ok(()) +} + /// Try to get the filename for the given filepath. fn get_filename_from_path(path: &str) -> Result { let path = Path::new(path); @@ -379,3 +435,63 @@ async fn print_wait_animation() -> Result<()> { } Ok(()) } + +/// Determine the appropriate directory for storing shell completions. +/// +/// First checks if the user-specific directory exists, +/// then checks for the global directory. +/// If neither exist, creates and uses the user-specific dir. +fn get_shell_completion_dir(shell: Shell) -> Result { + let home = dirs::home_dir().ok_or_else(|| anyhow!("Failed to get home directory"))?; + + // Special handling for oh-my-zsh: https://ohmyz.sh/ + // Create custom "plugin", which will then have to be loaded in .zshrc + if shell == Shell::Zsh { + let omz_plugins = home.join(".oh-my-zsh/custom/plugins"); + if omz_plugins.exists() { + let plugin_dir = omz_plugins.join("vault"); + std::fs::create_dir_all(&plugin_dir)?; + return Ok(plugin_dir); + } + } + + let user_dir = match shell { + Shell::PowerShell => { + if cfg!(windows) { + home.join(r"Documents\PowerShell\completions") + } else { + home.join(".config/powershell/completions") + } + } + Shell::Bash => home.join(".bash_completion.d"), + Shell::Elvish => home.join(".elvish"), + Shell::Fish => home.join(".config/fish/completions"), + Shell::Zsh => home.join(".zsh/completions"), + _ => anyhow::bail!("Unsupported shell"), + }; + + if user_dir.exists() { + return Ok(user_dir); + } + + let global_dir = match shell { + Shell::PowerShell => { + if cfg!(windows) { + home.join(r"Documents\PowerShell\completions") + } else { + home.join(".config/powershell/completions") + } + } + Shell::Bash => PathBuf::from("/etc/bash_completion.d"), + Shell::Fish => PathBuf::from("/usr/share/fish/completions"), + Shell::Zsh => PathBuf::from("/usr/share/zsh/site-functions"), + _ => anyhow::bail!("Unsupported shell"), + }; + + if global_dir.exists() { + return Ok(global_dir); + } + + std::fs::create_dir_all(&user_dir)?; + Ok(user_dir) +} diff --git a/rust/src/main.rs b/rust/src/main.rs index 63c1eefe..2578fb18 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -1,7 +1,8 @@ mod cli; use anyhow::{Context, Result}; -use clap::{Parser, Subcommand}; +use clap::{CommandFactory, Parser, Subcommand}; +use clap_complete::Shell; use colored::Colorize; use nitor_vault::Vault; @@ -36,6 +37,10 @@ struct Args { #[arg(long, name = "NAME", env = "VAULT_STACK")] vault_stack: Option, + /// Suppress additional output and error messages + #[arg(short, long)] + quiet: bool, + /// Available subcommands #[command(subcommand)] command: Option, @@ -48,6 +53,16 @@ enum Command { #[command(short_flag('a'), long_flag("all"), alias("a"))] All {}, + /// Generate shell completion + #[command(long_flag("completion"))] + Completion { + shell: Shell, + + /// Output completion directly to the default directory instead of stdout + #[arg(short, long, default_value_t = false)] + install: bool, + }, + /// Delete an existing key from the store #[command(short_flag('d'), long_flag("delete"), alias("d"))] Delete { key: String }, @@ -116,8 +131,17 @@ enum Command { }, /// Check if a key exists - #[command(long_flag("exists"))] - Exists { key: String }, + #[command( + long_flag("exists"), + long_about = "Check if the given key exists.\n\n\ + It will exit with code 0 if the key exists,\n\ + code 5 if it does *not* exist,\n\ + and with code 1 for other errors." + )] + Exists { + /// Key name to lookup + key: String, + }, /// Print vault information #[command(long_flag("info"))] @@ -194,7 +218,7 @@ enum Command { - Store from stdin: `cat file.zip | vault store mykey --file -`" )] Store { - /// Key name + /// Key name to use for stored value key: Option, /// Value to store, use '-' for stdin @@ -225,16 +249,19 @@ enum Command { } #[allow(clippy::match_same_arms)] -#[tokio::main] -async fn main() -> Result<()> { - let args = Args::parse(); - +#[allow(clippy::too_many_lines)] +async fn run(args: Args) -> Result<()> { if let Some(command) = args.command { match command { Command::Init { name } => { - cli::init_vault_stack(args.vault_stack.or(name), args.region, args.bucket) - .await - .with_context(|| "Failed to init vault stack".red())?; + cli::init_vault_stack( + args.vault_stack.or(name), + args.region, + args.bucket, + args.quiet, + ) + .await + .with_context(|| "Vault stack initialization failed".red())?; } Command::Update { name } => { let vault = Vault::new( @@ -245,12 +272,15 @@ async fn main() -> Result<()> { args.prefix, ) .await - .with_context(|| "Failed to create vault from given params".red())?; + .with_context(|| "Failed to create vault with given parameters".red())?; - cli::update_vault_stack(&vault) + cli::update_vault_stack(&vault, args.quiet) .await .with_context(|| "Failed to update vault stack".red())?; } + Command::Completion { shell, install } => { + cli::generate_shell_completion(shell, Args::command(), install, args.quiet)?; + } Command::Id {} => { cli::print_aws_account(args.region).await?; } @@ -273,7 +303,7 @@ async fn main() -> Result<()> { args.prefix, ) .await - .with_context(|| "Failed to create vault from given params".red())?; + .with_context(|| "Failed to create vault with given parameters".red())?; match command { Command::All {} => cli::list_all_keys(&vault).await?, @@ -291,10 +321,18 @@ async fn main() -> Result<()> { value_argument, outfile, } => cli::encrypt(&vault, value, file, value_argument, outfile).await?, - Command::Exists { key } => cli::exists(&vault, &key).await?, + Command::Exists { key } => { + if !cli::exists(&vault, &key, args.quiet).await? { + drop(vault); + std::process::exit(5); + } + } Command::Info {} => println!("{vault}"), Command::Status {} => { - println!("{}", vault.stack_status().await?); + let status = vault.stack_status().await?; + if !args.quiet { + println!("{status}"); + } } Command::Lookup { key, outfile } => cli::lookup(&vault, &key, outfile).await?, Command::Store { @@ -303,15 +341,44 @@ async fn main() -> Result<()> { overwrite, file, value_argument, - } => cli::store(&vault, key, value, file, value_argument, overwrite).await?, + } => { + cli::store( + &vault, + key, + value, + file, + value_argument, + overwrite, + args.quiet, + ) + .await?; + } // These are here again instead of a `_` so that if new commands are added, // there is an error about missing handling for that. Command::Init { .. } => unreachable!(), Command::Update { .. } => unreachable!(), Command::Id { .. } => unreachable!(), + Command::Completion { .. } => unreachable!(), } } }; } Ok(()) } + +#[tokio::main] +async fn main() -> Result<()> { + let args = Args::parse(); + let quiet = args.quiet; + + // Suppress error output if flag given + if let Err(error) = run(args).await { + if quiet { + std::process::exit(1); + } else { + return Err(error); + } + } + + Ok(()) +}