diff --git a/README.md b/README.md index 881c806..40cef66 100644 --- a/README.md +++ b/README.md @@ -70,12 +70,11 @@ The crates.io package is `rpass-cli`; the installed binary is `rpass`. ## Status -`rpass` can list, search, show, generate, insert, edit, remove, move, and run -Git commands for password-store entries using external GnuPG. It also supports -TOTP generation from `otpauth://` lines. +`rpass` can initialize, list, search, show, generate, insert, edit, remove, +move, and run Git commands for password-store entries using external GnuPG. It +also supports TOTP generation from `otpauth://` lines. -Commands such as clipboard support and store initialization are intentionally -not implemented yet. +Commands such as clipboard support are intentionally not implemented yet. ## Commands @@ -84,6 +83,7 @@ rpass -h # show help rpass list # list entries rpass search example # search entries rpass show example/login # show an entry explicitly +rpass init alice@example.com # initialize .gpg-id recipients rpass generate example/login # generate and save a 14-character password rpass insert example/login # insert a password interactively rpass edit example/login # edit or create an entry @@ -95,6 +95,9 @@ rpass otp example/login # generate an OTP code rpass doctor # check local setup ``` +`init` creates the store if needed and writes `.gpg-id` recipients. Use +`--path ` or `-p ` for directory-level recipients. + `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. @@ -154,8 +157,8 @@ Known differences from `pass`: - write support is limited to `generate`, `insert`, `edit`, `rm`, and `mv`; - Git integration is explicit through `rpass git `; -- shell completion, clipboard, QR code, and store initialization are not - implemented; +- changing recipients with `init` does not re-encrypt existing entries yet; +- shell completion, clipboard, and QR code are not implemented; - unsupported `pass` flags are rejected instead of ignored; - JSON output is an `rpass` integration contract, not part of the original `pass` CLI. diff --git a/TODO.md b/TODO.md index 914ca02..deeecd2 100644 --- a/TODO.md +++ b/TODO.md @@ -131,7 +131,7 @@ Goal: provide explicit, predictable Git commands for password-store workflows. Goal: support creation and maintenance of password-store compatible stores. -- [ ] `rpass init ` +- [x] `rpass init ` - [ ] `rpass recipients` - [ ] `rpass recipients add ` - [ ] `rpass recipients remove ` diff --git a/src/cli.rs b/src/cli.rs index 63357b0..6f422c3 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -13,8 +13,9 @@ use crate::password_generator::{ max_passphrase_words, max_password_length, }; use crate::password_store::{ - DecryptedEntry, DoctorReport, EditEntry, GitCommand, GpgCommand, InsertEntry, ListEntries, - MoveEntry, OtpCode, PasswordStore, RemoveEntry, SearchEntries, ShowEntry, StoreDirectory, + DecryptedEntry, DoctorReport, EditEntry, GitCommand, GpgCommand, InitStore, InitStoreResult, + InsertEntry, ListEntries, MoveEntry, OtpCode, PasswordStore, RemoveEntry, SearchEntries, + ShowEntry, StoreDirectory, }; use tree_output::EntryTree; @@ -59,6 +60,9 @@ enum Command { #[command(about = "Show a password store entry")] Show(ShowCommand), + #[command(about = "Initialize a password store or subfolder")] + Init(InitCommand), + #[command(about = "Insert a password store entry")] Insert(InsertCommand), @@ -104,6 +108,18 @@ struct ShowCommand { json: bool, } +#[derive(Debug, Parser)] +struct InitCommand { + #[arg(short = 'p', long = "path", value_name = "SUBFOLDER")] + path: Option, + + #[arg(long)] + json: bool, + + #[arg(value_name = "GPG-ID", required = true)] + gpg_ids: Vec, +} + #[derive(Debug, Parser)] struct InsertCommand { entry: String, @@ -307,6 +323,7 @@ pub fn run() -> Result<(), CliError> { let result = match cli.command { Some(Command::List(command)) => list_entries(command, store_directory), Some(Command::Show(command)) => show_entry(command, store_directory), + Some(Command::Init(command)) => init_store(command, store_directory), Some(Command::Insert(command)) => insert_entry(command, store_directory), Some(Command::Edit(command)) => edit_entry(command, store_directory), Some(Command::Remove(command)) => remove_entry(command, store_directory), @@ -355,6 +372,7 @@ impl Command { match self { Self::List(command) => command.json, Self::Show(command) => command.json, + Self::Init(command) => command.json, Self::Insert(command) => command.json, Self::Edit(command) => command.json, Self::Remove(command) => command.json, @@ -392,6 +410,84 @@ fn print_json_entries(entries: &[String]) -> Result<(), CliError> { Ok(()) } +fn init_store(command: InitCommand, store_directory: StoreDirectory) -> Result<(), CliError> { + validate_init_path(command.path.as_deref())?; + + let result = InitStore::new(store_directory.clone()) + .execute(command.path.as_deref(), &command.gpg_ids)?; + let store = PasswordStore::open(store_directory)?; + auto_commit(&store, &init_commit_message(&command, &result))?; + + if command.json { + print_json_init(&result)?; + } else if result.removed { + print_init_removed(&command); + } else { + print_init_success(&command); + } + + Ok(()) +} + +fn validate_init_path(path: Option<&str>) -> Result<(), CliError> { + let Some(path) = path else { + return Ok(()); + }; + + if path.is_empty() + || path.contains('\\') + || path.split('/').any(|segment| segment.is_empty()) + || path.split('/').any(|segment| matches!(segment, "." | "..")) + { + return Err(CliError::InvalidInitPath(path.to_owned())); + } + + Ok(()) +} + +fn init_commit_message(command: &InitCommand, result: &InitStoreResult) -> String { + if result.removed { + match command.path.as_deref() { + Some(path) => format!("Removed GPG id from {path}."), + None => "Removed GPG id from store.".to_string(), + } + } else { + format!("Set GPG id to {}.", command.gpg_ids.join(", ")) + } +} + +fn print_json_init(result: &InitStoreResult) -> Result<(), CliError> { + let json = serde_json::to_string_pretty(&InitJson { + path: &normalize_path(&result.gpg_id_path), + recipients: &result.recipients, + removed: result.removed, + })?; + println!("{json}"); + Ok(()) +} + +fn print_init_success(command: &InitCommand) { + let recipients = command.gpg_ids.join(", "); + match command.path.as_deref() { + Some(path) => println!("Password store initialized for {recipients} ({path})"), + None => println!("Password store initialized for {recipients}"), + } +} + +fn print_init_removed(command: &InitCommand) { + match command.path.as_deref() { + Some(path) => println!("Password store recipients removed ({path})"), + None => println!("Password store recipients removed"), + } +} + +fn normalize_path(path: &std::path::Path) -> String { + path.components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>() + .join("/") +} + fn search_entries(command: SearchCommand, store_directory: StoreDirectory) -> Result<(), CliError> { let store = PasswordStore::open(store_directory)?; let entries = SearchEntries::new(&store).execute(&command.query)?; @@ -848,6 +944,13 @@ struct ShowEntryJson<'entry> { extra_lines: &'entry [String], } +#[derive(Debug, Serialize)] +struct InitJson<'entry> { + path: &'entry str, + recipients: &'entry [String], + removed: bool, +} + #[derive(Debug, Serialize)] struct InsertJson<'entry> { name: &'entry str, @@ -920,6 +1023,9 @@ pub enum CliError { #[error("passphrase word count must be between {min} and {max}")] InvalidGenerateWordCount { min: usize, max: usize }, + #[error("invalid init path '{0}'")] + InvalidInitPath(String), + #[error("entry is required unless --dry-run is used")] GenerateEntryRequired, @@ -960,6 +1066,7 @@ impl CliError { Self::PasswordGenerator(_) => "password_generation_failed", Self::InvalidGenerateLength { .. } => "invalid_generate_length", Self::InvalidGenerateWordCount { .. } => "invalid_generate_word_count", + Self::InvalidInitPath(_) => "invalid_init_path", Self::GenerateEntryRequired => "generate_entry_required", Self::PasswordConfirmationMismatch => "password_confirmation_mismatch", Self::RemoveConfirmationRequired => "remove_confirmation_required", diff --git a/src/password_store/init_store.rs b/src/password_store/init_store.rs new file mode 100644 index 0000000..adecfd5 --- /dev/null +++ b/src/password_store/init_store.rs @@ -0,0 +1,67 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use super::{PasswordStoreError, StoreDirectory}; + +#[derive(Debug)] +pub struct InitStoreResult { + pub gpg_id_path: PathBuf, + pub recipients: Vec, + pub removed: bool, +} + +pub struct InitStore { + store_directory: StoreDirectory, +} + +impl InitStore { + pub fn new(store_directory: StoreDirectory) -> Self { + Self { store_directory } + } + + pub fn execute( + &self, + subfolder: Option<&str>, + recipients: &[String], + ) -> Result { + let store_root = self.store_directory.path(); + fs::create_dir_all(store_root)?; + + let target_directory = match subfolder { + Some(subfolder) => store_root.join(subfolder), + None => store_root.to_path_buf(), + }; + fs::create_dir_all(&target_directory)?; + + let gpg_id_path = target_directory.join(".gpg-id"); + let removed = recipients.len() == 1 && recipients.first().is_some_and(String::is_empty); + + if removed { + remove_gpg_id_if_present(&gpg_id_path)?; + } else { + fs::write(&gpg_id_path, recipients.join("\n") + "\n")?; + } + + Ok(InitStoreResult { + gpg_id_path: relative_path(store_root, &gpg_id_path), + recipients: if removed { + Vec::new() + } else { + recipients.to_vec() + }, + removed, + }) + } +} + +fn remove_gpg_id_if_present(path: &Path) -> Result<(), PasswordStoreError> { + match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } +} + +fn relative_path(root: &Path, path: &Path) -> PathBuf { + path.strip_prefix(root).unwrap_or(path).to_path_buf() +} diff --git a/src/password_store/mod.rs b/src/password_store/mod.rs index 583f0e1..34b78af 100644 --- a/src/password_store/mod.rs +++ b/src/password_store/mod.rs @@ -4,6 +4,7 @@ mod edit_entry; mod entry_name; mod git; mod gpg; +mod init_store; mod insert_entry; mod list_entries; mod move_entry; @@ -19,6 +20,7 @@ pub use edit_entry::EditEntry; pub use entry_name::EntryName; pub use git::GitCommand; pub use gpg::GpgCommand; +pub use init_store::{InitStore, InitStoreResult}; pub use insert_entry::InsertEntry; pub use list_entries::ListEntries; pub use move_entry::MoveEntry; diff --git a/tests/init_command.rs b/tests/init_command.rs new file mode 100644 index 0000000..ca69c20 --- /dev/null +++ b/tests/init_command.rs @@ -0,0 +1,224 @@ +mod support; + +use std::fs; +use std::path::Path; + +use predicates::prelude::*; +use serde_json::Value; + +use support::rpass; + +#[test] +fn init_creates_missing_store_and_writes_gpg_id() { + let parent = tempfile::TempDir::new().expect("temp dir"); + let store = parent.path().join(".password-store"); + + rpass() + .args([ + "--store-dir", + store.to_str().expect("store path"), + "init", + "alice@example.invalid", + ]) + .assert() + .success() + .stdout("Password store initialized for alice@example.invalid\n") + .stderr(""); + + assert_eq!( + fs::read_to_string(store.join(".gpg-id")).expect("gpg id"), + "alice@example.invalid\n" + ); +} + +#[test] +fn init_accepts_multiple_gpg_ids() { + let store = tempfile::TempDir::new().expect("temp dir"); + + rpass() + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "init", + "alice@example.invalid", + "bob@example.invalid", + ]) + .assert() + .success() + .stdout("Password store initialized for alice@example.invalid, bob@example.invalid\n") + .stderr(""); + + assert_eq!( + fs::read_to_string(store.path().join(".gpg-id")).expect("gpg id"), + "alice@example.invalid\nbob@example.invalid\n" + ); +} + +#[test] +fn init_path_writes_gpg_id_for_subfolder() { + let store = tempfile::TempDir::new().expect("temp dir"); + + rpass() + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "init", + "--path", + "team/work", + "team@example.invalid", + ]) + .assert() + .success() + .stdout("Password store initialized for team@example.invalid (team/work)\n") + .stderr(""); + + assert_eq!( + fs::read_to_string(store.path().join("team/work/.gpg-id")).expect("gpg id"), + "team@example.invalid\n" + ); +} + +#[test] +fn init_short_path_flag_writes_gpg_id_for_subfolder() { + let store = tempfile::TempDir::new().expect("temp dir"); + + rpass() + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "init", + "-p", + "team", + "team@example.invalid", + ]) + .assert() + .success(); + + assert_eq!( + fs::read_to_string(store.path().join("team/.gpg-id")).expect("gpg id"), + "team@example.invalid\n" + ); +} + +#[test] +fn init_with_empty_gpg_id_removes_existing_gpg_id_for_path() { + let store = tempfile::TempDir::new().expect("temp dir"); + write_file(store.path().join("team/.gpg-id"), "team@example.invalid\n"); + + rpass() + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "init", + "--path", + "team", + "", + ]) + .assert() + .success() + .stdout("Password store recipients removed (team)\n") + .stderr(""); + + assert!(!store.path().join("team/.gpg-id").exists()); +} + +#[test] +fn init_reports_success_as_json() { + let store = tempfile::TempDir::new().expect("temp dir"); + + let assert = rpass() + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "init", + "--json", + "alice@example.invalid", + "bob@example.invalid", + ]) + .assert() + .success() + .stderr(""); + let output: Value = serde_json::from_slice(&assert.get_output().stdout).expect("json"); + + assert_eq!(output["path"], ".gpg-id"); + assert_eq!(output["recipients"][0], "alice@example.invalid"); + assert_eq!(output["recipients"][1], "bob@example.invalid"); + assert_eq!(output["removed"], false); +} + +#[test] +fn init_rejects_path_traversal_subfolder_as_json() { + let store = tempfile::TempDir::new().expect("temp dir"); + + rpass() + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "init", + "--json", + "--path", + "../outside", + "alice@example.invalid", + ]) + .assert() + .failure() + .stdout("") + .stderr(predicate::str::contains("\"code\": \"invalid_init_path\"")); +} + +#[test] +fn init_auto_commits_when_store_is_git_repository() { + let store = tempfile::TempDir::new().expect("temp dir"); + git(store.path(), ["init"]); + git(store.path(), ["config", "user.name", "rpass tests"]); + git( + store.path(), + ["config", "user.email", "rpass-tests@example.invalid"], + ); + + rpass() + .env("GIT_AUTHOR_NAME", "rpass tests") + .env("GIT_AUTHOR_EMAIL", "rpass-tests@example.invalid") + .env("GIT_COMMITTER_NAME", "rpass tests") + .env("GIT_COMMITTER_EMAIL", "rpass-tests@example.invalid") + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "init", + "alice@example.invalid", + ]) + .assert() + .success(); + + assert_eq!( + git_output(store.path(), ["log", "-1", "--pretty=%s"]).trim_end_matches(['\r', '\n']), + "Set GPG id to alice@example.invalid." + ); +} + +fn git(path: &Path, args: [&str; N]) { + let status = std::process::Command::new("git") + .arg("-C") + .arg(path) + .args(args) + .status() + .expect("git command"); + assert!(status.success()); +} + +fn git_output(path: &Path, args: [&str; N]) -> String { + let output = std::process::Command::new("git") + .arg("-C") + .arg(path) + .args(args) + .output() + .expect("git command"); + assert!(output.status.success()); + String::from_utf8(output.stdout).expect("git stdout") +} + +fn write_file(path: impl AsRef, content: &str) { + let path = path.as_ref(); + fs::create_dir_all(path.parent().expect("parent")).expect("parent dir"); + fs::write(path, content).expect("file"); +}