From 0cd3d0674c6e7357d90c571f8020d62eb0a8afdc Mon Sep 17 00:00:00 2001 From: Cristhian Melo Date: Mon, 20 Jul 2026 11:21:37 -0500 Subject: [PATCH 1/6] feat(password_store): re-encrypt entries when recipients change on init and recipients commands --- src/cli.rs | 25 ++- src/password_store/mod.rs | 2 + src/password_store/re_encrypt_entries.rs | 186 +++++++++++++++++++++++ 3 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 src/password_store/re_encrypt_entries.rs diff --git a/src/cli.rs b/src/cli.rs index 45aa35d..89f10ed 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -18,7 +18,8 @@ use crate::password_store::importer::bitwarden::BitwardenImporter; use crate::password_store::{ DecryptedEntry, DoctorReport, EditEntry, GitCommand, GpgCommand, ImportEntries, ImportResult, InitStore, InitStoreResult, InsertEntry, ListEntries, MoveEntry, OtpCode, PasswordStore, - Recipients, RecipientsResult, RemoveEntry, SearchEntries, ShowEntry, StoreDirectory, + ReEncryptEntries, Recipients, RecipientsResult, RemoveEntry, SearchEntries, ShowEntry, + StoreDirectory, }; use tree_output::EntryTree; @@ -496,6 +497,16 @@ fn init_store(command: InitCommand, store_directory: StoreDirectory) -> Result<( let result = InitStore::new(store_directory.clone()) .execute(command.path.as_deref(), &command.gpg_ids)?; let store = PasswordStore::open(store_directory)?; + + if !result.removed && !result.recipients.is_empty() { + let gpg = GpgCommand::from_environment(); + ReEncryptEntries::new(&store, gpg).execute( + command.path.as_deref(), + &result.recipients, + None, + )?; + } + auto_commit(&store, &init_commit_message(&command, &result))?; if command.json { @@ -577,12 +588,24 @@ fn recipients(command: RecipientsCommand, store_directory: StoreDirectory) -> Re Some(RecipientsAction::Add { key_id }) => { let result = recipients.add(command.path.as_deref(), key_id)?; if result.changed { + let gpg = GpgCommand::from_environment(); + ReEncryptEntries::new(&store, gpg).execute( + command.path.as_deref(), + &result.recipients, + None, + )?; auto_commit(&store, &format!("Added GPG id {key_id}."))?; } result } Some(RecipientsAction::Remove { key_id }) => { let result = recipients.remove(command.path.as_deref(), key_id)?; + let gpg = GpgCommand::from_environment(); + ReEncryptEntries::new(&store, gpg).execute( + command.path.as_deref(), + &result.recipients, + None, + )?; auto_commit(&store, &format!("Removed GPG id {key_id}."))?; result } diff --git a/src/password_store/mod.rs b/src/password_store/mod.rs index 49373f3..0ddb49d 100644 --- a/src/password_store/mod.rs +++ b/src/password_store/mod.rs @@ -11,6 +11,7 @@ mod insert_entry; mod list_entries; mod move_entry; mod otp; +mod re_encrypt_entries; mod recipients; mod remove_entry; mod search_entries; @@ -29,6 +30,7 @@ pub use insert_entry::InsertEntry; pub use list_entries::ListEntries; pub use move_entry::MoveEntry; pub use otp::OtpCode; +pub use re_encrypt_entries::ReEncryptEntries; pub use recipients::{Recipients, RecipientsResult}; pub use remove_entry::RemoveEntry; pub use search_entries::SearchEntries; diff --git a/src/password_store/re_encrypt_entries.rs b/src/password_store/re_encrypt_entries.rs new file mode 100644 index 0000000..25a4dd8 --- /dev/null +++ b/src/password_store/re_encrypt_entries.rs @@ -0,0 +1,186 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use super::{GpgCommand, PasswordStore, PasswordStoreError}; + +pub struct ReEncryptEntries<'store> { + store: &'store PasswordStore, + gpg: GpgCommand, +} + +impl<'store> ReEncryptEntries<'store> { + pub fn new(store: &'store PasswordStore, gpg: GpgCommand) -> Self { + Self { store, gpg } + } + + pub fn execute( + &self, + subfolder: Option<&str>, + recipients: &[String], + passphrase: Option<&str>, + ) -> Result, PasswordStoreError> { + if recipients.is_empty() { + return Ok(Vec::new()); + } + + let target_dir = match subfolder { + Some(sub) => self.store.path().join(sub), + None => self.store.path().to_path_buf(), + }; + + let mut reencrypted = Vec::new(); + reencrypt_dir(&self.gpg, &target_dir, &target_dir, recipients, passphrase, &mut reencrypted)?; + Ok(reencrypted) + } +} + +fn reencrypt_dir( + gpg: &GpgCommand, + dir: &Path, + target_dir: &Path, + recipients: &[String], + passphrase: Option<&str>, + reencrypted: &mut Vec, +) -> Result<(), PasswordStoreError> { + let entries = match fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return Ok(()), + }; + + for entry in entries.flatten() { + let path = entry.path(); + + if path.is_dir() { + if path.join(".gpg-id").exists() { + // This subdir manages its own recipients — skip it entirely + continue; + } + reencrypt_dir(gpg, &path, target_dir, recipients, passphrase, reencrypted)?; + } else if path.extension().map_or(false, |ext| ext == "gpg") { + let plaintext = gpg.decrypt(&path, passphrase)?; + gpg.encrypt(&plaintext, &path, recipients)?; + reencrypted.push(path); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::fs; + + use tempfile::TempDir; + + use super::ReEncryptEntries; + use crate::password_store::{GpgCommand, PasswordStore, StoreDirectory}; + + fn store_with_files(files: &[(&str, &str)]) -> TempDir { + let dir = TempDir::new().expect("temp dir"); + for (path, content) in files { + let full_path = dir.path().join(path); + fs::create_dir_all(full_path.parent().expect("parent")).expect("create dir"); + fs::write(&full_path, content).expect("write file"); + } + dir + } + + #[cfg(not(windows))] + fn passthrough_gpg_script(dir: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) { + use std::os::unix::fs::PermissionsExt; + + let script = dir.join("gpg-test"); + let log = dir.join("gpg-test-log.txt"); + + fs::write( + &script, + format!( + r#"#!/bin/sh +set -eu +log='{log}' +mode='encrypt' +output='' +input_file='' +while [ "$#" -gt 0 ]; do + case "$1" in + --decrypt) mode='decrypt'; shift ;; + --recipient) printf 'recipient:%s\n' "$2" >> "$log"; shift 2 ;; + --output) output="$2"; shift 2 ;; + -*) shift ;; + *) input_file="$1"; shift ;; + esac +done +if [ "$mode" = "decrypt" ]; then cat "$input_file"; exit 0; fi +printf 'encrypt\n' >> "$log" +cat > "$output" +"#, + log = log.display() + ), + ) + .expect("write script"); + + let mut perms = fs::metadata(&script).expect("metadata").permissions(); + perms.set_mode(0o755); + fs::set_permissions(&script, perms).expect("set permissions"); + + (script, log) + } + + #[test] + #[cfg(not(windows))] + fn re_encrypts_gpg_files_in_store() { + let temp = store_with_files(&[ + (".gpg-id", "alice\n"), + ("entry.gpg", "secret\n"), + ]); + let (script, log) = passthrough_gpg_script(temp.path()); + let store = PasswordStore::open(StoreDirectory::from_path(temp.path())).expect("store"); + let gpg = GpgCommand::new(script); + + let result = ReEncryptEntries::new(&store, gpg) + .execute(None, &["alice".to_string()], None) + .expect("re-encrypt"); + + assert_eq!(result.len(), 1); + let log_content = fs::read_to_string(log).expect("log"); + assert!(log_content.contains("recipient:alice")); + assert_eq!(fs::read_to_string(temp.path().join("entry.gpg")).expect("entry"), "secret\n"); + } + + #[test] + #[cfg(not(windows))] + fn skips_entries_in_subdir_with_own_gpg_id() { + let temp = store_with_files(&[ + (".gpg-id", "alice\n"), + ("team/.gpg-id", "team\n"), + ("entry.gpg", "root\n"), + ("team/entry.gpg", "team-secret\n"), + ]); + let (script, log) = passthrough_gpg_script(temp.path()); + let store = PasswordStore::open(StoreDirectory::from_path(temp.path())).expect("store"); + let gpg = GpgCommand::new(script); + + let result = ReEncryptEntries::new(&store, gpg) + .execute(None, &["alice".to_string()], None) + .expect("re-encrypt"); + + assert_eq!(result.len(), 1, "only root entry should be re-encrypted"); + let log_content = fs::read_to_string(log).expect("log"); + assert_eq!(log_content.lines().filter(|l| *l == "encrypt").count(), 1); + } + + #[test] + #[cfg(not(windows))] + fn returns_empty_when_no_gpg_files() { + let temp = store_with_files(&[(".gpg-id", "alice\n")]); + let (script, _) = passthrough_gpg_script(temp.path()); + let store = PasswordStore::open(StoreDirectory::from_path(temp.path())).expect("store"); + let gpg = GpgCommand::new(script); + + let result = ReEncryptEntries::new(&store, gpg) + .execute(None, &["alice".to_string()], None) + .expect("re-encrypt"); + + assert!(result.is_empty()); + } +} From b50b2fcfe4a3750d0f798355f81912117eed9d89 Mon Sep 17 00:00:00 2001 From: Cristhian Melo Date: Mon, 20 Jul 2026 11:21:45 -0500 Subject: [PATCH 2/6] test: add integration tests for re-encryption on init and recipients add/remove --- tests/init_command.rs | 130 +++++++++++++++++++++++++++++++++++- tests/recipients_command.rs | 99 ++++++++++++++++++++++++++- tests/support/mod.rs | 111 ++++++++++++++++++++++++++++++ 3 files changed, 338 insertions(+), 2 deletions(-) diff --git a/tests/init_command.rs b/tests/init_command.rs index ca69c20..abc5b5c 100644 --- a/tests/init_command.rs +++ b/tests/init_command.rs @@ -6,7 +6,7 @@ use std::path::Path; use predicates::prelude::*; use serde_json::Value; -use support::rpass; +use support::{reencrypting_gpg_script, rpass}; #[test] fn init_creates_missing_store_and_writes_gpg_id() { @@ -196,6 +196,134 @@ fn init_auto_commits_when_store_is_git_repository() { ); } +#[test] +fn init_re_encrypts_existing_entries_with_new_recipients() { + let store = tempfile::TempDir::new().expect("temp dir"); + write_file(store.path().join(".gpg-id"), "old@example.invalid\n"); + write_file(store.path().join("entry.gpg"), "secret\n"); + write_file(store.path().join("subdir/nested.gpg"), "nested-secret\n"); + + let (gpg, log_file) = reencrypting_gpg_script(store.path()); + + rpass() + .env("PASSWORD_STORE_GPG", &gpg) + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "init", + "new@example.invalid", + ]) + .assert() + .success() + .stdout("Password store initialized for new@example.invalid\n") + .stderr(""); + + let log = fs::read_to_string(&log_file).expect("log file"); + assert!( + log.contains("recipient:new@example.invalid"), + "expected new recipient in log, got: {log}" + ); + let encrypt_count = log.lines().filter(|l| *l == "encrypt").count(); + assert_eq!(encrypt_count, 2, "expected 2 entries re-encrypted, got: {encrypt_count}"); + + assert_eq!( + fs::read_to_string(store.path().join("entry.gpg")).expect("entry"), + "secret\n", + "content should be preserved after re-encryption" + ); + assert_eq!( + fs::read_to_string(store.path().join("subdir/nested.gpg")).expect("nested entry"), + "nested-secret\n", + "nested content should be preserved after re-encryption" + ); +} + +#[test] +fn init_skips_entries_in_subdirectory_with_own_gpg_id() { + let store = tempfile::TempDir::new().expect("temp dir"); + write_file(store.path().join(".gpg-id"), "old@example.invalid\n"); + write_file(store.path().join("team/.gpg-id"), "team@example.invalid\n"); + write_file(store.path().join("entry.gpg"), "root-secret\n"); + write_file(store.path().join("team/entry.gpg"), "team-secret\n"); + + let (gpg, log_file) = reencrypting_gpg_script(store.path()); + + rpass() + .env("PASSWORD_STORE_GPG", &gpg) + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "init", + "new@example.invalid", + ]) + .assert() + .success(); + + let log = fs::read_to_string(&log_file).expect("log file"); + let encrypt_count = log.lines().filter(|l| *l == "encrypt").count(); + assert_eq!( + encrypt_count, 1, + "only the root entry should be re-encrypted, not the team entry with its own .gpg-id; log: {log}" + ); +} + +#[test] +fn init_re_encrypts_only_entries_in_target_subfolder() { + let store = tempfile::TempDir::new().expect("temp dir"); + write_file(store.path().join(".gpg-id"), "root@example.invalid\n"); + write_file(store.path().join("team/.gpg-id"), "old-team@example.invalid\n"); + write_file(store.path().join("root-entry.gpg"), "root-secret\n"); + write_file(store.path().join("team/entry.gpg"), "team-secret\n"); + + let (gpg, log_file) = reencrypting_gpg_script(store.path()); + + rpass() + .env("PASSWORD_STORE_GPG", &gpg) + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "init", + "--path", + "team", + "new-team@example.invalid", + ]) + .assert() + .success(); + + let log = fs::read_to_string(&log_file).expect("log file"); + let encrypt_count = log.lines().filter(|l| *l == "encrypt").count(); + assert_eq!( + encrypt_count, 1, + "only team/entry.gpg should be re-encrypted; log: {log}" + ); + assert!( + log.contains("recipient:new-team@example.invalid"), + "new team recipient should be used; log: {log}" + ); +} + +#[test] +fn init_skips_re_encryption_when_store_has_no_entries() { + let store = tempfile::TempDir::new().expect("temp dir"); + + let (gpg, log_file) = reencrypting_gpg_script(store.path()); + + rpass() + .env("PASSWORD_STORE_GPG", &gpg) + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "init", + "alice@example.invalid", + ]) + .assert() + .success() + .stdout("Password store initialized for alice@example.invalid\n"); + + let log = fs::read_to_string(&log_file).unwrap_or_default(); + assert!(log.is_empty(), "no re-encryption should happen with empty store; log: {log}"); +} + fn git(path: &Path, args: [&str; N]) { let status = std::process::Command::new("git") .arg("-C") diff --git a/tests/recipients_command.rs b/tests/recipients_command.rs index cbfaf50..1f31d24 100644 --- a/tests/recipients_command.rs +++ b/tests/recipients_command.rs @@ -6,7 +6,7 @@ use std::path::Path; use predicates::prelude::*; use serde_json::Value; -use support::rpass; +use support::{reencrypting_gpg_script, rpass}; #[test] fn lists_root_recipients() { @@ -246,6 +246,103 @@ fn add_auto_commits_when_store_is_git_repository() { ); } +#[test] +fn add_re_encrypts_existing_entries_with_new_recipient() { + let store = tempfile::TempDir::new().expect("temp dir"); + write_file(store.path().join(".gpg-id"), "alice@example.invalid\n"); + write_file(store.path().join("entry.gpg"), "secret\n"); + + let (gpg, log_file) = reencrypting_gpg_script(store.path()); + + rpass() + .env("PASSWORD_STORE_GPG", &gpg) + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "recipients", + "add", + "bob@example.invalid", + ]) + .assert() + .success() + .stdout("Recipient 'bob@example.invalid' added\n") + .stderr(""); + + let log = fs::read_to_string(&log_file).expect("log file"); + assert!( + log.contains("recipient:alice@example.invalid"), + "alice should still be a recipient; log: {log}" + ); + assert!( + log.contains("recipient:bob@example.invalid"), + "bob should be a recipient after add; log: {log}" + ); + let encrypt_count = log.lines().filter(|l| *l == "encrypt").count(); + assert_eq!(encrypt_count, 1, "one entry should be re-encrypted; log: {log}"); +} + +#[test] +fn remove_re_encrypts_existing_entries_without_removed_recipient() { + let store = tempfile::TempDir::new().expect("temp dir"); + write_file( + store.path().join(".gpg-id"), + "alice@example.invalid\nbob@example.invalid\n", + ); + write_file(store.path().join("entry.gpg"), "secret\n"); + + let (gpg, log_file) = reencrypting_gpg_script(store.path()); + + rpass() + .env("PASSWORD_STORE_GPG", &gpg) + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "recipients", + "remove", + "bob@example.invalid", + ]) + .assert() + .success() + .stdout("Recipient 'bob@example.invalid' removed\n") + .stderr(""); + + let log = fs::read_to_string(&log_file).expect("log file"); + assert!( + log.contains("recipient:alice@example.invalid"), + "alice should still be a recipient; log: {log}" + ); + assert!( + !log.contains("recipient:bob@example.invalid"), + "bob should NOT be a recipient after remove; log: {log}" + ); + let encrypt_count = log.lines().filter(|l| *l == "encrypt").count(); + assert_eq!(encrypt_count, 1, "one entry should be re-encrypted; log: {log}"); +} + +#[test] +fn add_skips_re_encryption_when_recipient_already_present() { + let store = tempfile::TempDir::new().expect("temp dir"); + write_file(store.path().join(".gpg-id"), "alice@example.invalid\n"); + write_file(store.path().join("entry.gpg"), "secret\n"); + + let (gpg, log_file) = reencrypting_gpg_script(store.path()); + + rpass() + .env("PASSWORD_STORE_GPG", &gpg) + .args([ + "--store-dir", + store.path().to_str().expect("store path"), + "recipients", + "add", + "alice@example.invalid", + ]) + .assert() + .success(); + + let log = fs::read_to_string(&log_file).unwrap_or_default(); + assert!(log.is_empty(), "no re-encryption when recipient already present; log: {log}"); +} + fn git(path: &Path, args: [&str; N]) { let status = std::process::Command::new("git") .arg("-C") diff --git a/tests/support/mod.rs b/tests/support/mod.rs index 313a33e..02c4e5e 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -497,6 +497,117 @@ pub fn failing_gpg_script(directory: &Path, message: &str) -> PathBuf { script } +#[cfg(windows)] +#[allow(dead_code)] +pub fn reencrypting_gpg_script(directory: &Path) -> (PathBuf, PathBuf) { + let script = directory.join("gpg-reencrypt.cmd"); + let log_file = directory.join("gpg-reencrypt-log.txt"); + + fs::write( + &script, + format!( + r#"@echo off +setlocal enabledelayedexpansion +set log_file={log} +set mode=encrypt +set output= +set input_file= +:args +if "%~1"=="" goto run +if "%~1"=="--decrypt" ( + set mode=decrypt + shift + goto args +) +if "%~1"=="--recipient" ( + echo recipient:%~2>> "%log_file%" + shift + shift + goto args +) +if "%~1"=="--output" ( + set output=%~2 + shift + shift + goto args +) +set arg=%~1 +if "!arg:~0,1!"=="-" ( + shift + goto args +) +set input_file=%~1 +shift +goto args +:run +if "%mode%"=="decrypt" ( + type "%input_file%" + exit /b 0 +) +echo encrypt>> "%log_file%" +findstr /r ".*" > "%output%" +exit /b 0 +"#, + log = log_file.display() + ), + ) + .expect("script"); + (script, log_file) +} + +#[cfg(not(windows))] +#[allow(dead_code)] +pub fn reencrypting_gpg_script(directory: &Path) -> (PathBuf, PathBuf) { + let script = directory.join("gpg-reencrypt"); + let log_file = directory.join("gpg-reencrypt-log.txt"); + + fs::write( + &script, + format!( + r#"#!/bin/sh +set -eu +log_file='{log}' +mode='encrypt' +output='' +input_file='' +while [ "$#" -gt 0 ]; do + case "$1" in + --decrypt) + mode='decrypt' + shift + ;; + --recipient) + printf 'recipient:%s\n' "$2" >> "$log_file" + shift 2 + ;; + --output) + output="$2" + shift 2 + ;; + -*) + shift + ;; + *) + input_file="$1" + shift + ;; + esac +done +if [ "$mode" = "decrypt" ]; then + cat "$input_file" + exit 0 +fi +printf 'encrypt\n' >> "$log_file" +cat > "$output" +"#, + log = log_file.display() + ), + ) + .expect("script"); + make_executable(&script); + (script, log_file) +} + #[cfg(not(windows))] fn make_executable(path: &Path) { use std::os::unix::fs::PermissionsExt; From 61c9ba9f85a41c072fd6dc28f3f65416e2a8b40c Mon Sep 17 00:00:00 2001 From: Cristhian Melo Date: Mon, 20 Jul 2026 11:21:51 -0500 Subject: [PATCH 3/6] docs: remove outdated known-difference about init not re-encrypting --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index b73fc3a..1d8722d 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,6 @@ rpass completions powershell >> $PROFILE **Known differences from `pass`:** - `generate`, `insert`, `edit`, `rm`, and `mv` for writes - Git is explicit (`rpass git `) rather than automatic -- Changing recipients with `init` does not re-encrypt existing entries - Clipboard and QR codes are not implemented - Unsupported `pass` flags are rejected instead of ignored From a322d9e288da75f544d9050df558d9f61188d5bf Mon Sep 17 00:00:00 2001 From: Cristhian Melo Date: Mon, 20 Jul 2026 11:32:45 -0500 Subject: [PATCH 4/6] chore(fmt): format code --- src/password_store/re_encrypt_entries.rs | 19 +++++++++++++------ tests/init_command.rs | 15 ++++++++++++--- tests/recipients_command.rs | 15 ++++++++++++--- 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/src/password_store/re_encrypt_entries.rs b/src/password_store/re_encrypt_entries.rs index 25a4dd8..3651316 100644 --- a/src/password_store/re_encrypt_entries.rs +++ b/src/password_store/re_encrypt_entries.rs @@ -29,7 +29,14 @@ impl<'store> ReEncryptEntries<'store> { }; let mut reencrypted = Vec::new(); - reencrypt_dir(&self.gpg, &target_dir, &target_dir, recipients, passphrase, &mut reencrypted)?; + reencrypt_dir( + &self.gpg, + &target_dir, + &target_dir, + recipients, + passphrase, + &mut reencrypted, + )?; Ok(reencrypted) } } @@ -129,10 +136,7 @@ cat > "$output" #[test] #[cfg(not(windows))] fn re_encrypts_gpg_files_in_store() { - let temp = store_with_files(&[ - (".gpg-id", "alice\n"), - ("entry.gpg", "secret\n"), - ]); + let temp = store_with_files(&[(".gpg-id", "alice\n"), ("entry.gpg", "secret\n")]); let (script, log) = passthrough_gpg_script(temp.path()); let store = PasswordStore::open(StoreDirectory::from_path(temp.path())).expect("store"); let gpg = GpgCommand::new(script); @@ -144,7 +148,10 @@ cat > "$output" assert_eq!(result.len(), 1); let log_content = fs::read_to_string(log).expect("log"); assert!(log_content.contains("recipient:alice")); - assert_eq!(fs::read_to_string(temp.path().join("entry.gpg")).expect("entry"), "secret\n"); + assert_eq!( + fs::read_to_string(temp.path().join("entry.gpg")).expect("entry"), + "secret\n" + ); } #[test] diff --git a/tests/init_command.rs b/tests/init_command.rs index abc5b5c..f4fe1d9 100644 --- a/tests/init_command.rs +++ b/tests/init_command.rs @@ -224,7 +224,10 @@ fn init_re_encrypts_existing_entries_with_new_recipients() { "expected new recipient in log, got: {log}" ); let encrypt_count = log.lines().filter(|l| *l == "encrypt").count(); - assert_eq!(encrypt_count, 2, "expected 2 entries re-encrypted, got: {encrypt_count}"); + assert_eq!( + encrypt_count, 2, + "expected 2 entries re-encrypted, got: {encrypt_count}" + ); assert_eq!( fs::read_to_string(store.path().join("entry.gpg")).expect("entry"), @@ -271,7 +274,10 @@ fn init_skips_entries_in_subdirectory_with_own_gpg_id() { fn init_re_encrypts_only_entries_in_target_subfolder() { let store = tempfile::TempDir::new().expect("temp dir"); write_file(store.path().join(".gpg-id"), "root@example.invalid\n"); - write_file(store.path().join("team/.gpg-id"), "old-team@example.invalid\n"); + write_file( + store.path().join("team/.gpg-id"), + "old-team@example.invalid\n", + ); write_file(store.path().join("root-entry.gpg"), "root-secret\n"); write_file(store.path().join("team/entry.gpg"), "team-secret\n"); @@ -321,7 +327,10 @@ fn init_skips_re_encryption_when_store_has_no_entries() { .stdout("Password store initialized for alice@example.invalid\n"); let log = fs::read_to_string(&log_file).unwrap_or_default(); - assert!(log.is_empty(), "no re-encryption should happen with empty store; log: {log}"); + assert!( + log.is_empty(), + "no re-encryption should happen with empty store; log: {log}" + ); } fn git(path: &Path, args: [&str; N]) { diff --git a/tests/recipients_command.rs b/tests/recipients_command.rs index 1f31d24..e0b47da 100644 --- a/tests/recipients_command.rs +++ b/tests/recipients_command.rs @@ -278,7 +278,10 @@ fn add_re_encrypts_existing_entries_with_new_recipient() { "bob should be a recipient after add; log: {log}" ); let encrypt_count = log.lines().filter(|l| *l == "encrypt").count(); - assert_eq!(encrypt_count, 1, "one entry should be re-encrypted; log: {log}"); + assert_eq!( + encrypt_count, 1, + "one entry should be re-encrypted; log: {log}" + ); } #[test] @@ -316,7 +319,10 @@ fn remove_re_encrypts_existing_entries_without_removed_recipient() { "bob should NOT be a recipient after remove; log: {log}" ); let encrypt_count = log.lines().filter(|l| *l == "encrypt").count(); - assert_eq!(encrypt_count, 1, "one entry should be re-encrypted; log: {log}"); + assert_eq!( + encrypt_count, 1, + "one entry should be re-encrypted; log: {log}" + ); } #[test] @@ -340,7 +346,10 @@ fn add_skips_re_encryption_when_recipient_already_present() { .success(); let log = fs::read_to_string(&log_file).unwrap_or_default(); - assert!(log.is_empty(), "no re-encryption when recipient already present; log: {log}"); + assert!( + log.is_empty(), + "no re-encryption when recipient already present; log: {log}" + ); } fn git(path: &Path, args: [&str; N]) { From de9c498f41b06de87e0668f0f67267be75f36f7c Mon Sep 17 00:00:00 2001 From: Cristhian Melo Date: Mon, 20 Jul 2026 11:42:17 -0500 Subject: [PATCH 5/6] refactor(password_store): remove unused target_dir param and use is_some_and for extension check --- src/password_store/re_encrypt_entries.rs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/password_store/re_encrypt_entries.rs b/src/password_store/re_encrypt_entries.rs index 3651316..e7a2f5e 100644 --- a/src/password_store/re_encrypt_entries.rs +++ b/src/password_store/re_encrypt_entries.rs @@ -32,7 +32,6 @@ impl<'store> ReEncryptEntries<'store> { reencrypt_dir( &self.gpg, &target_dir, - &target_dir, recipients, passphrase, &mut reencrypted, @@ -44,7 +43,6 @@ impl<'store> ReEncryptEntries<'store> { fn reencrypt_dir( gpg: &GpgCommand, dir: &Path, - target_dir: &Path, recipients: &[String], passphrase: Option<&str>, reencrypted: &mut Vec, @@ -59,11 +57,10 @@ fn reencrypt_dir( if path.is_dir() { if path.join(".gpg-id").exists() { - // This subdir manages its own recipients — skip it entirely continue; } - reencrypt_dir(gpg, &path, target_dir, recipients, passphrase, reencrypted)?; - } else if path.extension().map_or(false, |ext| ext == "gpg") { + reencrypt_dir(gpg, &path, recipients, passphrase, reencrypted)?; + } else if path.extension().is_some_and(|ext| ext == "gpg") { let plaintext = gpg.decrypt(&path, passphrase)?; gpg.encrypt(&plaintext, &path, recipients)?; reencrypted.push(path); From 8b576bd895df063200eca7067a66a2250e5b59c8 Mon Sep 17 00:00:00 2001 From: Cristhian Melo Date: Mon, 20 Jul 2026 11:49:15 -0500 Subject: [PATCH 6/6] test: remove redundant unit tests covered by integration tests --- src/password_store/re_encrypt_entries.rs | 119 ----------------------- 1 file changed, 119 deletions(-) diff --git a/src/password_store/re_encrypt_entries.rs b/src/password_store/re_encrypt_entries.rs index e7a2f5e..0580ced 100644 --- a/src/password_store/re_encrypt_entries.rs +++ b/src/password_store/re_encrypt_entries.rs @@ -69,122 +69,3 @@ fn reencrypt_dir( Ok(()) } - -#[cfg(test)] -mod tests { - use std::fs; - - use tempfile::TempDir; - - use super::ReEncryptEntries; - use crate::password_store::{GpgCommand, PasswordStore, StoreDirectory}; - - fn store_with_files(files: &[(&str, &str)]) -> TempDir { - let dir = TempDir::new().expect("temp dir"); - for (path, content) in files { - let full_path = dir.path().join(path); - fs::create_dir_all(full_path.parent().expect("parent")).expect("create dir"); - fs::write(&full_path, content).expect("write file"); - } - dir - } - - #[cfg(not(windows))] - fn passthrough_gpg_script(dir: &std::path::Path) -> (std::path::PathBuf, std::path::PathBuf) { - use std::os::unix::fs::PermissionsExt; - - let script = dir.join("gpg-test"); - let log = dir.join("gpg-test-log.txt"); - - fs::write( - &script, - format!( - r#"#!/bin/sh -set -eu -log='{log}' -mode='encrypt' -output='' -input_file='' -while [ "$#" -gt 0 ]; do - case "$1" in - --decrypt) mode='decrypt'; shift ;; - --recipient) printf 'recipient:%s\n' "$2" >> "$log"; shift 2 ;; - --output) output="$2"; shift 2 ;; - -*) shift ;; - *) input_file="$1"; shift ;; - esac -done -if [ "$mode" = "decrypt" ]; then cat "$input_file"; exit 0; fi -printf 'encrypt\n' >> "$log" -cat > "$output" -"#, - log = log.display() - ), - ) - .expect("write script"); - - let mut perms = fs::metadata(&script).expect("metadata").permissions(); - perms.set_mode(0o755); - fs::set_permissions(&script, perms).expect("set permissions"); - - (script, log) - } - - #[test] - #[cfg(not(windows))] - fn re_encrypts_gpg_files_in_store() { - let temp = store_with_files(&[(".gpg-id", "alice\n"), ("entry.gpg", "secret\n")]); - let (script, log) = passthrough_gpg_script(temp.path()); - let store = PasswordStore::open(StoreDirectory::from_path(temp.path())).expect("store"); - let gpg = GpgCommand::new(script); - - let result = ReEncryptEntries::new(&store, gpg) - .execute(None, &["alice".to_string()], None) - .expect("re-encrypt"); - - assert_eq!(result.len(), 1); - let log_content = fs::read_to_string(log).expect("log"); - assert!(log_content.contains("recipient:alice")); - assert_eq!( - fs::read_to_string(temp.path().join("entry.gpg")).expect("entry"), - "secret\n" - ); - } - - #[test] - #[cfg(not(windows))] - fn skips_entries_in_subdir_with_own_gpg_id() { - let temp = store_with_files(&[ - (".gpg-id", "alice\n"), - ("team/.gpg-id", "team\n"), - ("entry.gpg", "root\n"), - ("team/entry.gpg", "team-secret\n"), - ]); - let (script, log) = passthrough_gpg_script(temp.path()); - let store = PasswordStore::open(StoreDirectory::from_path(temp.path())).expect("store"); - let gpg = GpgCommand::new(script); - - let result = ReEncryptEntries::new(&store, gpg) - .execute(None, &["alice".to_string()], None) - .expect("re-encrypt"); - - assert_eq!(result.len(), 1, "only root entry should be re-encrypted"); - let log_content = fs::read_to_string(log).expect("log"); - assert_eq!(log_content.lines().filter(|l| *l == "encrypt").count(), 1); - } - - #[test] - #[cfg(not(windows))] - fn returns_empty_when_no_gpg_files() { - let temp = store_with_files(&[(".gpg-id", "alice\n")]); - let (script, _) = passthrough_gpg_script(temp.path()); - let store = PasswordStore::open(StoreDirectory::from_path(temp.path())).expect("store"); - let gpg = GpgCommand::new(script); - - let result = ReEncryptEntries::new(&store, gpg) - .execute(None, &["alice".to_string()], None) - .expect("re-encrypt"); - - assert!(result.is_empty()); - } -}