diff --git a/README.md b/README.md index a7548e6..b73fc0d 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,10 @@ rpass otp example/login # generate an OTP code rpass doctor # check local setup ``` +`generate` writes to the store by default. Use `--dry-run` to print a generated +password or passphrase without opening the store, requiring `.gpg-id`, or calling +GPG. Use `--length ` with `--dry-run` when no entry name is provided. + `insert` prompts for a password and confirmation when run in an interactive terminal. Use `--echo` to show input, `--multiline` to read the full entry until EOF, and `--force` to overwrite an existing entry. In multiline mode, the first diff --git a/src/cli.rs b/src/cli.rs index f06a46f..45bc508 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -151,7 +151,7 @@ struct MoveCommand { #[derive(Debug, Parser)] struct GenerateCommand { - entry: String, + entry: Option, #[arg( value_name = "LENGTH", @@ -252,10 +252,13 @@ struct GenerateCommand { )] symbols: Option, + #[arg(long, help_heading = "Output options")] + dry_run: bool, + #[arg(short = 'f', long, help_heading = "Write options")] force: bool, - #[arg(long, help_heading = "Write options")] + #[arg(long, help_heading = "Output options")] json: bool, } @@ -516,15 +519,22 @@ fn generate_entry( ) -> Result<(), CliError> { validate_generate_command(&command)?; - let store = PasswordStore::open(store_directory)?; - let gpg = GpgCommand::from_environment(); let password = generated_secret(&command)?; - let content = format!("{password}\n"); - InsertEntry::new(&store, &gpg).execute(&command.entry, &content, command.force)?; + if !command.dry_run { + let entry = command + .entry + .as_deref() + .ok_or(CliError::GenerateEntryRequired)?; + let store = PasswordStore::open(store_directory)?; + let gpg = GpgCommand::from_environment(); + let content = format!("{password}\n"); + + InsertEntry::new(&store, &gpg).execute(entry, &content, command.force)?; + } if command.json { - print_json_generate(&command.entry, &password)?; + print_json_generate(command.entry.as_deref(), &password, command.dry_run)?; } else { println!("{password}"); } @@ -533,6 +543,10 @@ fn generate_entry( } fn validate_generate_command(command: &GenerateCommand) -> Result<(), CliError> { + if command.entry.is_none() && !command.dry_run { + return Err(CliError::GenerateEntryRequired); + } + if let Some(length) = command.length.or(command.length_option) && !(1..=max_password_length()).contains(&length) { @@ -583,10 +597,15 @@ fn generated_secret(command: &GenerateCommand) -> Result { .map_err(CliError::PasswordGenerator) } -fn print_json_generate(entry_name: &str, password: &str) -> Result<(), CliError> { +fn print_json_generate( + entry_name: Option<&str>, + password: &str, + dry_run: bool, +) -> Result<(), CliError> { let json = serde_json::to_string_pretty(&GenerateJson { name: entry_name, password, + dry_run, })?; println!("{json}"); Ok(()) @@ -793,8 +812,15 @@ struct MoveJson<'entry> { #[derive(Debug, Serialize)] struct GenerateJson<'entry> { - name: &'entry str, + #[serde(skip_serializing_if = "Option::is_none")] + name: Option<&'entry str>, password: &'entry str, + #[serde(skip_serializing_if = "is_false")] + dry_run: bool, +} + +fn is_false(value: &bool) -> bool { + !value } #[derive(Debug, Serialize)] @@ -845,6 +871,9 @@ pub enum CliError { #[error("passphrase word count must be between {min} and {max}")] InvalidGenerateWordCount { min: usize, max: usize }, + #[error("entry is required unless --dry-run is used")] + GenerateEntryRequired, + #[error("password confirmation did not match")] PasswordConfirmationMismatch, @@ -882,6 +911,7 @@ impl CliError { Self::PasswordGenerator(_) => "password_generation_failed", Self::InvalidGenerateLength { .. } => "invalid_generate_length", Self::InvalidGenerateWordCount { .. } => "invalid_generate_word_count", + Self::GenerateEntryRequired => "generate_entry_required", Self::PasswordConfirmationMismatch => "password_confirmation_mismatch", Self::RemoveConfirmationRequired => "remove_confirmation_required", Self::RemoveAborted => "remove_aborted", diff --git a/tests/generate_command.rs b/tests/generate_command.rs index f15258b..b5e6bcd 100644 --- a/tests/generate_command.rs +++ b/tests/generate_command.rs @@ -193,6 +193,110 @@ fn generates_memorable_passphrase() { assert!(parts[4].chars().all(|character| character.is_ascii_digit())); } +#[test] +fn dry_run_generates_password_without_store_or_gpg() { + let temp_dir = tempfile::TempDir::new().expect("temp dir"); + let missing_store = temp_dir.path().join("missing-store"); + + let assert = rpass() + .args([ + "--store-dir", + missing_store.to_str().expect("store path"), + "generate", + "--dry-run", + "--length", + "20", + ]) + .assert() + .success() + .stderr(""); + let generated = stdout_line(&assert); + + assert_eq!(generated.chars().count(), 20); + assert!(!missing_store.exists()); +} + +#[test] +fn dry_run_generates_passphrase_without_entry() { + let assert = rpass() + .args([ + "generate", + "--dry-run", + "--phrase", + "--words", + "4", + "--separator", + "_", + ]) + .assert() + .success() + .stderr(""); + let generated = stdout_line(&assert); + + assert_eq!(generated.split('_').count(), 4); +} + +#[test] +fn dry_run_json_omits_name_when_entry_is_not_provided() { + let assert = rpass() + .args(["generate", "--dry-run", "--length", "18", "--json"]) + .assert() + .success() + .stderr(""); + let output: Value = serde_json::from_slice(&assert.get_output().stdout).expect("json"); + + assert_eq!( + output["password"] + .as_str() + .expect("password") + .chars() + .count(), + 18 + ); + assert_eq!(output["dry_run"], true); + assert!(output.get("name").is_none()); +} + +#[test] +fn dry_run_json_includes_name_when_entry_is_provided() { + let assert = rpass() + .args([ + "generate", + "example/login", + "--dry-run", + "--length", + "18", + "--json", + ]) + .assert() + .success() + .stderr(""); + let output: Value = serde_json::from_slice(&assert.get_output().stdout).expect("json"); + + assert_eq!(output["name"], "example/login"); + assert_eq!( + output["password"] + .as_str() + .expect("password") + .chars() + .count(), + 18 + ); + assert_eq!(output["dry_run"], true); +} + +#[test] +fn requires_entry_without_dry_run() { + rpass() + .args(["generate", "--length", "18"]) + .assert() + .failure() + .stdout("") + .stderr(predicate::str::contains( + "entry is required unless --dry-run is used", + )); +} + #[test] fn reports_empty_character_set_as_json() { let store = tempfile::TempDir::new().expect("temp dir");