Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <N>` 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
Expand Down
48 changes: 39 additions & 9 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ struct MoveCommand {

#[derive(Debug, Parser)]
struct GenerateCommand {
entry: String,
entry: Option<String>,

#[arg(
value_name = "LENGTH",
Expand Down Expand Up @@ -252,10 +252,13 @@ struct GenerateCommand {
)]
symbols: Option<String>,

#[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,
}

Expand Down Expand Up @@ -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}");
}
Expand All @@ -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)
{
Expand Down Expand Up @@ -583,10 +597,15 @@ fn generated_secret(command: &GenerateCommand) -> Result<String, CliError> {
.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(())
Expand Down Expand Up @@ -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)]
Expand Down Expand Up @@ -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,

Expand Down Expand Up @@ -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",
Expand Down
104 changes: 104 additions & 0 deletions tests/generate_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
Loading