From ad88981a80bae5ff2521f2caac3e35a97f8e7a4a Mon Sep 17 00:00:00 2001 From: Roman Studenikin Date: Sun, 28 Dec 2025 23:04:01 +0000 Subject: [PATCH 01/12] Add storage versionV5 --- TODO | 30 + src/cli/completer.rs | 8 +- src/cli/interactive.rs | 28 +- src/cli/update.rs | 49 +- src/lib.rs | 5 +- src/main.rs | 24 +- src/storage/mod.rs | 13 +- src/storage/serialization.rs | 68 ++- src/storage/store.rs | 6 +- src/storage/v5.rs | 578 ++++++++++++++++++ src/storage/value.rs | 3 +- src/version.rs | 1 + tests/cli_tests.rs | 8 +- .../{storage_tests.rs => storage_v4_tests.rs} | 86 +-- tests/storage_v5_tests.rs | 393 ++++++++++++ 15 files changed, 1169 insertions(+), 131 deletions(-) create mode 100644 TODO create mode 100644 src/storage/v5.rs rename tests/{storage_tests.rs => storage_v4_tests.rs} (81%) create mode 100644 tests/storage_v5_tests.rs diff --git a/TODO b/TODO new file mode 100644 index 0000000..98cfe8e --- /dev/null +++ b/TODO @@ -0,0 +1,30 @@ +# Implement a new extensible storage format that supports + +## grouping secrets into folders +- everything is in the root folder by default +- secrets can be moved between folders + +## encryption domains +- default encryption domain(0) means encryption with default master key as it is done now in storage v4. +- non-default domain means secrets are encrypted with a custom key requiring another password +- can be attached to either individual keys or folders +- when a folder has a non-default encrypted domain, all its contents should be encrypted together, such that it is impossible to know what is stored inside. It should appear in the CLI UI as a regular key/value instead of a folder with a user-set value to confuse attackers in a case of master key is stolen. +- "unlock" command for such a key asks for a password, decrypts its content and opens it for querying by all commands like if it was unencrypted. + +**Use case** +Encryption: +- User can call a command "lock {folder/key}" which would ask which encryption domain to use or to create a new one, password for it and a PlaceholderSecret value to show when it is locked. +- The value or a folder is immediately encrypted and stored encrypted in memory +- get/set/history commands work as if a PlaceholderSecret is a real stored secret value. + +Decryption: +- get/set/history commands work as always as if a PlaceHolderSecret was a real value. set command can override the value even if it holds an encrypted folder underneath. +- "unlock key" command checks encryption domain and if a key for this domain is already cached, then it performs decryption and if it is a folder, decrypts its contents into memory making its contents available for get/set/history commands + +## rename/move commands +- rename command allows renaming keys with a regexp. Before renaming, it performs checks confirming that there are no collisions and asks for a confirmation if multiple keys are being renamed. +- move command allows moving from/into folders. Default folder is "/" +- an encrypted folder must be unlocked to allow copying into/from it. Re-encryption is performed with a key from corresponding encryption domain. + +## support SecretType and optional metadata HashMap +- this will later be used for supporting storage of files. diff --git a/src/cli/completer.rs b/src/cli/completer.rs index d91ee7f..b67d8c9 100644 --- a/src/cli/completer.rs +++ b/src/cli/completer.rs @@ -1,6 +1,6 @@ use std::sync::{Arc, Mutex}; -use crate::Storage; +use crate::StorageV5; use rustyline::Helper; use rustyline::completion::{Completer, Pair}; use rustyline::highlight::Highlighter; @@ -8,11 +8,11 @@ use rustyline::hint::Hinter; use rustyline::validate::Validator; pub struct CypherCompleter { - storage: Arc>, + storage: Arc>, } impl CypherCompleter { - pub const fn new(storage: Arc>) -> Self { + pub const fn new(storage: Arc>) -> Self { Self { storage } } } @@ -60,7 +60,7 @@ impl Completer for CypherCompleter { let prefix = if parts.len() == 2 { parts[1] } else { "" }; let storage = self.storage.lock().expect("able to take a lock"); - let mut keys: Vec = storage.data.keys().cloned().collect(); + let mut keys: Vec = storage.root.secrets.keys().cloned().collect(); drop(storage); keys.sort(); diff --git a/src/cli/interactive.rs b/src/cli/interactive.rs index 41b7d2d..52d47ac 100644 --- a/src/cli/interactive.rs +++ b/src/cli/interactive.rs @@ -1,13 +1,13 @@ use crate::Cypher; use crate::EncryptedValue; -use crate::Storage; +use crate::StorageV5; use crate::cli::CLIPBOARD_TTL_MS; use crate::cli::STANDBY_TIMEOUT; use crate::cli::completer::CypherCompleter; use crate::cli::utils::{copy_to_clipboard, format_timestamp, secure_print}; use crate::is_debugger_attached; -use crate::load_storage; -use crate::save_storage; +use crate::load_storage_v5; +use crate::save_storage_v5; use anyhow::{Result, bail}; use rustyline::CompletionType; use rustyline::Config; @@ -41,7 +41,7 @@ impl InteractiveCli { } pub fn run(&self) -> Result<()> { - let storage = Arc::new(Mutex::new(load_storage(&self.cypher, &self.filename)?)); + let storage = Arc::new(Mutex::new(load_storage_v5(&self.cypher, &self.filename)?)); let config = Config::builder() .completion_type(CompletionType::List) @@ -109,7 +109,7 @@ impl InteractiveCli { Ok(()) } - fn process_cmd(&self, line: &str, storage: &mut Storage) -> Result<()> { + fn process_cmd(&self, line: &str, storage: &mut StorageV5) -> Result<()> { let parts: Vec<&str> = line.splitn(3, ' ').collect(); let cmd = parts[0]; match cmd { @@ -157,17 +157,17 @@ impl InteractiveCli { } } - fn cmd_put(&self, key: &str, value: &str, storage: &mut Storage) -> Result<()> { + fn cmd_put(&self, key: &str, value: &str, storage: &mut StorageV5) -> Result<()> { let encrypted_value = EncryptedValue::encrypt(&self.cypher, value)?; storage.put(key.to_string(), encrypted_value); secure_print(format!("{key} stored"), self.insecure_stdout)?; - save_storage(&self.cypher, storage, &self.filename)?; + save_storage_v5(&self.cypher, storage, &self.filename)?; Ok(()) } - fn cmd_get(&self, pattern: &str, storage: &Storage) -> Result<()> { + fn cmd_get(&self, pattern: &str, storage: &StorageV5) -> Result<()> { match storage.get(pattern) { Ok(results) => { let mut found = false; @@ -187,7 +187,7 @@ impl InteractiveCli { Ok(()) } - fn cmd_copy(&self, key: &str, storage: &Storage) -> Result<()> { + fn cmd_copy(&self, key: &str, storage: &StorageV5) -> Result<()> { match storage.get(key) { Ok(mut results) => { let first = results.next(); @@ -220,10 +220,10 @@ impl InteractiveCli { Ok(()) } - fn cmd_history(&self, key: &str, storage: &Storage) -> Result<()> { + fn cmd_history(&self, key: &str, storage: &StorageV5) -> Result<()> { if let Some(entries) = storage.history(key) { for entry in entries { - let mut secret = entry.value.decrypt(&self.cypher)?; + let mut secret = entry.encrypted_value().decrypt(&self.cypher)?; let output = format!("[{}]: {}", format_timestamp(entry.timestamp), &*secret); secret.zeroize(); secure_print(output, self.insecure_stdout)?; @@ -234,7 +234,7 @@ impl InteractiveCli { Ok(()) } - fn cmd_search(&self, pattern: &str, storage: &Storage) -> Result<()> { + fn cmd_search(&self, pattern: &str, storage: &StorageV5) -> Result<()> { match storage.search(pattern) { Ok(keys) => { for key in keys { @@ -246,10 +246,10 @@ impl InteractiveCli { Ok(()) } - fn cmd_delete(&self, key: &str, storage: &mut Storage) -> Result<()> { + fn cmd_delete(&self, key: &str, storage: &mut StorageV5) -> Result<()> { if storage.delete(key) { secure_print(format!("{key} deleted"), self.insecure_stdout)?; - save_storage(&self.cypher, storage, &self.filename)?; + save_storage_v5(&self.cypher, storage, &self.filename)?; } else { bail!("No such key '{key}' found"); } diff --git a/src/cli/update.rs b/src/cli/update.rs index 47d918a..1246973 100644 --- a/src/cli/update.rs +++ b/src/cli/update.rs @@ -1,5 +1,5 @@ use crate::cli::utils::{format_timestamp, secure_print}; -use crate::{Cypher, EncryptedValue, EncryptionKey, Storage, load_storage, save_storage}; +use crate::{Cypher, EncryptedValue, EncryptionKey, StorageV5, load_storage_v5, save_storage_v5}; use anyhow::Result; use std::io; use std::io::Write; @@ -23,28 +23,29 @@ impl UpdateEntry { /// Find entries that need updating by comparing latest values from both storages fn find_updates( - main_storage: &Storage, - update_storage: &Storage, + main_storage: &StorageV5, + update_storage: &StorageV5, main_cypher: &Cypher, update_cypher: &Cypher, ) -> Vec { let mut updates = Vec::new(); - for (key, update_entries) in &update_storage.data { + for (key, update_entries) in &update_storage.root.secrets { let update_latest = update_entries.last().expect("entries should not be empty"); let main_latest = main_storage - .data + .root + .secrets .get(key) .and_then(|entries| entries.last()); let should_update = main_latest.is_none_or(|main_entry| { // Key exists - decrypt and compare values let main_decrypted = main_entry - .value + .encrypted_value() .decrypt(main_cypher) .expect("Failed to decrypt main file"); let update_decrypted = update_latest - .value + .encrypted_value() .decrypt(update_cypher) .expect("Failed to decrypt update file"); @@ -55,9 +56,9 @@ fn find_updates( if should_update { updates.push(UpdateEntry { key: key.clone(), - new_value: update_latest.value.clone(), + new_value: update_latest.encrypted_value().clone(), new_timestamp: update_latest.timestamp, - old_value: main_latest.map(|e| e.value.clone()), + old_value: main_latest.map(|e| e.encrypted_value().clone()), old_timestamp: main_latest.map(|e| e.timestamp), }); } @@ -150,7 +151,7 @@ fn display_update_summary( /// Apply all updates at once fn apply_all_updates( updates: Vec, - main_storage: &mut Storage, + main_storage: &mut StorageV5, main_cypher: &Cypher, update_cypher: &Cypher, filename: &Path, @@ -162,7 +163,7 @@ fn apply_all_updates( main_storage.put_ts(update.key, re_encrypted, update.new_timestamp); } - save_storage(main_cypher, main_storage, filename)?; + save_storage_v5(main_cypher, main_storage, filename)?; println!("✓ All updates applied successfully."); Ok(()) } @@ -170,7 +171,7 @@ fn apply_all_updates( /// Apply updates interactively, prompting for each one fn apply_updates_interactive( updates: Vec, - main_storage: &mut Storage, + main_storage: &mut StorageV5, main_cypher: &Cypher, update_cypher: &Cypher, filename: &Path, @@ -209,7 +210,7 @@ fn apply_updates_interactive( } if applied > 0 { - save_storage(main_cypher, main_storage, filename)?; + save_storage_v5(main_cypher, main_storage, filename)?; println!( "\n✓ Applied {} update{}, skipped {}.", applied, @@ -242,10 +243,10 @@ pub fn run_update_with( insecure_stdout: bool, ) -> Result<()> { let main_cypher = Cypher::new(main_key); - let mut main_storage = load_storage(&main_cypher, filename)?; + let mut main_storage = load_storage_v5(&main_cypher, filename)?; let update_cypher = Cypher::new(update_key); - let update_storage = load_storage(&update_cypher, update_file)?; + let update_storage = load_storage_v5(&update_cypher, update_file)?; // Find what needs updating let updates = find_updates(&main_storage, &update_storage, &main_cypher, &update_cypher); @@ -293,7 +294,7 @@ pub fn run_update_with( #[cfg(test)] mod tests { use super::*; - use crate::{CypherVersion, Storage}; + use crate::{CypherVersion, StorageV5}; fn create_test_cypher() -> Cypher { let key = EncryptionKey::from_password(CypherVersion::default(), "test_password") @@ -330,8 +331,8 @@ mod tests { #[test] fn test_find_updates_new_keys() { let cypher = create_test_cypher(); - let mut main_storage = Storage::new(); - let mut update_storage = Storage::new(); + let mut main_storage = StorageV5::new(); + let mut update_storage = StorageV5::new(); // Main has key1, update has key1 and key2 main_storage.put( @@ -357,8 +358,8 @@ mod tests { #[test] fn test_find_updates_conflicts() { let cypher = create_test_cypher(); - let mut main_storage = Storage::new(); - let mut update_storage = Storage::new(); + let mut main_storage = StorageV5::new(); + let mut update_storage = StorageV5::new(); // Both have key1 but with different values main_storage.put_ts( @@ -382,8 +383,8 @@ mod tests { #[test] fn test_find_updates_no_changes() { let cypher = create_test_cypher(); - let mut main_storage = Storage::new(); - let mut update_storage = Storage::new(); + let mut main_storage = StorageV5::new(); + let mut update_storage = StorageV5::new(); // Both have same key with same value main_storage.put( @@ -403,8 +404,8 @@ mod tests { #[test] fn test_find_updates_ignores_older_timestamp() { let cypher = create_test_cypher(); - let mut main_storage = Storage::new(); - let mut update_storage = Storage::new(); + let mut main_storage = StorageV5::new(); + let mut update_storage = StorageV5::new(); // Update has older value update_storage.put_ts( diff --git a/src/lib.rs b/src/lib.rs index 833f19b..8c1f900 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -30,8 +30,9 @@ pub use cli::utils::{Spinner, ThreadStopGuard, copy_to_clipboard, format_timesta pub use crypto::{Argon2Params, Cypher, EncryptionKey}; pub use security::{disable_core_dumps, enable_ptrace_protection, is_debugger_attached}; pub use storage::{ - EncryptedValue, Storage, ValueEntry, deserialize_storage, load_storage, save_storage, - serialize_storage, + EncryptedValue, SecretEntry, StorageV4, StorageV5, deserialize_storage_v4, + deserialize_storage_v5_from_slice, load_storage_v4, load_storage_v5, save_storage_v4, + save_storage_v5, serialize_storage_v4, serialize_storage_v5_to_vec, }; pub use version::CypherVersion; diff --git a/src/main.rs b/src/main.rs index 9b31bbe..80924b2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -22,8 +22,8 @@ use clap::{ArgGroup, Parser}; use nix::fcntl::{Flock, FlockArg}; use rcypher::cli::utils::get_password; use rcypher::{ - Argon2Params, Cypher, CypherVersion, EncryptedValue, EncryptionKey, Spinner, Storage, - ThreadStopGuard, load_storage, serialize_storage, + Argon2Params, Cypher, CypherVersion, EncryptedValue, EncryptionKey, Spinner, StorageV5, + ThreadStopGuard, load_storage_v5, save_storage_v5, }; // Import from lib use rcypher::{cli, disable_core_dumps, enable_ptrace_protection, is_debugger_attached}; use std::fs::OpenOptions; @@ -32,7 +32,6 @@ use std::io::Write; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; -use tempfile::NamedTempFile; use zeroize::Zeroize; #[derive(Parser)] @@ -151,29 +150,20 @@ fn run_upgrade_storage( let spinner = Spinner::new("Converting", params.quiet); let old_cypher = Cypher::new(old_key); - let old_storage = load_storage(&old_cypher, ¶ms.filename)?; + let old_storage = load_storage_v5(&old_cypher, ¶ms.filename)?; - let mut new_storage = Storage::new(); + let mut new_storage = StorageV5::new(); let new_cypher = Cypher::new(new_key); - for (key, entries) in old_storage.data { + for (key, entries) in old_storage.root.secrets { for entry in entries { - let mut secret = entry.value.decrypt(&old_cypher)?; + let mut secret = entry.encrypted_value().decrypt(&old_cypher)?; let new_value = EncryptedValue::encrypt(&new_cypher, &secret)?; new_storage.put_ts(key.clone(), new_value, entry.timestamp); secret.zeroize(); } } - let dir = ¶ms - .filename - .parent() - .expect("Can't get parent dir of a file"); - let mut temp = NamedTempFile::new_in(dir)?; - let serialized = serialize_storage(&new_storage); - let encrypted = new_cypher.encrypt(&serialized)?; - - temp.write_all(&encrypted)?; - temp.persist(¶ms.filename)?; + save_storage_v5(&new_cypher, &new_storage, ¶ms.filename)?; spinner.finish_and_clear(); Ok(()) diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 5b2d926..d04e398 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -1,7 +1,14 @@ mod serialization; mod store; +mod v5; mod value; -pub use serialization::{deserialize_storage, load_storage, save_storage, serialize_storage}; -pub use store::Storage; -pub use value::{EncryptedValue, ValueEntry}; +pub use serialization::{ + deserialize_storage_v4, load_storage_v4, load_storage_v5, save_storage_v4, save_storage_v5, + serialize_storage_v4, +}; +pub use store::StorageV4; +pub use v5::{ + SecretEntry, StorageV5, deserialize_storage_v5_from_slice, serialize_storage_v5_to_vec, +}; +pub use value::EncryptedValue; diff --git a/src/storage/serialization.rs b/src/storage/serialization.rs index 9a08ee0..6417ac7 100644 --- a/src/storage/serialization.rs +++ b/src/storage/serialization.rs @@ -8,10 +8,11 @@ use tempfile::NamedTempFile; use crate::crypto::Cypher; use crate::version::StoreVersion; -use super::store::Storage; +use super::store::StorageV4; +use super::v5::{self, StorageV5}; use super::value::{EncryptedValue, ValueEntry}; -pub fn serialize_storage(storage: &Storage) -> Vec { +pub fn serialize_storage_v4(storage: &StorageV4) -> Vec { let mut result = Vec::new(); // Version @@ -46,20 +47,13 @@ pub fn serialize_storage(storage: &Storage) -> Vec { result } -pub fn deserialize_storage(data: &[u8]) -> Result { - let version = StoreVersion::probe_data(data)?; - match version { - StoreVersion::Version4 => deserialize_storage_v4(data), - } -} - -pub fn deserialize_storage_v4(data: &[u8]) -> Result { +pub fn deserialize_storage_v4(data: &[u8]) -> Result { if data.len() < 6 { bail!("Data too short"); } let count = u32::from_be_bytes([data[2], data[3], data[4], data[5]]); - let mut storage = Storage::new(); + let mut storage = StorageV4::new(); let mut pos = 6; for _ in 0..count { @@ -122,22 +116,64 @@ pub fn deserialize_storage_v4(data: &[u8]) -> Result { Ok(storage) } -pub fn load_storage(cypher: &Cypher, path: &Path) -> Result { +pub fn load_storage_v4(cypher: &Cypher, path: &Path) -> Result { if !path.exists() { - return Ok(Storage::new()); + return Ok(StorageV4::new()); + } + let encrypted = fs::read(path)?; + let decrypted = cypher.decrypt(&encrypted)?; + + deserialize_storage_v4(&decrypted) +} + +pub fn save_storage_v4(cypher: &Cypher, storage: &StorageV4, path: &Path) -> Result<()> { + let dir = path.parent().expect("Can't get parent dir of a file"); + let mut temp = NamedTempFile::new_in(dir)?; + + let serialized = serialize_storage_v4(storage); + let encrypted = cypher.encrypt(&serialized)?; + + temp.write_all(&encrypted)?; + temp.persist(path)?; + + Ok(()) +} + +// ============================================================================ +// V5 Storage Functions +// ============================================================================ + +/// Load storage from file, automatically converting V4 to V5 if needed +/// This is the main entry point for loading storage - always returns V5 +pub fn load_storage_v5(cypher: &Cypher, path: &Path) -> Result { + if !path.exists() { + return Ok(StorageV5::new()); } let encrypted = fs::read(path)?; let decrypted = cypher.decrypt(&encrypted)?; - deserialize_storage(&decrypted) + // Determine version and deserialize accordingly + let version = StoreVersion::probe_data(&decrypted)?; + match version { + StoreVersion::Version4 => { + // Load V4 and convert to V5 + let v4 = deserialize_storage_v4(&decrypted)?; + Ok(v5::migrate_v4_to_v5(v4)) + } + StoreVersion::Version5 => { + // Load V5 directly + v5::deserialize_storage_v5_from_slice(&decrypted) + } + } } -pub fn save_storage(cypher: &Cypher, storage: &Storage, path: &Path) -> Result<()> { +/// Save V5 storage to file +pub fn save_storage_v5(cypher: &Cypher, storage: &StorageV5, path: &Path) -> Result<()> { let dir = path.parent().expect("Can't get parent dir of a file"); let mut temp = NamedTempFile::new_in(dir)?; - let serialized = serialize_storage(storage); + let serialized = v5::serialize_storage_v5_to_vec(storage)?; let encrypted = cypher.encrypt(&serialized)?; temp.write_all(&encrypted)?; diff --git a/src/storage/store.rs b/src/storage/store.rs index b50c647..e444e50 100644 --- a/src/storage/store.rs +++ b/src/storage/store.rs @@ -9,11 +9,11 @@ use serde::{Deserialize, Serialize}; use super::value::{EncryptedValue, ValueEntry}; #[derive(Debug, Serialize, Deserialize)] -pub struct Storage { +pub struct StorageV4 { pub data: BTreeMap>, } -impl Storage { +impl StorageV4 { pub const fn new() -> Self { Self { data: BTreeMap::new(), @@ -79,7 +79,7 @@ impl Storage { } } -impl Default for Storage { +impl Default for StorageV4 { fn default() -> Self { Self::new() } diff --git a/src/storage/v5.rs b/src/storage/v5.rs new file mode 100644 index 0000000..a042e9a --- /dev/null +++ b/src/storage/v5.rs @@ -0,0 +1,578 @@ +use std::collections::{BTreeMap, HashMap}; +use std::io::{Read, Write}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{Result, bail}; +use bincode::{Decode, Encode, config}; +use regex::Regex; + +use super::value::EncryptedValue; +use crate::version::StoreVersion; + +// ============================================================================ +// Storage V5 - Hierarchical folders with encryption domains +// ============================================================================ + +/// Root storage container for V5 +#[derive(Debug, Clone, Encode, Decode)] +pub struct StorageV5 { + /// Root folder containing all secrets and subfolders + pub root: Folder, +} + +impl StorageV5 { + /// Create a new empty V5 storage + pub const fn new() -> Self { + Self { + root: Folder::new_root(), + } + } + + /// Store a secret value with current timestamp + /// For V5, stores in root folder with default encryption domain (0) + pub fn put(&mut self, key: String, value: EncryptedValue) { + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time should go forward") + .as_secs(); + + self.put_ts(key, value, timestamp); + } + + /// Store a secret value with a specific timestamp + pub fn put_ts(&mut self, key: String, value: EncryptedValue, timestamp: u64) { + self.root + .secrets + .entry(key) + .or_default() + .push(SecretEntry::new_plain(value, timestamp, 0)); + } + + /// Returns an iterator over key-value pairs matching the given regex pattern. + /// + /// # Sorting + /// - Keys are returned in sorted order (guaranteed by `BTreeMap`) + /// - Returns the latest value for each key (entries sorted by timestamp) + pub fn get(&self, pattern: &str) -> Result + '_> { + let re = Regex::new(&format!("^{pattern}$"))?; + Ok(self + .root + .secrets + .iter() + .filter(move |(k, entries)| re.is_match(k) && !entries.is_empty()) + .filter_map(|(k, entries)| { + entries + .last() + .map(|entry| (k.as_str(), entry.encrypted_value())) + })) + } + + /// Returns an iterator over all historical values for a given key. + /// + /// # Sorting + /// Entries are returned in chronological order (oldest to newest). + pub fn history(&self, key: &str) -> Option + '_> { + self.root.secrets.get(key).map(|entries| entries.iter()) + } + + /// Delete a key and all its history + pub fn delete(&mut self, key: &str) -> bool { + self.root.secrets.remove(key).is_some() + } + + /// Returns an iterator over all keys matching the given regex pattern. + /// + /// # Sorting + /// Keys are returned in sorted order (guaranteed by `BTreeMap`). + pub fn search(&self, pattern: &str) -> Result + '_> { + let re = Regex::new(pattern)?; + Ok(self + .root + .secrets + .keys() + .filter(move |k| re.is_match(k)) + .map(String::as_str)) + } +} + +impl Default for StorageV5 { + fn default() -> Self { + Self::new() + } +} + +// ============================================================================ +// Folder - Hierarchical container for secrets +// ============================================================================ + +/// A folder containing secrets and subfolders +#[derive(Debug, Clone, Encode, Decode)] +pub struct Folder { + /// Folder name (empty string for root) + pub name: String, + + /// Which encryption domain this folder belongs to + /// - 0 = default domain (master key) + /// - N > 0 = custom domain (requires separate password) + pub encryption_domain: u32, + + /// Secrets in this folder (key -> history of values) + pub secrets: BTreeMap>, + + /// Subfolders + pub subfolders: BTreeMap, +} + +impl Folder { + /// Create a new root folder (domain 0) + pub const fn new_root() -> Self { + Self { + name: String::new(), + encryption_domain: 0, + secrets: BTreeMap::new(), + subfolders: BTreeMap::new(), + } + } + + /// Create a new named folder + pub const fn new(name: String, encryption_domain: u32) -> Self { + Self { + name, + encryption_domain, + secrets: BTreeMap::new(), + subfolders: BTreeMap::new(), + } + } +} + +// ============================================================================ +// Secret Entry - A versioned secret with metadata +// ============================================================================ + +/// A secret entry with timestamp and metadata +#[derive(Debug, Clone, Encode, Decode)] +pub struct SecretEntry { + /// The secret value (may be plain or encrypted folder) + pub value: SecretValue, + + /// When this version was created (seconds since UNIX epoch) + pub timestamp: u64, + + /// Type of secret + pub secret_type: SecretType, + + /// Optional metadata (for future file storage, etc.) + pub metadata: HashMap, +} + +impl SecretEntry { + /// Create a new secret entry with plain value + pub fn new_plain( + encrypted_value: EncryptedValue, + timestamp: u64, + encryption_domain: u32, + ) -> Self { + Self { + value: SecretValue::Plain { + data: encrypted_value, + encryption_domain, + }, + timestamp, + secret_type: SecretType::Utf8String, + metadata: HashMap::new(), + } + } + + /// Get the encrypted value from this entry + pub const fn encrypted_value(&self) -> &EncryptedValue { + match &self.value { + SecretValue::Plain { data, .. } => data, + // For encrypted folders, we return the placeholder + // (actual folder decryption will be handled separately) + SecretValue::EncryptedFolder { + placeholder_data, .. + } => placeholder_data, + } + } +} + +// ============================================================================ +// Secret Value - Either plain data or encrypted folder +// ============================================================================ + +/// The actual secret value +#[derive(Debug, Clone, Encode, Decode)] +pub enum SecretValue { + /// Regular secret encrypted with domain key + /// - Domain 0: encrypted with master key (like V4) + /// - Domain N: encrypted with custom domain key + Plain { + /// The encrypted data + data: EncryptedValue, + + /// Which encryption domain encrypts this data + encryption_domain: u32, + }, + + /// Locked folder disguised as a regular secret + /// When locked, CLI shows `placeholder_data` + /// When unlocked, `encrypted_folder` is decrypted and merged into parent + EncryptedFolder { + /// What to show when locked (appears as regular secret value) + placeholder_data: EncryptedValue, + + /// The actual folder serialized and encrypted with domain key + encrypted_folder: Vec, + + /// Which encryption domain encrypts this folder + encryption_domain: u32, + }, +} + +impl SecretValue { + /// Check if this is an encrypted folder + pub const fn is_encrypted_folder(&self) -> bool { + matches!(self, Self::EncryptedFolder { .. }) + } + + /// Get the encryption domain for this value + pub const fn encryption_domain(&self) -> u32 { + match self { + Self::Plain { + encryption_domain, .. + } + | Self::EncryptedFolder { + encryption_domain, .. + } => *encryption_domain, + } + } +} + +// ============================================================================ +// Secret Type - Extensible enum for different secret types +// ============================================================================ + +/// Type of secret content +#[derive(Debug, Clone, Copy, Encode, Decode, PartialEq, Eq)] +#[repr(u16)] +pub enum SecretType { + Utf8String = 0, +} + +// ============================================================================ +// Serialization - Stream-based +// ============================================================================ + +/// Serialize V5 storage to a writer +/// Format: [version:u16 big-endian][bincode_payload] +/// Version is written separately to allow forward-only stream reading +pub fn serialize_storage_v5(writer: &mut W, storage: &StorageV5) -> Result<()> { + // Write version (2 bytes, big-endian) + let version = StoreVersion::Version5 as u16; + writer.write_all(&version.to_be_bytes())?; + + // Serialize the storage with bincode + let config = config::standard(); + bincode::encode_into_std_write(storage, writer, config)?; + + Ok(()) +} + +/// Deserialize V5 storage from a reader +/// Reads version first, then deserializes payload (forward-only stream) +pub fn deserialize_storage_v5(reader: &mut R) -> Result { + // Read version (2 bytes, big-endian) + let mut version_bytes = [0u8; 2]; + reader.read_exact(&mut version_bytes)?; + let version = u16::from_be_bytes(version_bytes); + + let expected_version = StoreVersion::Version5 as u16; + if version != expected_version { + bail!("Invalid version for V5 deserializer: expected {expected_version}, got {version}"); + } + + // Deserialize storage from stream + let config = config::standard(); + let mut storage: StorageV5 = bincode::decode_from_std_read(reader, config)?; + + // Sort entries by timestamp for consistency + sort_folder_entries(&mut storage.root); + + Ok(storage) +} + +// Convenience functions for Vec + +/// Serialize V5 storage to bytes (convenience wrapper) +pub fn serialize_storage_v5_to_vec(storage: &StorageV5) -> Result> { + let mut buffer = Vec::new(); + serialize_storage_v5(&mut buffer, storage)?; + Ok(buffer) +} + +/// Deserialize V5 storage from bytes (convenience wrapper) +pub fn deserialize_storage_v5_from_slice(data: &[u8]) -> Result { + let mut cursor = std::io::Cursor::new(data); + deserialize_storage_v5(&mut cursor) +} + +/// Sort all secret entries by timestamp (recursive) +fn sort_folder_entries(folder: &mut Folder) { + for entries in folder.secrets.values_mut() { + entries.sort_by_key(|e| e.timestamp); + } + + for subfolder in folder.subfolders.values_mut() { + sort_folder_entries(subfolder); + } +} + +// ============================================================================ +// Migration from V4 to V5 +// ============================================================================ + +use super::store::StorageV4; +use super::value::ValueEntry; + +/// Migrate V4 storage to V5 format +/// All secrets go into root folder with default encryption domain (0) +pub fn migrate_v4_to_v5(v4: StorageV4) -> StorageV5 { + let mut root = Folder::new_root(); + + // Migrate flat structure to root folder + for (key, entries) in v4.data { + let secrets: Vec = entries.into_iter().map(value_entry_to_secret).collect(); + + root.secrets.insert(key, secrets); + } + + StorageV5 { root } +} + +/// Convert V4 `ValueEntry` to V5 `SecretEntry` +fn value_entry_to_secret(entry: ValueEntry) -> SecretEntry { + SecretEntry { + value: SecretValue::Plain { + data: entry.value, + encryption_domain: 0, // Default domain + }, + timestamp: entry.timestamp, + secret_type: SecretType::Utf8String, + metadata: HashMap::new(), + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_empty_storage_v5() { + let storage = StorageV5::new(); + let serialized = serialize_storage_v5_to_vec(&storage).unwrap(); + let deserialized = deserialize_storage_v5_from_slice(&serialized).unwrap(); + + assert!(deserialized.root.secrets.is_empty()); + assert!(deserialized.root.subfolders.is_empty()); + } + + #[test] + fn test_simple_secret_v5() { + let mut storage = StorageV5::new(); + let test_value = EncryptedValue::from_ciphertext(b"test_value".to_vec()); + storage.root.secrets.insert( + "test_key".to_string(), + vec![SecretEntry::new_plain(test_value, 12345, 0)], + ); + + let serialized = serialize_storage_v5_to_vec(&storage).unwrap(); + let deserialized = deserialize_storage_v5_from_slice(&serialized).unwrap(); + + assert_eq!(deserialized.root.secrets.len(), 1); + let entry = &deserialized.root.secrets["test_key"][0]; + + match &entry.value { + SecretValue::Plain { + data, + encryption_domain, + } => { + assert_eq!(data.as_bytes(), b"test_value"); + assert_eq!(*encryption_domain, 0); + } + _ => panic!("Expected Plain variant"), + } + } + + #[test] + fn test_folder_structure() { + let mut storage = StorageV5::new(); + + // Add a subfolder + let mut subfolder = Folder::new("work".to_string(), 0); + let api_key_value = EncryptedValue::from_ciphertext(b"secret123".to_vec()); + subfolder.secrets.insert( + "api_key".to_string(), + vec![SecretEntry::new_plain(api_key_value, 12345, 0)], + ); + + storage + .root + .subfolders + .insert("work".to_string(), subfolder); + + let serialized = serialize_storage_v5_to_vec(&storage).unwrap(); + let deserialized = deserialize_storage_v5_from_slice(&serialized).unwrap(); + + assert_eq!(deserialized.root.subfolders.len(), 1); + assert!(deserialized.root.subfolders.contains_key("work")); + assert_eq!(deserialized.root.subfolders["work"].secrets.len(), 1); + } + + #[test] + fn test_encrypted_folder_variant() { + let mut storage = StorageV5::new(); + + // Create an encrypted folder secret + let placeholder = EncryptedValue::from_ciphertext(b"placeholder".to_vec()); + storage.root.secrets.insert( + "secret_folder".to_string(), + vec![SecretEntry { + value: SecretValue::EncryptedFolder { + placeholder_data: placeholder, + encrypted_folder: vec![1, 2, 3, 4], // Mock encrypted data + encryption_domain: 1, + }, + timestamp: 12345, + secret_type: SecretType::Utf8String, + metadata: HashMap::new(), + }], + ); + + let serialized = serialize_storage_v5_to_vec(&storage).unwrap(); + let deserialized = deserialize_storage_v5_from_slice(&serialized).unwrap(); + + let entry = &deserialized.root.secrets["secret_folder"][0]; + assert!(entry.value.is_encrypted_folder()); + assert_eq!(entry.value.encryption_domain(), 1); + } + + #[test] + fn test_v4_migration() { + let mut v4 = StorageV4::new(); + v4.put("key1".to_string(), "value1".into()); + v4.put("key2".to_string(), "value2".into()); + + let v5 = migrate_v4_to_v5(v4); + + assert_eq!(v5.root.secrets.len(), 2); + assert!(v5.root.secrets.contains_key("key1")); + assert!(v5.root.secrets.contains_key("key2")); + assert_eq!(v5.root.encryption_domain, 0); + } + + #[test] + fn test_put_and_get() { + let mut storage = StorageV5::new(); + let test_value = EncryptedValue::from_ciphertext(b"test_value".to_vec()); + storage.put("test_key".to_string(), test_value); + + let results: Vec<_> = storage.get("test_key").unwrap().collect(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].0, "test_key"); + assert_eq!(results[0].1.as_bytes(), b"test_value"); + } + + #[test] + fn test_get_with_pattern() { + let mut storage = StorageV5::new(); + storage.put( + "key1".to_string(), + EncryptedValue::from_ciphertext(b"value1".to_vec()), + ); + storage.put( + "key2".to_string(), + EncryptedValue::from_ciphertext(b"value2".to_vec()), + ); + storage.put( + "other".to_string(), + EncryptedValue::from_ciphertext(b"value3".to_vec()), + ); + + let results: Vec<_> = storage.get("key.*").unwrap().collect(); + assert_eq!(results.len(), 2); + assert!(results.iter().any(|(k, _)| *k == "key1")); + assert!(results.iter().any(|(k, _)| *k == "key2")); + } + + #[test] + fn test_search() { + let mut storage = StorageV5::new(); + storage.put( + "alpha".to_string(), + EncryptedValue::from_ciphertext(b"value1".to_vec()), + ); + storage.put( + "beta".to_string(), + EncryptedValue::from_ciphertext(b"value2".to_vec()), + ); + storage.put( + "gamma".to_string(), + EncryptedValue::from_ciphertext(b"value3".to_vec()), + ); + + let results: Vec<_> = storage.search(".*a.*").unwrap().collect(); + assert_eq!(results.len(), 2); // alpha, gamma + assert!(results.contains(&"alpha")); + assert!(results.contains(&"gamma")); + } + + #[test] + fn test_history() { + let mut storage = StorageV5::new(); + storage.put_ts( + "key".to_string(), + EncryptedValue::from_ciphertext(b"value1".to_vec()), + 100, + ); + storage.put_ts( + "key".to_string(), + EncryptedValue::from_ciphertext(b"value2".to_vec()), + 200, + ); + storage.put_ts( + "key".to_string(), + EncryptedValue::from_ciphertext(b"value3".to_vec()), + 300, + ); + + let history: Vec<_> = storage.history("key").unwrap().collect(); + assert_eq!(history.len(), 3); + assert_eq!(history[0].timestamp, 100); + assert_eq!(history[1].timestamp, 200); + assert_eq!(history[2].timestamp, 300); + } + + #[test] + fn test_delete() { + let mut storage = StorageV5::new(); + storage.put( + "key1".to_string(), + EncryptedValue::from_ciphertext(b"value1".to_vec()), + ); + storage.put( + "key2".to_string(), + EncryptedValue::from_ciphertext(b"value2".to_vec()), + ); + + assert!(storage.delete("key1")); + assert!(!storage.delete("key1")); // Already deleted + assert!(storage.delete("key2")); + + let results: Vec<_> = storage.get(".*").unwrap().collect(); + assert_eq!(results.len(), 0); + } +} diff --git a/src/storage/value.rs b/src/storage/value.rs index 7714303..4dfc01a 100644 --- a/src/storage/value.rs +++ b/src/storage/value.rs @@ -1,13 +1,14 @@ use std::fmt; use anyhow::Result; +use bincode::{Decode, Encode}; use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; use crate::crypto::Cypher; use crate::version::CypherVersion; -#[derive(Debug, Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize, Encode, Decode)] pub struct EncryptedValue { // Store encrypted bytes instead of plaintext ciphertext: Vec, diff --git a/src/version.rs b/src/version.rs index 304a13f..dfad79d 100644 --- a/src/version.rs +++ b/src/version.rs @@ -11,6 +11,7 @@ use crate::constants::{BLOCK_SIZE, BlockBytes, SaltBytes}; #[repr(u16)] pub enum StoreVersion { Version4 = 4u16, + Version5 = 5u16, } impl StoreVersion { diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 0bf7cfd..63d8e6b 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -1,5 +1,5 @@ -use rcypher::save_storage; -use rcypher::{Cypher, CypherVersion, EncryptedValue, EncryptionKey, Storage}; +use rcypher::save_storage_v5; +use rcypher::{Cypher, CypherVersion, EncryptedValue, EncryptionKey, StorageV5}; use std::fs; use std::path::Path; use std::path::PathBuf; @@ -369,7 +369,7 @@ fn test_upgrade_storage() { EncryptionKey::from_password(CypherVersion::LegacyWithoutKdf, "test_password").unwrap(); let legacy_cypher = Cypher::new(legacy_key); - let mut storage = Storage::new(); + let mut storage = StorageV5::new(); storage.put( "key1".to_string(), EncryptedValue::encrypt(&legacy_cypher, "value1").unwrap(), @@ -379,7 +379,7 @@ fn test_upgrade_storage() { EncryptedValue::encrypt(&legacy_cypher, "value2").unwrap(), ); - save_storage(&legacy_cypher, &storage, &storage_path).unwrap(); + save_storage_v5(&legacy_cypher, &storage, &storage_path).unwrap(); // Run upgrade command let mut cmd = Command::new(cargo::cargo_bin!("rcypher")); diff --git a/tests/storage_tests.rs b/tests/storage_v4_tests.rs similarity index 81% rename from tests/storage_tests.rs rename to tests/storage_v4_tests.rs index 62c80aa..4a3f55b 100644 --- a/tests/storage_tests.rs +++ b/tests/storage_v4_tests.rs @@ -12,13 +12,13 @@ fn temp_test_file() -> (TempDir, PathBuf) { #[test] fn test_storage_new() { - let storage = Storage::new(); + let storage = StorageV4::new(); assert_eq!(storage.data.len(), 0); } #[test] fn test_storage_put_get() { - let mut storage = Storage::new(); + let mut storage = StorageV4::new(); storage.put("key1".to_string(), "value1".into()); storage.put("key2".to_string(), "value2".into()); @@ -30,7 +30,7 @@ fn test_storage_put_get() { #[test] fn test_storage_put_multiple_values() { - let mut storage = Storage::new(); + let mut storage = StorageV4::new(); storage.put("key1".to_string(), "value1".into()); storage.put("key1".to_string(), "value2".into()); storage.put("key1".to_string(), "value3".into()); @@ -50,7 +50,7 @@ fn test_storage_put_multiple_values() { #[test] fn test_storage_get_with_regex() { - let mut storage = Storage::new(); + let mut storage = StorageV4::new(); storage.put("test1".to_string(), "value1".into()); storage.put("test2".to_string(), "value2".into()); storage.put("prod1".to_string(), "value3".into()); @@ -67,7 +67,7 @@ fn test_storage_get_with_regex() { #[test] fn test_storage_get_no_match() { - let mut storage = Storage::new(); + let mut storage = StorageV4::new(); storage.put("key1".to_string(), "value1".into()); let results: Vec<_> = storage.get("nonexistent").unwrap().collect(); @@ -76,7 +76,7 @@ fn test_storage_get_no_match() { #[test] fn test_storage_search() { - let mut storage = Storage::new(); + let mut storage = StorageV4::new(); storage.put("user_alice".to_string(), "value1".into()); storage.put("user_bob".to_string(), "value2".into()); storage.put("admin_charlie".to_string(), "value3".into()); @@ -92,7 +92,7 @@ fn test_storage_search() { #[test] fn test_storage_delete() { - let mut storage = Storage::new(); + let mut storage = StorageV4::new(); storage.put("key1".to_string(), "value1".into()); storage.put("key2".to_string(), "value2".into()); @@ -106,7 +106,7 @@ fn test_storage_delete() { #[test] fn test_storage_history() { use std::time::{SystemTime, UNIX_EPOCH}; - let mut storage = Storage::new(); + let mut storage = StorageV4::new(); storage.put("key1".to_string(), "v1".into()); let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -130,20 +130,20 @@ fn test_storage_history() { #[test] fn test_serialize_deserialize_empty() { - let storage = Storage::new(); - let serialized = serialize_storage(&storage); - let deserialized = deserialize_storage(&serialized).unwrap(); + let storage = StorageV4::new(); + let serialized = serialize_storage_v4(&storage); + let deserialized = deserialize_storage_v4(&serialized).unwrap(); assert_eq!(deserialized.data.len(), 0); } #[test] fn test_serialize_deserialize_single_entry() { - let mut storage = Storage::new(); + let mut storage = StorageV4::new(); storage.put("key1".to_string(), "value1".into()); - let serialized = serialize_storage(&storage); - let deserialized = deserialize_storage(&serialized).unwrap(); + let serialized = serialize_storage_v4(&storage); + let deserialized = deserialize_storage_v4(&serialized).unwrap(); assert_eq!(deserialized.data.len(), 1); let entry = &deserialized.data["key1"][0]; @@ -152,13 +152,13 @@ fn test_serialize_deserialize_single_entry() { #[test] fn test_serialize_deserialize_multiple_entries() { - let mut storage = Storage::new(); + let mut storage = StorageV4::new(); storage.put("key1".to_string(), "value1".into()); storage.put("key2".to_string(), "value2".into()); storage.put("key1".to_string(), "value1_updated".into()); - let serialized = serialize_storage(&storage); - let deserialized = deserialize_storage(&serialized).unwrap(); + let serialized = serialize_storage_v4(&storage); + let deserialized = deserialize_storage_v4(&serialized).unwrap(); assert_eq!(deserialized.data.len(), 2); assert_eq!(deserialized.data["key1"].len(), 2); @@ -167,12 +167,12 @@ fn test_serialize_deserialize_multiple_entries() { #[test] fn test_serialize_deserialize_unicode() { - let mut storage = Storage::new(); + let mut storage = StorageV4::new(); storage.put("ключ".to_string(), "значение".into()); storage.put("🔑".to_string(), "🎁".into()); - let serialized = serialize_storage(&storage); - let deserialized = deserialize_storage(&serialized).unwrap(); + let serialized = serialize_storage_v4(&storage); + let deserialized = deserialize_storage_v4(&serialized).unwrap(); assert_eq!(deserialized.data.len(), 2); assert_eq!( @@ -185,13 +185,13 @@ fn test_serialize_deserialize_unicode() { #[test] fn test_deserialize_corrupted_data() { // Too short - let result = deserialize_storage(&[0, 1, 2]); + let result = deserialize_storage_v4(&[0, 1, 2]); assert!(result.is_err()); // Invalid version let mut data = vec![0, 99]; // version 99 data.extend_from_slice(&[0, 0, 0, 1]); // count = 1 - let result = deserialize_storage(&data); + let result = deserialize_storage_v4(&data); assert!(result.is_err()); } @@ -201,14 +201,14 @@ fn test_load_save_storage() { let cypher = Cypher::new(EncryptionKey::for_file("test_password", &path).unwrap()); - let mut storage = Storage::new(); + let mut storage = StorageV4::new(); storage.put("key1".to_string(), "value1".into()); storage.put("key2".to_string(), "value2".into()); - save_storage(&cypher, &storage, &path).unwrap(); + save_storage_v4(&cypher, &storage, &path).unwrap(); assert!(path.exists()); - let loaded = load_storage(&cypher, &path).unwrap(); + let loaded = load_storage_v4(&cypher, &path).unwrap(); assert_eq!(loaded.data.len(), 2); assert_eq!(loaded.data["key1"][0].value.as_bytes(), "value1".as_bytes()); assert_eq!(loaded.data["key2"][0].value.as_bytes(), "value2".as_bytes()); @@ -220,7 +220,7 @@ fn test_load_nonexistent_file() { let cypher = Cypher::new(EncryptionKey::for_file("test_password", &path).unwrap()); - let storage = load_storage(&cypher, &path).unwrap(); + let storage = load_storage_v4(&cypher, &path).unwrap(); assert_eq!(storage.data.len(), 0); } @@ -230,19 +230,19 @@ fn test_load_with_wrong_password() { let cypher1 = Cypher::new(EncryptionKey::for_file("test_password", &path).unwrap()); let cypher2 = Cypher::new(EncryptionKey::for_file("test_password2", &path).unwrap()); - let mut storage = Storage::new(); + let mut storage = StorageV4::new(); storage.put("key1".to_string(), "value1".into()); - save_storage(&cypher1, &storage, &path).unwrap(); + save_storage_v4(&cypher1, &storage, &path).unwrap(); // Should fail or return garbage - let result = load_storage(&cypher2, &path); + let result = load_storage_v4(&cypher2, &path); assert!(result.is_err() || result.unwrap().data.is_empty()); } #[test] fn test_storage_ordering() { - let mut storage = Storage::new(); + let mut storage = StorageV4::new(); // Add keys in random order storage.put("zebra".to_string(), "z".into()); @@ -258,7 +258,7 @@ fn test_storage_ordering() { #[test] fn test_special_characters_in_keys() { - let mut storage = Storage::new(); + let mut storage = StorageV4::new(); storage.put("key-with-dash".to_string(), "value1".into()); storage.put("key_with_underscore".to_string(), "value2".into()); storage.put("key.with.dots".to_string(), "value3".into()); @@ -285,7 +285,7 @@ fn test_concurrent_operations() { use std::sync::{Arc, Mutex}; use std::thread; - let storage = Arc::new(Mutex::new(Storage::new())); + let storage = Arc::new(Mutex::new(StorageV4::new())); let mut handles = vec![]; for i in 0..10 { @@ -312,22 +312,22 @@ fn test_storage_persistence_across_sessions() { // Session 1: Create and save { - let mut storage = Storage::new(); + let mut storage = StorageV4::new(); storage.put("session1_key".to_string(), "session1_value".into()); - save_storage(&cypher, &storage, &path).unwrap(); + save_storage_v4(&cypher, &storage, &path).unwrap(); } // Session 2: Load and add { - let mut storage = load_storage(&cypher, &path).unwrap(); + let mut storage = load_storage_v4(&cypher, &path).unwrap(); assert_eq!(storage.data.len(), 1); storage.put("session2_key".to_string(), "session2_value".into()); - save_storage(&cypher, &storage, &path).unwrap(); + save_storage_v4(&cypher, &storage, &path).unwrap(); } // Session 3: Verify both keys exist { - let storage = load_storage(&cypher, &path).unwrap(); + let storage = load_storage_v4(&cypher, &path).unwrap(); assert_eq!(storage.data.len(), 2); assert!(storage.data.contains_key("session1_key")); assert!(storage.data.contains_key("session2_key")); @@ -336,14 +336,14 @@ fn test_storage_persistence_across_sessions() { #[test] fn test_empty_key_value() { - let mut storage = Storage::new(); + let mut storage = StorageV4::new(); storage.put("".to_string(), "value".into()); storage.put("key".to_string(), "".into()); assert_eq!(storage.data.len(), 2); - let serialized = serialize_storage(&storage); - let deserialized = deserialize_storage(&serialized).unwrap(); + let serialized = serialize_storage_v4(&storage); + let deserialized = deserialize_storage_v4(&serialized).unwrap(); assert_eq!(deserialized.data.len(), 2); assert_eq!( @@ -355,14 +355,14 @@ fn test_empty_key_value() { #[test] fn test_very_long_key_value() { - let mut storage = Storage::new(); + let mut storage = StorageV4::new(); let long_key = "k".repeat(10000); let long_value = "v".repeat(50000); storage.put(long_key.clone(), long_value.clone().into()); - let serialized = serialize_storage(&storage); - let deserialized = deserialize_storage(&serialized).unwrap(); + let serialized = serialize_storage_v4(&storage); + let deserialized = deserialize_storage_v4(&serialized).unwrap(); assert_eq!( deserialized.data[&long_key][0].value.as_bytes(), diff --git a/tests/storage_v5_tests.rs b/tests/storage_v5_tests.rs new file mode 100644 index 0000000..60b11d3 --- /dev/null +++ b/tests/storage_v5_tests.rs @@ -0,0 +1,393 @@ +use rcypher::*; +use std::ops::Add; +use std::path::PathBuf; +use tempfile::TempDir; + +// Helper to create a temporary test file +fn temp_test_file() -> (TempDir, PathBuf) { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("rcypher_test"); + (dir, path) +} + +#[test] +fn test_storage_new() { + let storage = StorageV5::new(); + assert_eq!(storage.root.secrets.len(), 0); +} + +#[test] +fn test_storage_put_get() { + let mut storage = StorageV5::new(); + storage.put("key1".to_string(), "value1".into()); + storage.put("key2".to_string(), "value2".into()); + + let results: Vec<_> = storage.get("key1").unwrap().collect(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].0.as_bytes(), "key1".as_bytes()); + assert_eq!(results[0].1.as_bytes(), "value1".as_bytes()); +} + +#[test] +fn test_storage_put_multiple_values() { + let mut storage = StorageV5::new(); + storage.put("key1".to_string(), "value1".into()); + storage.put("key1".to_string(), "value2".into()); + storage.put("key1".to_string(), "value3".into()); + + // get should return the latest value + let results: Vec<_> = storage.get("key1").unwrap().collect(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].1.as_bytes(), "value3".as_bytes()); + + // history should return all values + let history: Vec<_> = storage.history("key1").unwrap().collect(); + assert_eq!(history.len(), 3); + assert_eq!(history[0].encrypted_value().as_bytes(), "value1".as_bytes()); + assert_eq!(history[1].encrypted_value().as_bytes(), "value2".as_bytes()); + assert_eq!(history[2].encrypted_value().as_bytes(), "value3".as_bytes()); +} + +#[test] +fn test_storage_get_with_regex() { + let mut storage = StorageV5::new(); + storage.put("test1".to_string(), "value1".into()); + storage.put("test2".to_string(), "value2".into()); + storage.put("prod1".to_string(), "value3".into()); + + // Match all test keys + let results: Vec<_> = storage.get("test.*").unwrap().collect(); + assert_eq!(results.len(), 2); + + // Match specific key + let results: Vec<_> = storage.get("test1").unwrap().collect(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].0, "test1"); +} + +#[test] +fn test_storage_get_no_match() { + let mut storage = StorageV5::new(); + storage.put("key1".to_string(), "value1".into()); + + let results: Vec<_> = storage.get("nonexistent").unwrap().collect(); + assert_eq!(results.len(), 0); +} + +#[test] +fn test_storage_search() { + let mut storage = StorageV5::new(); + storage.put("user_alice".to_string(), "value1".into()); + storage.put("user_bob".to_string(), "value2".into()); + storage.put("admin_charlie".to_string(), "value3".into()); + + let keys: Vec<_> = storage.search("user_").unwrap().collect(); + assert_eq!(keys.len(), 2); + assert!(keys.contains(&"user_alice")); + assert!(keys.contains(&"user_bob")); + + let all_keys: Vec<_> = storage.search("").unwrap().collect(); + assert_eq!(all_keys.len(), 3); +} + +#[test] +fn test_storage_delete() { + let mut storage = StorageV5::new(); + storage.put("key1".to_string(), "value1".into()); + storage.put("key2".to_string(), "value2".into()); + + assert!(storage.delete("key1")); + assert_eq!(storage.root.secrets.len(), 1); + + assert!(!storage.delete("key1")); // Already deleted + assert!(!storage.delete("nonexistent")); +} + +#[test] +fn test_storage_history() { + use std::time::{SystemTime, UNIX_EPOCH}; + let mut storage = StorageV5::new(); + storage.put("key1".to_string(), "v1".into()); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + storage.put_ts("key1".to_string(), "v2".into(), timestamp.add(1)); + storage.put_ts("key1".to_string(), "v3".into(), timestamp.add(2)); + + let history: Vec<_> = storage.history("key1").unwrap().collect(); + assert_eq!(history.len(), 3); + + // Timestamps should be in ascending order + assert!(history[0].timestamp + 1 == history[1].timestamp); + assert!(history[1].timestamp + 1 == history[2].timestamp); + + // Values should be in order + assert_eq!(history[0].encrypted_value().as_bytes(), "v1".as_bytes()); + assert_eq!(history[1].encrypted_value().as_bytes(), "v2".as_bytes()); + assert_eq!(history[2].encrypted_value().as_bytes(), "v3".as_bytes()); +} + +#[test] +fn test_serialize_deserialize_empty() { + let storage = StorageV5::new(); + let serialized = serialize_storage_v5_to_vec(&storage).unwrap(); + let deserialized = deserialize_storage_v5_from_slice(&serialized).unwrap(); + + assert_eq!(deserialized.root.secrets.len(), 0); +} + +#[test] +fn test_serialize_deserialize_single_entry() { + let mut storage = StorageV5::new(); + storage.put("key1".to_string(), "value1".into()); + + let serialized = serialize_storage_v5_to_vec(&storage).unwrap(); + let deserialized = deserialize_storage_v5_from_slice(&serialized).unwrap(); + + assert_eq!(deserialized.root.secrets.len(), 1); + let entry = &deserialized.root.secrets["key1"][0]; + assert_eq!(entry.encrypted_value().as_bytes(), "value1".as_bytes()); +} + +#[test] +fn test_serialize_deserialize_multiple_entries() { + let mut storage = StorageV5::new(); + storage.put("key1".to_string(), "value1".into()); + storage.put("key2".to_string(), "value2".into()); + storage.put("key1".to_string(), "value1_updated".into()); + + let serialized = serialize_storage_v5_to_vec(&storage).unwrap(); + let deserialized = deserialize_storage_v5_from_slice(&serialized).unwrap(); + + assert_eq!(deserialized.root.secrets.len(), 2); + assert_eq!(deserialized.root.secrets["key1"].len(), 2); + assert_eq!(deserialized.root.secrets["key2"].len(), 1); +} + +#[test] +fn test_serialize_deserialize_unicode() { + let mut storage = StorageV5::new(); + storage.put("ключ".to_string(), "значение".into()); + storage.put("🔑".to_string(), "🎁".into()); + + let serialized = serialize_storage_v5_to_vec(&storage).unwrap(); + let deserialized = deserialize_storage_v5_from_slice(&serialized).unwrap(); + + assert_eq!(deserialized.root.secrets.len(), 2); + assert_eq!( + deserialized.root.secrets["ключ"][0] + .encrypted_value() + .as_bytes(), + "значение".as_bytes() + ); + assert_eq!( + deserialized.root.secrets["🔑"][0] + .encrypted_value() + .as_bytes(), + "🎁".as_bytes() + ); +} + +#[test] +fn test_deserialize_corrupted_data() { + // Too short + let result = deserialize_storage_v5_from_slice(&[0, 1, 2]); + assert!(result.is_err()); + + // Invalid version + let mut data = vec![0, 99]; // version 99 + data.extend_from_slice(&[0, 0, 0, 1]); // count = 1 + let result = deserialize_storage_v5_from_slice(&data); + assert!(result.is_err()); +} + +#[test] +fn test_load_save_storage_v5() { + let (_dir, path) = temp_test_file(); + + let cypher = Cypher::new(EncryptionKey::for_file("test_password", &path).unwrap()); + + let mut storage = StorageV5::new(); + storage.put("key1".to_string(), "value1".into()); + storage.put("key2".to_string(), "value2".into()); + + save_storage_v5(&cypher, &storage, &path).unwrap(); + assert!(path.exists()); + + let loaded = load_storage_v5(&cypher, &path).unwrap(); + assert_eq!(loaded.root.secrets.len(), 2); + assert_eq!( + loaded.root.secrets["key1"][0].encrypted_value().as_bytes(), + "value1".as_bytes() + ); + assert_eq!( + loaded.root.secrets["key2"][0].encrypted_value().as_bytes(), + "value2".as_bytes() + ); +} + +#[test] +fn test_load_nonexistent_file() { + let (_dir, path) = temp_test_file(); + + let cypher = Cypher::new(EncryptionKey::for_file("test_password", &path).unwrap()); + + let storage = load_storage_v5(&cypher, &path).unwrap(); + assert_eq!(storage.root.secrets.len(), 0); +} + +#[test] +fn test_load_with_wrong_password() { + let (_dir, path) = temp_test_file(); + let cypher1 = Cypher::new(EncryptionKey::for_file("test_password", &path).unwrap()); + let cypher2 = Cypher::new(EncryptionKey::for_file("test_password2", &path).unwrap()); + + let mut storage = StorageV5::new(); + storage.put("key1".to_string(), "value1".into()); + + save_storage_v5(&cypher1, &storage, &path).unwrap(); + + // Should fail or return garbage + let result = load_storage_v5(&cypher2, &path); + assert!(result.is_err() || result.unwrap().root.secrets.is_empty()); +} + +#[test] +fn test_storage_ordering() { + let mut storage = StorageV5::new(); + + // Add keys in random order + storage.put("zebra".to_string(), "z".into()); + storage.put("alpha".to_string(), "a".into()); + storage.put("beta".to_string(), "b".into()); + + // Search should return sorted + let keys: Vec<_> = storage.search("").unwrap().collect(); + assert_eq!(keys[0], "alpha"); + assert_eq!(keys[1], "beta"); + assert_eq!(keys[2], "zebra"); +} + +#[test] +fn test_special_characters_in_keys() { + let mut storage = StorageV5::new(); + storage.put("key-with-dash".to_string(), "value1".into()); + storage.put("key_with_underscore".to_string(), "value2".into()); + storage.put("key.with.dots".to_string(), "value3".into()); + storage.put("key@with@at".to_string(), "value4".into()); + + assert_eq!(storage.root.secrets.len(), 4); + + let result: Vec<_> = storage.get("key-with-dash").unwrap().collect(); + assert_eq!(result[0].1.as_bytes(), "value1".as_bytes()); + let result: Vec<_> = storage.get("key_with_underscore").unwrap().collect(); + assert_eq!(result[0].1.as_bytes(), "value2".as_bytes()); + let result: Vec<_> = storage.get("key.with.dots").unwrap().collect(); + assert_eq!(result[0].1.as_bytes(), "value3".as_bytes()); + let result: Vec<_> = storage.get("key@with@at").unwrap().collect(); + assert_eq!(result[0].1.as_bytes(), "value4".as_bytes()); + + // Dot in regex matches any character + let results: Vec<_> = storage.get("key.*").unwrap().collect(); + assert_eq!(results.len(), 4); +} + +#[test] +fn test_concurrent_operations() { + use std::sync::{Arc, Mutex}; + use std::thread; + + let storage = Arc::new(Mutex::new(StorageV5::new())); + let mut handles = vec![]; + + for i in 0..10 { + let storage_clone = Arc::clone(&storage); + let handle = thread::spawn(move || { + let mut s = storage_clone.lock().unwrap(); + s.put(format!("key{}", i), format!("value{}", i).into()); + }); + handles.push(handle); + } + + for handle in handles { + handle.join().unwrap(); + } + + let final_storage = storage.lock().unwrap(); + assert_eq!(final_storage.root.secrets.len(), 10); +} + +#[test] +fn test_storage_persistence_across_sessions() { + let (_dir, path) = temp_test_file(); + let cypher = Cypher::new(EncryptionKey::for_file("test_password", &path).unwrap()); + + // Session 1: Create and save + { + let mut storage = StorageV5::new(); + storage.put("session1_key".to_string(), "session1_value".into()); + save_storage_v5(&cypher, &storage, &path).unwrap(); + } + + // Session 2: Load and add + { + let mut storage = load_storage_v5(&cypher, &path).unwrap(); + assert_eq!(storage.root.secrets.len(), 1); + storage.put("session2_key".to_string(), "session2_value".into()); + save_storage_v5(&cypher, &storage, &path).unwrap(); + } + + // Session 3: Verify both keys exist + { + let storage = load_storage_v5(&cypher, &path).unwrap(); + assert_eq!(storage.root.secrets.len(), 2); + assert!(storage.root.secrets.contains_key("session1_key")); + assert!(storage.root.secrets.contains_key("session2_key")); + } +} + +#[test] +fn test_empty_key_value() { + let mut storage = StorageV5::new(); + storage.put("".to_string(), "value".into()); + storage.put("key".to_string(), "".into()); + + assert_eq!(storage.root.secrets.len(), 2); + + let serialized = serialize_storage_v5_to_vec(&storage).unwrap(); + let deserialized = deserialize_storage_v5_from_slice(&serialized).unwrap(); + + assert_eq!(deserialized.root.secrets.len(), 2); + assert_eq!( + deserialized.root.secrets[""][0] + .encrypted_value() + .as_bytes(), + "value".as_bytes() + ); + assert_eq!( + deserialized.root.secrets["key"][0] + .encrypted_value() + .as_bytes(), + "".as_bytes() + ); +} + +#[test] +fn test_very_long_key_value() { + let mut storage = StorageV5::new(); + let long_key = "k".repeat(10000); + let long_value = "v".repeat(50000); + + storage.put(long_key.clone(), long_value.clone().into()); + + let serialized = serialize_storage_v5_to_vec(&storage).unwrap(); + let deserialized = deserialize_storage_v5_from_slice(&serialized).unwrap(); + + assert_eq!( + deserialized.root.secrets[&long_key][0] + .encrypted_value() + .as_bytes(), + long_value.as_bytes() + ); +} From 5dababce2640119fea37069435be776ef53d6655 Mon Sep 17 00:00:00 2001 From: Roman Studenikin Date: Mon, 29 Dec 2025 17:34:54 +0000 Subject: [PATCH 02/12] Support forlder in the cli interface --- src/cli/completer.rs | 105 ++++++-- src/cli/interactive.rs | 163 ++++++++++--- src/cli/update.rs | 160 ++++++++++-- src/cli/utils.rs | 108 +++++++++ src/lib.rs | 3 +- src/main.rs | 11 +- src/storage/v5.rs | 410 ++++++++++++++++++++++++++----- tests/cli_tests.rs | 344 +++++++++++++++++++++++++- tests/storage_v5_tests.rs | 496 ++++++++++++++++++++++++++++++++++---- 9 files changed, 1598 insertions(+), 202 deletions(-) diff --git a/src/cli/completer.rs b/src/cli/completer.rs index b67d8c9..219e998 100644 --- a/src/cli/completer.rs +++ b/src/cli/completer.rs @@ -1,6 +1,7 @@ use std::sync::{Arc, Mutex}; use crate::StorageV5; +use crate::cli::utils::{format_full_path, parse_key_path}; use rustyline::Helper; use rustyline::completion::{Completer, Pair}; use rustyline::highlight::Highlighter; @@ -9,11 +10,15 @@ use rustyline::validate::Validator; pub struct CypherCompleter { storage: Arc>, + current_path: Arc>, } impl CypherCompleter { - pub const fn new(storage: Arc>) -> Self { - Self { storage } + pub const fn new(storage: Arc>, current_path: Arc>) -> Self { + Self { + storage, + current_path, + } } } @@ -33,7 +38,8 @@ impl Completer for CypherCompleter { if parts.is_empty() || (parts.len() == 1 && !line.ends_with(' ')) { // Complete commands let commands = [ - "put", "get", "copy", "history", "search", "del", "rm", "help", + "put", "get", "copy", "history", "search", "del", "rm", "mkdir", "cd", "pwd", + "help", ]; let prefix = parts.first().unwrap_or(&""); let matches: Vec = commands @@ -49,31 +55,90 @@ impl Completer for CypherCompleter { return Ok((start, matches)); } - // Complete keys for commands that need them + // Complete arguments for commands if !parts.is_empty() { let cmd = parts[0]; match cmd { + "cd" | "mkdir" => { + // Complete with folder names + if parts.len() == 1 || (parts.len() == 2 && !line.ends_with(' ')) { + let input = if parts.len() == 2 { parts[1] } else { "" }; + + let storage = self.storage.lock().expect("able to take a lock"); + let current_path = self.current_path.lock().expect("able to lock"); + + // Parse input to get resolved path and prefix + let (target_path, prefix) = parse_key_path(¤t_path, input); + + // Extract original dir part to preserve user's input style in completions + // (e.g., if user typed "work/api", complete as "work/api_key", not "/work/api_key") + let dir_part = input.strip_suffix(prefix).unwrap_or(""); + + let mut matches: Vec = Vec::new(); + if let Some(folder) = storage.get_folder(&target_path) { + for name in folder.subfolders.keys() { + if name.starts_with(prefix) { + let full_path = format_full_path(dir_part, name, true); + matches.push(Pair { + display: full_path.clone(), + replacement: full_path, + }); + } + } + } + drop(current_path); + drop(storage); + + matches.sort_by(|a, b| a.replacement.cmp(&b.replacement)); + + let start = pos - input.len(); + return Ok((start, matches)); + } + } "get" | "history" | "del" | "rm" | "put" | "copy" | "search" => { - // Only complete if we have exactly 1 argument (the command itself) - // or 2 arguments where the second is incomplete + // Complete with keys and folders from current directory if parts.len() == 1 || (parts.len() == 2 && !line.ends_with(' ')) { - let prefix = if parts.len() == 2 { parts[1] } else { "" }; + let input = if parts.len() == 2 { parts[1] } else { "" }; let storage = self.storage.lock().expect("able to take a lock"); - let mut keys: Vec = storage.root.secrets.keys().cloned().collect(); + let current_path = self.current_path.lock().expect("able to lock"); + + // Parse input to get resolved path and prefix + let (target_path, prefix) = parse_key_path(¤t_path, input); + + // Extract original dir part to preserve user's input style in completions + // (e.g., if user typed "work/api", complete as "work/api_key", not "/work/api_key") + let dir_part = input.strip_suffix(prefix).unwrap_or(""); + + let mut matches: Vec = Vec::new(); + if let Some(folder) = storage.get_folder(&target_path) { + // Add matching secret keys + for key in folder.secrets.keys() { + if key.starts_with(prefix) { + let full_path = format_full_path(dir_part, key, false); + matches.push(Pair { + display: full_path.clone(), + replacement: full_path, + }); + } + } + // Add matching folder names + for name in folder.subfolders.keys() { + if name.starts_with(prefix) { + let full_path = format_full_path(dir_part, name, true); + matches.push(Pair { + display: full_path.clone(), + replacement: full_path, + }); + } + } + } + drop(current_path); drop(storage); - keys.sort(); - - let matches: Vec = keys - .iter() - .filter(|key| key.starts_with(prefix)) - .map(|key| Pair { - display: key.clone(), - replacement: key.clone(), - }) - .collect(); - - let start = pos - prefix.len(); + + matches.sort_by(|a, b| a.replacement.cmp(&b.replacement)); + + let start = pos - input.len(); return Ok((start, matches)); } } diff --git a/src/cli/interactive.rs b/src/cli/interactive.rs index 52d47ac..f680f90 100644 --- a/src/cli/interactive.rs +++ b/src/cli/interactive.rs @@ -4,7 +4,10 @@ use crate::StorageV5; use crate::cli::CLIPBOARD_TTL_MS; use crate::cli::STANDBY_TIMEOUT; use crate::cli::completer::CypherCompleter; -use crate::cli::utils::{copy_to_clipboard, format_timestamp, secure_print}; +use crate::cli::utils::{ + copy_to_clipboard, format_full_path, format_timestamp, parse_key_path, resolve_path, + secure_print, +}; use crate::is_debugger_attached; use crate::load_storage_v5; use crate::save_storage_v5; @@ -23,24 +26,35 @@ pub struct InteractiveCli { insecure_stdout: bool, cypher: Cypher, filename: PathBuf, + current_path: Arc>, } impl InteractiveCli { - pub const fn new( - prompt: String, - insecure_stdout: bool, - cypher: Cypher, - filename: PathBuf, - ) -> Self { + pub fn new(prompt: String, insecure_stdout: bool, cypher: Cypher, filename: PathBuf) -> Self { Self { prompt, insecure_stdout, cypher, filename, + current_path: Arc::new(Mutex::new(String::from("/"))), } } - pub fn run(&self) -> Result<()> { + fn get_current_path(&self) -> String { + let path = self.current_path.lock().expect("able to lock"); + if path.is_empty() { + String::from("/") + } else { + path.clone() + } + } + + fn get_prompt(&self) -> String { + let path = self.current_path.lock().expect("able to lock"); + self.prompt.replace("%p", path.as_ref()) + } + + pub fn run(&mut self) -> Result<()> { let storage = Arc::new(Mutex::new(load_storage_v5(&self.cypher, &self.filename)?)); let config = Config::builder() @@ -51,7 +65,7 @@ impl InteractiveCli { .max_history_size(5)? .build(); - let completer = CypherCompleter::new(storage.clone()); + let completer = CypherCompleter::new(storage.clone(), self.current_path.clone()); let mut rl = Editor::with_config(config)?; rl.set_helper(Some(completer)); @@ -63,7 +77,8 @@ impl InteractiveCli { bail!("Debugger detected"); } - let readline = rl.readline(&self.prompt); + let prompt = self.get_prompt(); + let readline = rl.readline(&prompt); // Check timeout if last_use_time @@ -147,6 +162,20 @@ impl InteractiveCli { } self.cmd_delete(parts[1], storage) } + "mkdir" => { + if parts.len() < 2 { + bail!("syntax: mkdir FOLDER_NAME"); + } + self.cmd_mkdir(parts[1], storage) + } + "cd" => { + let path = if parts.len() > 1 { parts[1] } else { "/" }; + self.cmd_cd(path, storage) + } + "pwd" => { + self.cmd_pwd(); + Ok(()) + } "help" => { print_help(); Ok(()) @@ -159,7 +188,17 @@ impl InteractiveCli { fn cmd_put(&self, key: &str, value: &str, storage: &mut StorageV5) -> Result<()> { let encrypted_value = EncryptedValue::encrypt(&self.cypher, value)?; - storage.put(key.to_string(), encrypted_value); + let (folder_path, key_name) = parse_key_path(&self.get_current_path(), key); + let timestamp = SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("time should go forward") + .as_secs(); + storage.put_at_path( + &folder_path, + key_name.to_string(), + encrypted_value, + timestamp, + ); secure_print(format!("{key} stored"), self.insecure_stdout)?; @@ -168,13 +207,19 @@ impl InteractiveCli { } fn cmd_get(&self, pattern: &str, storage: &StorageV5) -> Result<()> { - match storage.get(pattern) { + let (folder_path, key_pattern) = parse_key_path(&self.get_current_path(), pattern); + match storage.get_at_path(&folder_path, key_pattern, true) { + // Recursive search Ok(results) => { let mut found = false; - for (key, val) in results { + for (folder_path, key, val) in results { found = true; let mut secret = val.decrypt(&self.cypher)?; - let output = format!("{}: {}", key, &*secret); + let output = format!( + "{}: {}", + format_full_path(&folder_path, key, false), + &*secret + ); secret.zeroize(); secure_print(output, self.insecure_stdout)?; } @@ -188,23 +233,33 @@ impl InteractiveCli { } fn cmd_copy(&self, key: &str, storage: &StorageV5) -> Result<()> { - match storage.get(key) { + let current_path = self.get_current_path(); + match storage.get_at_path(¤t_path, key, true) { Ok(mut results) => { let first = results.next(); let second = results.next(); match (first, second) { (None, _) => bail!("No key '{key}' found!"), - (Some((first_key, _)), Some((second_key, _))) => { + (Some((first_path, first_key, _)), Some((second_path, second_key, _))) => { // Multiple results - print all println!("Multiple keys found! Plese specify exact key name:"); - secure_print(first_key.to_string(), self.insecure_stdout)?; - secure_print(second_key.to_string(), self.insecure_stdout)?; - for (key, _) in results { - secure_print(key.to_string(), self.insecure_stdout)?; + secure_print( + format_full_path(&first_path, first_key, false), + self.insecure_stdout, + )?; + secure_print( + format_full_path(&second_path, second_key, false), + self.insecure_stdout, + )?; + for (folder_path, key, _) in results { + secure_print( + format_full_path(&folder_path, key, false), + self.insecure_stdout, + )?; } } - (Some((_, val)), None) => { + (Some((_, _, val)), None) => { // Exactly one result - copy to clipboard let mut secret = val.decrypt(&self.cypher)?; copy_to_clipboard( @@ -221,7 +276,8 @@ impl InteractiveCli { } fn cmd_history(&self, key: &str, storage: &StorageV5) -> Result<()> { - if let Some(entries) = storage.history(key) { + let (folder_path, key_name) = parse_key_path(&self.get_current_path(), key); + if let Some(entries) = storage.history_at_path(&folder_path, key_name) { for entry in entries { let mut secret = entry.encrypted_value().decrypt(&self.cypher)?; let output = format!("[{}]: {}", format_timestamp(entry.timestamp), &*secret); @@ -235,10 +291,15 @@ impl InteractiveCli { } fn cmd_search(&self, pattern: &str, storage: &StorageV5) -> Result<()> { - match storage.search(pattern) { + let (folder_path, key_pattern) = parse_key_path(&self.get_current_path(), pattern); + match storage.search_at_path(&folder_path, key_pattern, true) { + // Recursive search Ok(keys) => { - for key in keys { - secure_print(key.to_string(), self.insecure_stdout)?; + for (folder_path, key) in keys { + secure_print( + format_full_path(&folder_path, key, false), + self.insecure_stdout, + )?; } } Err(e) => bail!("Error: {e}"), @@ -247,7 +308,8 @@ impl InteractiveCli { } fn cmd_delete(&self, key: &str, storage: &mut StorageV5) -> Result<()> { - if storage.delete(key) { + let (folder_path, key_name) = parse_key_path(&self.get_current_path(), key); + if storage.delete_at_path(&folder_path, key_name) { secure_print(format!("{key} deleted"), self.insecure_stdout)?; save_storage_v5(&self.cypher, storage, &self.filename)?; } else { @@ -255,6 +317,34 @@ impl InteractiveCli { } Ok(()) } + + fn cmd_mkdir(&self, folder_name: &str, storage: &mut StorageV5) -> Result<()> { + let (parent_path, new_folder_name) = parse_key_path(&self.get_current_path(), folder_name); + storage.mkdir(&parent_path, new_folder_name)?; + secure_print( + format!("Folder '{folder_name}' created"), + self.insecure_stdout, + )?; + save_storage_v5(&self.cypher, storage, &self.filename)?; + Ok(()) + } + + fn cmd_cd(&self, path: &str, storage: &StorageV5) -> Result<()> { + let current = self.get_current_path(); + let new_path = resolve_path(¤t, path); + + // Verify the folder exists + if storage.get_folder(&new_path).is_none() { + bail!("Folder '{new_path}' not found"); + } + + *self.current_path.lock().expect("able to lock") = new_path; + Ok(()) + } + + fn cmd_pwd(&self) { + println!("{}", self.get_current_path()); + } } fn clear_screen() { @@ -262,12 +352,19 @@ fn clear_screen() { } fn print_help() { - println!("USER COMMANDS:"); - println!(" put KEY VAL - Store a key-value pair"); - println!(" get REGEXP - Get values for keys matching regexp"); - println!(" copy KEY - Copy key value into system clipboard"); - println!(" history KEY - Show history of changes for a key"); - println!(" search REGEXP - Search for keys matching regexp"); - println!(" del|rm KEY - Delete a key"); + println!("BASE COMMANDS:"); + println!(" put KEY VAL - Store a key-value pair in current folder"); + println!(" get REGEXP - Get values for keys matching regexp (recursive)"); + println!(" copy KEY - Copy key value into system clipboard (recursive)"); + println!(" history KEY - Show history of changes for a key in current folder"); + println!(" search REGEXP - Search for keys matching regexp (recursive)"); + println!(" del|rm KEY - Delete a key from current folder"); + println!(); + println!("FOLDER COMMANDS:"); + println!(" mkdir FOLDER - Create a new folder in current directory"); + println!(" cd [PATH] - Change to directory (use .. to go up, / for root)"); + println!(" pwd - Print current working directory"); + println!(); + println!("OTHER:"); println!(" help - Show this help"); } diff --git a/src/cli/update.rs b/src/cli/update.rs index 1246973..25d5e9b 100644 --- a/src/cli/update.rs +++ b/src/cli/update.rs @@ -8,6 +8,7 @@ use zeroize::Zeroize; #[derive(Debug)] struct UpdateEntry { + folder_path: String, key: String, new_value: EncryptedValue, new_timestamp: u64, @@ -21,21 +22,25 @@ impl UpdateEntry { } } -/// Find entries that need updating by comparing latest values from both storages -fn find_updates( +/// Recursively find updates in a folder and its subfolders +fn find_updates_in_folder( + folder_path: &str, main_storage: &StorageV5, update_storage: &StorageV5, main_cypher: &Cypher, update_cypher: &Cypher, -) -> Vec { - let mut updates = Vec::new(); - - for (key, update_entries) in &update_storage.root.secrets { + updates: &mut Vec, +) { + let Some(update_folder) = update_storage.get_folder(folder_path) else { + return; + }; + + // Check secrets in this folder + for (key, update_entries) in &update_folder.secrets { let update_latest = update_entries.last().expect("entries should not be empty"); let main_latest = main_storage - .root - .secrets - .get(key) + .get_folder(folder_path) + .and_then(|f| f.secrets.get(key)) .and_then(|entries| entries.last()); let should_update = main_latest.is_none_or(|main_entry| { @@ -55,6 +60,7 @@ fn find_updates( if should_update { updates.push(UpdateEntry { + folder_path: folder_path.to_string(), key: key.clone(), new_value: update_latest.encrypted_value().clone(), new_timestamp: update_latest.timestamp, @@ -64,8 +70,49 @@ fn find_updates( } } - // Sort by key name for consistent presentation - updates.sort_by(|a, b| a.key.cmp(&b.key)); + // Recursively check subfolders + for subfolder_name in update_folder.subfolders.keys() { + let subfolder_path = if folder_path == "/" { + format!("/{subfolder_name}") + } else { + format!("{folder_path}/{subfolder_name}") + }; + find_updates_in_folder( + &subfolder_path, + main_storage, + update_storage, + main_cypher, + update_cypher, + updates, + ); + } +} + +/// Find entries that need updating by comparing latest values from both storages +fn find_updates( + main_storage: &StorageV5, + update_storage: &StorageV5, + main_cypher: &Cypher, + update_cypher: &Cypher, +) -> Vec { + let mut updates = Vec::new(); + + // Start recursive search from root + find_updates_in_folder( + "/", + main_storage, + update_storage, + main_cypher, + update_cypher, + &mut updates, + ); + + // Sort by folder path then key name for consistent presentation + updates.sort_by(|a, b| { + a.folder_path + .cmp(&b.folder_path) + .then_with(|| a.key.cmp(&b.key)) + }); updates } @@ -76,13 +123,16 @@ fn display_update_entry( update_cypher: &Cypher, insecure_stdout: bool, ) -> Result<()> { + use crate::cli::utils::format_full_path; + let full_path = format_full_path(&update.folder_path, &update.key, false); + // Compact format for summary view if update.is_new_key() { let new_decrypted = update.new_value.decrypt(update_cypher)?; secure_print( format!( " [NEW] {}\n New: {} ({})", - update.key, + full_path, &*new_decrypted, format_timestamp(update.new_timestamp) ), @@ -98,7 +148,7 @@ fn display_update_entry( secure_print( format!( " [CONFLICT] {}\n Current: {} ({})\n Update: {} ({})", - update.key, + full_path, &*old_decrypted, format_timestamp(update.old_timestamp.expect("old timestamp exists")), &*new_decrypted, @@ -148,6 +198,34 @@ fn display_update_summary( Ok((new_keys, conflicts)) } +/// Ensure all parent folders exist for a given path, creating them if needed +fn ensure_folder_path(storage: &mut StorageV5, path: &str) -> Result<()> { + if path == "/" || path.is_empty() { + return Ok(()); + } + + let parts: Vec<&str> = path.trim_matches('/').split('/').collect(); + let mut current_path = String::from("/"); + + for part in parts { + // Check if folder exists + let folder_path = if current_path == "/" { + format!("/{part}") + } else { + format!("{current_path}/{part}") + }; + + if storage.get_folder(&folder_path).is_none() { + // Create the folder + storage.mkdir(¤t_path, part)?; + } + + current_path = folder_path; + } + + Ok(()) +} + /// Apply all updates at once fn apply_all_updates( updates: Vec, @@ -160,7 +238,16 @@ fn apply_all_updates( let mut decrypted = update.new_value.decrypt(update_cypher)?; let re_encrypted = EncryptedValue::encrypt(main_cypher, &decrypted)?; decrypted.zeroize(); - main_storage.put_ts(update.key, re_encrypted, update.new_timestamp); + + // Ensure folder path exists before putting the key + ensure_folder_path(main_storage, &update.folder_path)?; + + main_storage.put_at_path( + &update.folder_path, + update.key, + re_encrypted, + update.new_timestamp, + ); } save_storage_v5(main_cypher, main_storage, filename)?; @@ -194,7 +281,16 @@ fn apply_updates_interactive( let mut decrypted = update.new_value.decrypt(update_cypher)?; let re_encrypted = EncryptedValue::encrypt(main_cypher, &decrypted)?; decrypted.zeroize(); - main_storage.put_ts(update.key, re_encrypted, update.new_timestamp); + + // Ensure folder path exists before putting the key + ensure_folder_path(main_storage, &update.folder_path)?; + + main_storage.put_at_path( + &update.folder_path, + update.key, + re_encrypted, + update.new_timestamp, + ); applied += 1; println!("✓ Applied"); } @@ -308,6 +404,7 @@ mod tests { let value = EncryptedValue::encrypt(&cypher, "test").unwrap(); let new_entry = UpdateEntry { + folder_path: "/".to_string(), key: "key1".to_string(), new_value: value.clone(), new_timestamp: 100, @@ -318,6 +415,7 @@ mod tests { assert!(new_entry.is_new_key()); let existing_entry = UpdateEntry { + folder_path: "/".to_string(), key: "key2".to_string(), new_value: value.clone(), new_timestamp: 100, @@ -335,17 +433,23 @@ mod tests { let mut update_storage = StorageV5::new(); // Main has key1, update has key1 and key2 - main_storage.put( + main_storage.put_at_path( + "/", "key1".to_string(), EncryptedValue::encrypt(&cypher, "value1").unwrap(), + 0, ); - update_storage.put( + update_storage.put_at_path( + "/", "key1".to_string(), EncryptedValue::encrypt(&cypher, "value1").unwrap(), + 0, ); - update_storage.put( + update_storage.put_at_path( + "/", "key2".to_string(), EncryptedValue::encrypt(&cypher, "value2").unwrap(), + 0, ); let updates = find_updates(&main_storage, &update_storage, &cypher, &cypher); @@ -362,12 +466,14 @@ mod tests { let mut update_storage = StorageV5::new(); // Both have key1 but with different values - main_storage.put_ts( + main_storage.put_at_path( + "/", "key1".to_string(), EncryptedValue::encrypt(&cypher, "old_value").unwrap(), 100, ); - update_storage.put_ts( + update_storage.put_at_path( + "/", "key1".to_string(), EncryptedValue::encrypt(&cypher, "new_value").unwrap(), 200, @@ -387,13 +493,17 @@ mod tests { let mut update_storage = StorageV5::new(); // Both have same key with same value - main_storage.put( + main_storage.put_at_path( + "/", "key1".to_string(), EncryptedValue::encrypt(&cypher, "value1").unwrap(), + 0, ); - update_storage.put( + update_storage.put_at_path( + "/", "key1".to_string(), EncryptedValue::encrypt(&cypher, "value1").unwrap(), + 0, ); let updates = find_updates(&main_storage, &update_storage, &cypher, &cypher); @@ -408,13 +518,15 @@ mod tests { let mut update_storage = StorageV5::new(); // Update has older value - update_storage.put_ts( + update_storage.put_at_path( + "/", "key1".to_string(), EncryptedValue::encrypt(&cypher, "old_value").unwrap(), 100, ); - main_storage.put_ts( + main_storage.put_at_path( + "/", "key1".to_string(), EncryptedValue::encrypt(&cypher, "new_value").unwrap(), 200, diff --git a/src/cli/utils.rs b/src/cli/utils.rs index 4d5ab25..87cf738 100644 --- a/src/cli/utils.rs +++ b/src/cli/utils.rs @@ -20,6 +20,114 @@ pub fn format_timestamp(ts: u64) -> String { dt.format("%Y-%m-%d %H:%M:%S").to_string() } +/// Format a full path from folder path and key +/// Handles both absolute paths ("/", "/work") and relative paths ("", "work/", "../work") +/// If `is_folder` is true, appends a trailing "/" to the result +pub fn format_full_path(folder_path: &str, key: &str, is_folder: bool) -> String { + let path = if folder_path.is_empty() { + key.to_string() + } else if folder_path == "/" { + format!("/{key}") + } else { + format!("{}/{key}", folder_path.trim_end_matches('/')) + }; + + if is_folder { format!("{path}/") } else { path } +} + +/// Compute relative path from root to path +/// Examples: +/// - `relative_path_from`("/", "/work") = "work" +/// - `relative_path_from`("/", "/work/api") = "work/api" +/// - `relative_path_from("/work`", "/work/api") = "api" +/// - `relative_path_from`("/", "/") = "" +pub fn relative_path_from(root: &str, path: &str) -> String { + if path == root { + String::new() + } else if root == "/" { + path.strip_prefix('/').unwrap_or(path).to_string() + } else { + path.strip_prefix(root) + .and_then(|p| p.strip_prefix('/')) + .unwrap_or("") + .to_string() + } +} + +/// Resolve a path (absolute or relative) from a given current directory +pub fn resolve_path(current_path: &str, path: &str) -> String { + if path.is_empty() { + return current_path.to_string(); + } + + if path.starts_with('/') { + // Absolute path + return normalize_path(path); + } + + // Relative path - resolve from current directory + let mut components: Vec<&str> = if current_path == "/" { + Vec::new() + } else { + current_path.trim_matches('/').split('/').collect() + }; + + // Process each component of the path + for component in path.trim_end_matches('/').split('/') { + match component { + "" | "." => {} + ".." => { + components.pop(); + } + name => { + components.push(name); + } + } + } + + if components.is_empty() { + String::from("/") + } else { + format!("/{}", components.join("/")) + } +} + +/// Normalize an absolute path by resolving . and .. components +pub fn normalize_path(path: &str) -> String { + let mut components: Vec<&str> = Vec::new(); + + for component in path.split('/') { + match component { + "" | "." => {} + ".." => { + components.pop(); + } + name => { + components.push(name); + } + } + } + + if components.is_empty() { + String::from("/") + } else { + format!("/{}", components.join("/")) + } +} + +/// Parse a key argument that may include a path (e.g., "`work/api_key`") +/// Returns (`resolved_folder_path`, `key_name`) +pub fn parse_key_path<'a>(current_path: &str, key_arg: &'a str) -> (String, &'a str) { + if let Some(last_slash) = key_arg.rfind('/') { + let dir_part = &key_arg[..last_slash]; + let key_name = &key_arg[last_slash + 1..]; + let resolved_path = resolve_path(current_path, dir_part); + (resolved_path, key_name) + } else { + (current_path.to_string(), key_arg) + } +} + /// Prints directly to tty to avoid /// - snooping passwords from process stdout /// - lingering passwords in memory diff --git a/src/lib.rs b/src/lib.rs index 8c1f900..da510ac 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,7 +14,8 @@ clippy::missing_errors_doc, clippy::must_use_candidate, clippy::multiple_crate_versions, - clippy::missing_panics_doc + clippy::missing_panics_doc, + clippy::option_if_let_else )] // Module declarations diff --git a/src/main.rs b/src/main.rs index 80924b2..d8af1e5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -14,7 +14,8 @@ clippy::missing_errors_doc, clippy::must_use_candidate, clippy::multiple_crate_versions, - clippy::missing_panics_doc + clippy::missing_panics_doc, + clippy::option_if_let_else )] use anyhow::{Result, bail}; @@ -79,7 +80,9 @@ struct CliParams { #[arg(long, action, default_value_t = false, hide(true))] quiet: bool, - #[arg(long, default_value = "cypher > ")] + /// Prompt for interactive mode. + /// %p is replaced with the current folder path + #[arg(long, default_value = "cypher : %p > ")] prompt: String, /// Upgrade file with stored secrets to the latest supported encryption format. The file will @@ -158,7 +161,7 @@ fn run_upgrade_storage( for entry in entries { let mut secret = entry.encrypted_value().decrypt(&old_cypher)?; let new_value = EncryptedValue::encrypt(&new_cypher, &secret)?; - new_storage.put_ts(key.clone(), new_value, entry.timestamp); + new_storage.put_at_path("/", key.clone(), new_value, entry.timestamp); secret.zeroize(); } } @@ -172,7 +175,7 @@ fn run_upgrade_storage( fn run_interactive(params: &CliParams, key: EncryptionKey) -> Result<()> { let cypher = Cypher::new(key); - let interactive_cli = cli::InteractiveCli::new( + let mut interactive_cli = cli::InteractiveCli::new( params.prompt.clone(), params.insecure_stdout, cypher, diff --git a/src/storage/v5.rs b/src/storage/v5.rs index a042e9a..6ce7a4f 100644 --- a/src/storage/v5.rs +++ b/src/storage/v5.rs @@ -1,12 +1,12 @@ use std::collections::{BTreeMap, HashMap}; use std::io::{Read, Write}; -use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{Result, bail}; use bincode::{Decode, Encode, config}; use regex::Regex; use super::value::EncryptedValue; +use crate::cli::utils::{format_full_path, relative_path_from}; use crate::version::StoreVersion; // ============================================================================ @@ -28,70 +28,341 @@ impl StorageV5 { } } - /// Store a secret value with current timestamp - /// For V5, stores in root folder with default encryption domain (0) - pub fn put(&mut self, key: String, value: EncryptedValue) { - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("time should go forward") - .as_secs(); + /// Get a folder by path (e.g., "/" or "/work/personal") + pub fn get_folder(&self, path: &str) -> Option<&Folder> { + if path == "/" || path.is_empty() { + return Some(&self.root); + } + + let parts: Vec<&str> = path.trim_matches('/').split('/').collect(); + let mut current = &self.root; + + for part in parts { + current = current.subfolders.get(part)?; + } + + Some(current) + } + + /// Get a mutable folder by path + pub fn get_folder_mut(&mut self, path: &str) -> Option<&mut Folder> { + if path == "/" || path.is_empty() { + return Some(&mut self.root); + } + + let parts: Vec<&str> = path.trim_matches('/').split('/').collect(); + let mut current = &mut self.root; + + for part in parts { + current = current.subfolders.get_mut(part)?; + } + + Some(current) + } + + /// Create a new folder at the given path + pub fn mkdir(&mut self, path: &str, folder_name: &str) -> Result<()> { + let parent = self + .get_folder_mut(path) + .ok_or_else(|| anyhow::anyhow!("Parent folder '{path}' not found"))?; + + if parent.subfolders.contains_key(folder_name) { + bail!("Folder '{folder_name}' already exists"); + } + + parent.subfolders.insert( + folder_name.to_string(), + Folder::new(folder_name.to_string(), parent.encryption_domain), + ); - self.put_ts(key, value, timestamp); + Ok(()) } - /// Store a secret value with a specific timestamp - pub fn put_ts(&mut self, key: String, value: EncryptedValue, timestamp: u64) { - self.root - .secrets - .entry(key) - .or_default() - .push(SecretEntry::new_plain(value, timestamp, 0)); + /// Store a secret value at a specific path + pub fn put_at_path(&mut self, path: &str, key: String, value: EncryptedValue, timestamp: u64) { + if let Some(folder) = self.get_folder_mut(path) { + folder + .secrets + .entry(key) + .or_default() + .push(SecretEntry::new_plain(value, timestamp, 0)); + } } - /// Returns an iterator over key-value pairs matching the given regex pattern. + /// Returns an iterator over key-value pairs matching the given regex pattern in root folder. /// /// # Sorting /// - Keys are returned in sorted order (guaranteed by `BTreeMap`) /// - Returns the latest value for each key (entries sorted by timestamp) - pub fn get(&self, pattern: &str) -> Result + '_> { + /// + /// Returns (`full_path`, key, value) tuples where `full_path` is like "/work/personal" + pub fn get( + &self, + pattern: &str, + ) -> Result + '_> { + self.get_at_path("/", pattern, false) + } + + /// Returns an iterator over key-value pairs matching pattern at a specific path. + /// If recursive is true, searches through all subfolders. + /// Returns (`full_path`, key, value) tuples where `full_path` is like "/work/personal" + pub fn get_at_path( + &self, + path: &str, + pattern: &str, + recursive: bool, + ) -> Result + '_> { let re = Regex::new(&format!("^{pattern}$"))?; - Ok(self - .root - .secrets - .iter() - .filter(move |(k, entries)| re.is_match(k) && !entries.is_empty()) - .filter_map(|(k, entries)| { - entries - .last() - .map(|entry| (k.as_str(), entry.encrypted_value())) - })) + let folder = self + .get_folder(path) + .ok_or_else(|| anyhow::anyhow!("Folder '{path}' not found"))?; + + let normalized_path = if path == "/" || path.is_empty() { + String::from("/") + } else { + path.to_string() + }; + + Ok(RecursiveSecretIterator::new( + folder, + re, + recursive, + normalized_path, + )) } - /// Returns an iterator over all historical values for a given key. + /// Returns an iterator over all historical values for a given key in root folder. /// /// # Sorting /// Entries are returned in chronological order (oldest to newest). pub fn history(&self, key: &str) -> Option + '_> { - self.root.secrets.get(key).map(|entries| entries.iter()) + self.history_at_path("/", key) } - /// Delete a key and all its history + /// Returns an iterator over all historical values for a given key at a specific path. + pub fn history_at_path( + &self, + path: &str, + key: &str, + ) -> Option + '_> { + self.get_folder(path) + .and_then(|folder| folder.secrets.get(key)) + .map(|entries| entries.iter()) + } + + /// Delete a key and all its history from root folder pub fn delete(&mut self, key: &str) -> bool { - self.root.secrets.remove(key).is_some() + self.delete_at_path("/", key) + } + + /// Delete a key and all its history from a specific path + pub fn delete_at_path(&mut self, path: &str, key: &str) -> bool { + self.get_folder_mut(path) + .and_then(|folder| folder.secrets.remove(key)) + .is_some() } - /// Returns an iterator over all keys matching the given regex pattern. + /// Returns an iterator over all keys matching the given regex pattern in root folder. /// /// # Sorting /// Keys are returned in sorted order (guaranteed by `BTreeMap`). - pub fn search(&self, pattern: &str) -> Result + '_> { + /// Returns (`folder_path`, key) tuples where `folder_path` is like "/work/personal" + pub fn search(&self, pattern: &str) -> Result + '_> { + self.search_at_path("/", pattern, false) + } + + /// Returns an iterator over all keys matching pattern at a specific path. + /// If recursive is true, searches through all subfolders. + /// Returns (`folder_path`, key) tuples where `folder_path` is like "/work/personal" + pub fn search_at_path( + &self, + path: &str, + pattern: &str, + recursive: bool, + ) -> Result + '_> { let re = Regex::new(pattern)?; - Ok(self - .root - .secrets - .keys() - .filter(move |k| re.is_match(k)) - .map(String::as_str)) + let folder = self + .get_folder(path) + .ok_or_else(|| anyhow::anyhow!("Folder '{path}' not found"))?; + + let normalized_path = if path == "/" || path.is_empty() { + String::from("/") + } else { + path.to_string() + }; + + Ok(RecursiveKeyIterator::new( + folder, + re, + recursive, + normalized_path, + )) + } +} + +// ============================================================================ +// Recursive Iterators - Zero-copy iteration through folder hierarchies +// ============================================================================ + +type SecretsIterator<'a> = std::collections::btree_map::Iter<'a, String, Vec>; +type KeysIterator<'a> = std::collections::btree_map::Keys<'a, String, Vec>; +type FolderIterator<'a> = std::collections::btree_map::Iter<'a, String, Folder>; +/// Iterator over secrets in a folder and optionally its subfolders +pub struct RecursiveSecretIterator<'a> { + // Stack of (current_path, folder, secrets_iter) for depth-first traversal + stack: Vec<(String, &'a Folder, SecretsIterator<'a>)>, + regex: Regex, + recursive: bool, + // For tracking subfolders to visit - stores (current_path, subfolders_iter) + subfolders_stack: Vec<(String, FolderIterator<'a>)>, + // Search root for computing relative paths in regex matching + search_root: String, +} + +impl<'a> RecursiveSecretIterator<'a> { + fn new(folder: &'a Folder, regex: Regex, recursive: bool, initial_path: String) -> Self { + let secrets_iter = folder.secrets.iter(); + let subfolders_iter = folder.subfolders.iter(); + + Self { + stack: vec![(initial_path.clone(), folder, secrets_iter)], + regex, + recursive, + subfolders_stack: vec![(initial_path.clone(), subfolders_iter)], + search_root: initial_path, + } + } +} + +impl<'a> Iterator for RecursiveSecretIterator<'a> { + type Item = (String, &'a str, &'a EncryptedValue); + + fn next(&mut self) -> Option { + loop { + // Try to get next secret from current folder + if let Some((current_path, _, secrets_iter)) = self.stack.last_mut() { + let path = current_path.clone(); + // Compute relative path from search root for regex matching + let relative_folder = relative_path_from(&self.search_root, &path); + + for (key, entries) in secrets_iter.by_ref() { + // Match regex against full relative path (folder + key) + let full_path = format_full_path(&relative_folder, key, false); + if self.regex.is_match(&full_path) + && !entries.is_empty() + && let Some(entry) = entries.last() + { + return Some((path, key.as_str(), entry.encrypted_value())); + } + } + } + + // Current folder exhausted, try to descend into subfolder + if self.recursive + && let Some((current_path, subfolders_iter)) = self.subfolders_stack.last_mut() + && let Some((subfolder_name, subfolder)) = subfolders_iter.next() + { + // Build path for the subfolder + let new_path = if current_path == "/" { + format!("/{subfolder_name}") + } else { + format!("{current_path}/{subfolder_name}") + }; + + // Push new folder onto stack + self.stack + .push((new_path.clone(), subfolder, subfolder.secrets.iter())); + self.subfolders_stack + .push((new_path, subfolder.subfolders.iter())); + continue; + } + + // No more subfolders, pop the stack + self.stack.pop(); + self.subfolders_stack.pop(); + + if self.stack.is_empty() { + return None; + } + } + } +} + +/// Iterator over keys in a folder and optionally its subfolders +pub struct RecursiveKeyIterator<'a> { + // Stack of (current_path, keys_iter) for tracking keys in each folder + stack: Vec<(String, KeysIterator<'a>)>, + regex: Regex, + recursive: bool, + // Stack of (current_path, folder, subfolders_iter) for descending into subfolders + folder_stack: Vec<(String, &'a Folder, FolderIterator<'a>)>, + // Search root for computing relative paths in regex matching + search_root: String, +} + +impl<'a> RecursiveKeyIterator<'a> { + fn new(folder: &'a Folder, regex: Regex, recursive: bool, initial_path: String) -> Self { + let keys_iter = folder.secrets.keys(); + let subfolders_iter = folder.subfolders.iter(); + + Self { + stack: vec![(initial_path.clone(), keys_iter)], + regex, + recursive, + folder_stack: vec![(initial_path.clone(), folder, subfolders_iter)], + search_root: initial_path, + } + } +} + +impl<'a> Iterator for RecursiveKeyIterator<'a> { + type Item = (String, &'a str); + + fn next(&mut self) -> Option { + loop { + // Try to get next key from current folder + if let Some((current_path, keys_iter)) = self.stack.last_mut() { + let path = current_path.clone(); + // Compute relative path from search root for regex matching + let relative_folder = relative_path_from(&self.search_root, &path); + + for key in keys_iter.by_ref() { + // Match regex against full relative path (folder + key) + let full_path = format_full_path(&relative_folder, key, false); + if self.regex.is_match(&full_path) { + return Some((path, key.as_str())); + } + } + } + + // Current folder exhausted, try to descend into subfolder + if self.recursive + && let Some((current_path, _, subfolders_iter)) = self.folder_stack.last_mut() + && let Some((subfolder_name, subfolder)) = subfolders_iter.next() + { + // Build path for the subfolder + let new_path = if current_path == "/" { + format!("/{subfolder_name}") + } else { + format!("{current_path}/{subfolder_name}") + }; + + // Push new folder onto stack + self.stack + .push((new_path.clone(), subfolder.secrets.keys())); + self.folder_stack + .push((new_path, subfolder, subfolder.subfolders.iter())); + continue; + } + + // No more subfolders, pop the stack + self.stack.pop(); + self.folder_stack.pop(); + + if self.stack.is_empty() || self.folder_stack.is_empty() { + return None; + } + } } } @@ -478,7 +749,7 @@ mod tests { fn test_put_and_get() { let mut storage = StorageV5::new(); let test_value = EncryptedValue::from_ciphertext(b"test_value".to_vec()); - storage.put("test_key".to_string(), test_value); + storage.put_at_path("/", "test_key".to_string(), test_value, 0); let results: Vec<_> = storage.get("test_key").unwrap().collect(); assert_eq!(results.len(), 1); @@ -489,61 +760,84 @@ mod tests { #[test] fn test_get_with_pattern() { let mut storage = StorageV5::new(); - storage.put( + storage.put_at_path( + "/", "key1".to_string(), EncryptedValue::from_ciphertext(b"value1".to_vec()), + 0, ); - storage.put( + storage.put_at_path( + "/", "key2".to_string(), EncryptedValue::from_ciphertext(b"value2".to_vec()), + 0, ); - storage.put( + storage.put_at_path( + "/", "other".to_string(), EncryptedValue::from_ciphertext(b"value3".to_vec()), + 0, ); let results: Vec<_> = storage.get("key.*").unwrap().collect(); assert_eq!(results.len(), 2); - assert!(results.iter().any(|(k, _)| *k == "key1")); - assert!(results.iter().any(|(k, _)| *k == "key2")); + assert!( + results + .iter() + .any(|(path, k, _)| path == "/" && *k == "key1") + ); + assert!( + results + .iter() + .any(|(path, k, _)| path == "/" && *k == "key2") + ); } #[test] fn test_search() { let mut storage = StorageV5::new(); - storage.put( + storage.put_at_path( + "/", "alpha".to_string(), EncryptedValue::from_ciphertext(b"value1".to_vec()), + 0, ); - storage.put( + storage.put_at_path( + "/", "beta".to_string(), EncryptedValue::from_ciphertext(b"value2".to_vec()), + 0, ); - storage.put( + storage.put_at_path( + "/", "gamma".to_string(), EncryptedValue::from_ciphertext(b"value3".to_vec()), + 0, ); let results: Vec<_> = storage.search(".*a.*").unwrap().collect(); assert_eq!(results.len(), 2); // alpha, gamma - assert!(results.contains(&"alpha")); - assert!(results.contains(&"gamma")); + assert!(results.iter().any(|(path, k)| path == "/" && *k == "alpha")); + assert!(results.iter().any(|(path, k)| path == "/" && *k == "gamma")); } #[test] fn test_history() { let mut storage = StorageV5::new(); - storage.put_ts( + storage.put_at_path( + "/", "key".to_string(), EncryptedValue::from_ciphertext(b"value1".to_vec()), 100, ); - storage.put_ts( + storage.put_at_path( + "/", "key".to_string(), EncryptedValue::from_ciphertext(b"value2".to_vec()), 200, ); - storage.put_ts( + storage.put_at_path( + "/", "key".to_string(), EncryptedValue::from_ciphertext(b"value3".to_vec()), 300, @@ -559,13 +853,17 @@ mod tests { #[test] fn test_delete() { let mut storage = StorageV5::new(); - storage.put( + storage.put_at_path( + "/", "key1".to_string(), EncryptedValue::from_ciphertext(b"value1".to_vec()), + 0, ); - storage.put( + storage.put_at_path( + "/", "key2".to_string(), EncryptedValue::from_ciphertext(b"value2".to_vec()), + 0, ); assert!(storage.delete("key1")); diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 63d8e6b..3414af8 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -114,16 +114,16 @@ fn test_commands() { let lines = run_commands(&file_path, commands); - assert_eq!(lines[0], "key1: val_new"); - assert_eq!(lines[1], "key2: val2"); + assert_eq!(lines[0], "/key1: val_new"); + assert_eq!(lines[1], "/key2: val2"); // get with a regexp let mut commands = Vec::new(); commands.extend_from_slice(b"get key.*\n"); let lines = run_commands(&file_path, commands); - assert_eq!(lines[0], "key1: val_new"); - assert_eq!(lines[1], "key2: val2"); + assert_eq!(lines[0], "/key1: val_new"); + assert_eq!(lines[1], "/key2: val2"); // history command let mut commands = Vec::new(); @@ -211,8 +211,8 @@ fn test_update_with_new_keys() { commands.extend_from_slice(b"get key3\n"); let lines = run_commands(&main_path, commands); - assert_eq!(lines[0], "key2: value2"); - assert_eq!(lines[1], "key3: value3"); + assert_eq!(lines[0], "/key2: value2"); + assert_eq!(lines[1], "/key3: value3"); } #[test] @@ -259,7 +259,7 @@ fn test_update_with_conflicts() { commands.extend_from_slice(b"get key1\n"); let lines = run_commands(&main_path, commands); - assert_eq!(lines[0], "key1: new_value"); + assert_eq!(lines[0], "/key1: new_value"); } #[test] @@ -305,7 +305,7 @@ fn test_update_with_interactive_mode() { commands.extend_from_slice(b"get key3\n"); let lines = run_commands(&main_path, commands); - assert_eq!(lines[0], "key2: accept_this"); + assert_eq!(lines[0], "/key2: accept_this"); assert!( lines[1].contains("not found") || lines[1].contains("Not found") @@ -370,13 +370,17 @@ fn test_upgrade_storage() { let legacy_cypher = Cypher::new(legacy_key); let mut storage = StorageV5::new(); - storage.put( + storage.put_at_path( + "/", "key1".to_string(), EncryptedValue::encrypt(&legacy_cypher, "value1").unwrap(), + 0, ); - storage.put( + storage.put_at_path( + "/", "key2".to_string(), EncryptedValue::encrypt(&legacy_cypher, "value2").unwrap(), + 0, ); save_storage_v5(&legacy_cypher, &storage, &storage_path).unwrap(); @@ -401,6 +405,322 @@ fn test_upgrade_storage() { commands.extend_from_slice(b"get key2\n"); let lines = run_commands(&storage_path, commands); - assert_eq!(lines[0], "key1: value1"); - assert_eq!(lines[1], "key2: value2"); + assert_eq!(lines[0], "/key1: value1"); + assert_eq!(lines[1], "/key2: value2"); +} + +#[test] +fn test_folders_and_paths() { + let (_dir, file_path) = temp_test_file(); + + // Create folders and store keys in different locations + let mut commands = Vec::new(); + commands.extend_from_slice(b"mkdir work\n"); + commands.extend_from_slice(b"mkdir personal\n"); + commands.extend_from_slice(b"put /key1 root_value\n"); + commands.extend_from_slice(b"put work/api_key work_value\n"); + commands.extend_from_slice(b"put personal/password personal_value\n"); + run_commands(&file_path, commands); + + // Test get with absolute paths + let mut commands = Vec::new(); + commands.extend_from_slice(b"get /key1\n"); + commands.extend_from_slice(b"get work/api_key\n"); + commands.extend_from_slice(b"get personal/password\n"); + let lines = run_commands(&file_path, commands); + assert_eq!(lines[0], "/key1: root_value"); + assert_eq!(lines[1], "/work/api_key: work_value"); + assert_eq!(lines[2], "/personal/password: personal_value"); + + // Test cd and relative paths + let mut commands = Vec::new(); + commands.extend_from_slice(b"cd work\n"); + commands.extend_from_slice(b"put local_key local_value\n"); + let _ = run_commands(&file_path, commands); + + let mut commands = Vec::new(); + commands.extend_from_slice(b"cd work\n"); + commands.extend_from_slice(b"get local_key\n"); + commands.extend_from_slice(b"get ../personal/password\n"); + let lines = run_commands(&file_path, commands); + assert_eq!(lines[0], "/work/local_key: local_value"); + assert_eq!(lines[1], "/personal/password: personal_value"); + + // Test search with paths + let mut commands = Vec::new(); + commands.extend_from_slice(b"search work/.*\n"); + let lines = run_commands(&file_path, commands); + assert!(lines.iter().any(|l| l.contains("/work/api_key"))); + assert!(lines.iter().any(|l| l.contains("/work/local_key"))); + + // Test delete with paths + let mut commands = Vec::new(); + commands.extend_from_slice(b"del work/local_key\n"); + commands.extend_from_slice(b"search work/.*\n"); + let lines = run_commands(&file_path, commands); + assert!(lines.iter().any(|l| l.contains("/work/api_key"))); + assert!(!lines.iter().any(|l| l.contains("/work/local_key"))); + + // Test mkdir with paths and nested folders + let mut commands = Vec::new(); + commands.extend_from_slice(b"mkdir work/projects\n"); + commands.extend_from_slice(b"put work/projects/secret nested_value\n"); + let _ = run_commands(&file_path, commands); + + let mut commands = Vec::new(); + commands.extend_from_slice(b"cd work/projects\n"); + commands.extend_from_slice(b"get secret\n"); + let lines = run_commands(&file_path, commands); + assert_eq!(lines[0], "/work/projects/secret: nested_value"); + + // Test complex relative paths + let mut commands = Vec::new(); + commands.extend_from_slice(b"cd work/projects\n"); + commands.extend_from_slice(b"get ../../key1\n"); + commands.extend_from_slice(b"get ../api_key\n"); + let lines = run_commands(&file_path, commands); + assert_eq!(lines[0], "/key1: root_value"); + assert_eq!(lines[1], "/work/api_key: work_value"); + + // Test pwd + let mut commands = Vec::new(); + commands.extend_from_slice(b"cd work\n"); + commands.extend_from_slice(b"pwd\n"); + let lines = run_commands(&file_path, commands); + assert_eq!(lines[0], "/work"); + + // Test history with paths + let mut commands = Vec::new(); + commands.extend_from_slice(b"put work/api_key updated_value\n"); + let _ = run_commands(&file_path, commands); + + let mut commands = Vec::new(); + commands.extend_from_slice(b"history work/api_key\n"); + let lines = run_commands(&file_path, commands); + assert!(lines.iter().any(|l| l.contains("work_value"))); + assert!(lines.iter().any(|l| l.contains("updated_value"))); +} + +#[test] +fn test_update_with_nested_folders() { + let (_dir, main_path) = temp_test_file(); + let (_dir2, update_path) = temp_test_file(); + + // Create main storage with nested folder structure + let mut commands = Vec::new(); + commands.extend_from_slice(b"mkdir work\n"); + commands.extend_from_slice(b"mkdir work/projects\n"); + commands.extend_from_slice(b"mkdir personal\n"); + commands.extend_from_slice(b"put /root_key root_value\n"); + commands.extend_from_slice(b"put work/api_key work_api\n"); + commands.extend_from_slice(b"put work/projects/secret project_secret\n"); + commands.extend_from_slice(b"put personal/password personal_pw\n"); + let _ = run_commands(&main_path, commands); + + // Create update storage with same structure but different values + let mut commands = Vec::new(); + commands.extend_from_slice(b"mkdir work\n"); + commands.extend_from_slice(b"mkdir work/projects\n"); + commands.extend_from_slice(b"mkdir personal\n"); + commands.extend_from_slice(b"put /root_key root_value\n"); + commands.extend_from_slice(b"put work/api_key updated_api\n"); + commands.extend_from_slice(b"put work/projects/secret updated_secret\n"); + commands.extend_from_slice(b"put personal/password personal_pw\n"); + let _ = run_commands(&update_path, commands); + + // Wait to ensure different timestamp + std::thread::sleep(std::time::Duration::from_secs(1)); + + // Create update file with newer timestamps + let mut commands = Vec::new(); + commands.extend_from_slice(b"mkdir work\n"); + commands.extend_from_slice(b"mkdir work/projects\n"); + commands.extend_from_slice(b"put work/api_key updated_api\n"); + commands.extend_from_slice(b"put work/projects/secret updated_secret\n"); + let _ = run_commands(&update_path, commands); + + // Run update-with + let mut cmd = Command::new(cargo::cargo_bin!("rcypher")); + let output = cmd + .arg("--quiet") + .arg("--insecure-stdout") + .arg("--insecure-password") + .arg("test_password") + .arg("--insecure-allow-debugging") + .arg(&main_path) + .arg("--update-with") + .arg(&update_path) + .write_stdin(b"a\n") + .output() + .unwrap(); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("2 conflict")); + + // Verify updates were applied in nested folders + let mut commands = Vec::new(); + commands.extend_from_slice(b"get work/api_key\n"); + commands.extend_from_slice(b"get work/projects/secret\n"); + let lines = run_commands(&main_path, commands); + + assert_eq!(lines[0], "/work/api_key: updated_api"); + assert_eq!(lines[1], "/work/projects/secret: updated_secret"); +} + +#[test] +fn test_update_with_new_keys_in_nested_folders() { + let (_dir, main_path) = temp_test_file(); + let (_dir2, update_path) = temp_test_file(); + + // Create main storage with basic structure + let mut commands = Vec::new(); + commands.extend_from_slice(b"mkdir work\n"); + commands.extend_from_slice(b"put work/api_key work_api\n"); + let _ = run_commands(&main_path, commands); + + // Create update storage with additional nested keys + let mut commands = Vec::new(); + commands.extend_from_slice(b"mkdir work\n"); + commands.extend_from_slice(b"mkdir work/projects\n"); + commands.extend_from_slice(b"mkdir work/projects/client_a\n"); + commands.extend_from_slice(b"put work/api_key work_api\n"); + commands.extend_from_slice(b"put work/projects/secret project_secret\n"); + commands.extend_from_slice(b"put work/projects/client_a/token client_token\n"); + let _ = run_commands(&update_path, commands); + + // Run update-with and auto-apply + let mut cmd = Command::new(cargo::cargo_bin!("rcypher")); + let output = cmd + .arg("--quiet") + .arg("--insecure-stdout") + .arg("--insecure-password") + .arg("test_password") + .arg("--insecure-allow-debugging") + .arg(&main_path) + .arg("--update-with") + .arg(&update_path) + .write_stdin(b"a\n") + .output() + .unwrap(); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("2 new key")); + assert!(stdout.contains("All updates applied")); + + // Verify the new keys were added in nested folders + let mut commands = Vec::new(); + commands.extend_from_slice(b"get work/projects/secret\n"); + commands.extend_from_slice(b"get work/projects/client_a/token\n"); + let lines = run_commands(&main_path, commands); + + assert_eq!(lines[0], "/work/projects/secret: project_secret"); + assert_eq!(lines[1], "/work/projects/client_a/token: client_token"); +} + +#[test] +fn test_update_with_deep_nesting() { + let (_dir, main_path) = temp_test_file(); + let (_dir2, update_path) = temp_test_file(); + + // Create main storage with deeply nested structure (4 levels) + let mut commands = Vec::new(); + commands.extend_from_slice(b"mkdir org\n"); + commands.extend_from_slice(b"mkdir org/dept\n"); + commands.extend_from_slice(b"mkdir org/dept/team\n"); + commands.extend_from_slice(b"mkdir org/dept/team/project\n"); + commands.extend_from_slice(b"put org/dept/team/project/secret old_secret\n"); + let _ = run_commands(&main_path, commands); + + // Create update storage with new value + let mut commands = Vec::new(); + commands.extend_from_slice(b"mkdir org\n"); + commands.extend_from_slice(b"mkdir org/dept\n"); + commands.extend_from_slice(b"mkdir org/dept/team\n"); + commands.extend_from_slice(b"mkdir org/dept/team/project\n"); + commands.extend_from_slice(b"put org/dept/team/project/secret old_secret\n"); + let _ = run_commands(&update_path, commands); + + // Wait and update with new value + std::thread::sleep(std::time::Duration::from_secs(1)); + let mut commands = Vec::new(); + commands.extend_from_slice(b"put org/dept/team/project/secret new_secret\n"); + let _ = run_commands(&update_path, commands); + + // Run update-with + let mut cmd = Command::new(cargo::cargo_bin!("rcypher")); + let output = cmd + .arg("--quiet") + .arg("--insecure-stdout") + .arg("--insecure-password") + .arg("test_password") + .arg("--insecure-allow-debugging") + .arg(&main_path) + .arg("--update-with") + .arg(&update_path) + .write_stdin(b"a\n") + .output() + .unwrap(); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("1 conflict")); + + // Verify the deeply nested key was updated + let mut commands = Vec::new(); + commands.extend_from_slice(b"get org/dept/team/project/secret\n"); + let lines = run_commands(&main_path, commands); + + assert_eq!(lines[0], "/org/dept/team/project/secret: new_secret"); +} + +#[test] +fn test_update_with_mixed_nested_and_root_keys() { + let (_dir, main_path) = temp_test_file(); + let (_dir2, update_path) = temp_test_file(); + + // Create main storage with mixed structure + let mut commands = Vec::new(); + commands.extend_from_slice(b"mkdir work\n"); + commands.extend_from_slice(b"put /root1 root_value1\n"); + commands.extend_from_slice(b"put work/nested1 nested_value1\n"); + let _ = run_commands(&main_path, commands); + + // Create update storage with new keys at both levels + let mut commands = Vec::new(); + commands.extend_from_slice(b"mkdir work\n"); + commands.extend_from_slice(b"put /root1 root_value1\n"); + commands.extend_from_slice(b"put /root2 root_value2\n"); + commands.extend_from_slice(b"put work/nested1 nested_value1\n"); + commands.extend_from_slice(b"put work/nested2 nested_value2\n"); + let _ = run_commands(&update_path, commands); + + // Run update-with + let mut cmd = Command::new(cargo::cargo_bin!("rcypher")); + let output = cmd + .arg("--quiet") + .arg("--insecure-stdout") + .arg("--insecure-password") + .arg("test_password") + .arg("--insecure-allow-debugging") + .arg(&main_path) + .arg("--update-with") + .arg(&update_path) + .write_stdin(b"a\n") + .output() + .unwrap(); + + assert!(output.status.success()); + let stdout = String::from_utf8(output.stdout).unwrap(); + assert!(stdout.contains("2 new key")); + + // Verify both root and nested keys were added + let mut commands = Vec::new(); + commands.extend_from_slice(b"get /root2\n"); + commands.extend_from_slice(b"get work/nested2\n"); + let lines = run_commands(&main_path, commands); + + assert_eq!(lines[0], "/root2: root_value2"); + assert_eq!(lines[1], "/work/nested2: nested_value2"); } diff --git a/tests/storage_v5_tests.rs b/tests/storage_v5_tests.rs index 60b11d3..3fdcdd8 100644 --- a/tests/storage_v5_tests.rs +++ b/tests/storage_v5_tests.rs @@ -19,26 +19,29 @@ fn test_storage_new() { #[test] fn test_storage_put_get() { let mut storage = StorageV5::new(); - storage.put("key1".to_string(), "value1".into()); - storage.put("key2".to_string(), "value2".into()); + storage.put_at_path("/", "key1".to_string(), "value1".into(), 0); + storage.put_at_path("/", "key2".to_string(), "value2".into(), 0); let results: Vec<_> = storage.get("key1").unwrap().collect(); assert_eq!(results.len(), 1); - assert_eq!(results[0].0.as_bytes(), "key1".as_bytes()); - assert_eq!(results[0].1.as_bytes(), "value1".as_bytes()); + assert_eq!(results[0].0, "/"); + assert_eq!(results[0].1, "key1"); + assert_eq!(results[0].2.as_bytes(), "value1".as_bytes()); } #[test] fn test_storage_put_multiple_values() { let mut storage = StorageV5::new(); - storage.put("key1".to_string(), "value1".into()); - storage.put("key1".to_string(), "value2".into()); - storage.put("key1".to_string(), "value3".into()); + storage.put_at_path("/", "key1".to_string(), "value1".into(), 0); + storage.put_at_path("/", "key1".to_string(), "value2".into(), 0); + storage.put_at_path("/", "key1".to_string(), "value3".into(), 0); // get should return the latest value let results: Vec<_> = storage.get("key1").unwrap().collect(); assert_eq!(results.len(), 1); - assert_eq!(results[0].1.as_bytes(), "value3".as_bytes()); + assert_eq!(results[0].0, "/"); + assert_eq!(results[0].1, "key1"); + assert_eq!(results[0].2.as_bytes(), "value3".as_bytes()); // history should return all values let history: Vec<_> = storage.history("key1").unwrap().collect(); @@ -51,9 +54,9 @@ fn test_storage_put_multiple_values() { #[test] fn test_storage_get_with_regex() { let mut storage = StorageV5::new(); - storage.put("test1".to_string(), "value1".into()); - storage.put("test2".to_string(), "value2".into()); - storage.put("prod1".to_string(), "value3".into()); + storage.put_at_path("/", "test1".to_string(), "value1".into(), 0); + storage.put_at_path("/", "test2".to_string(), "value2".into(), 0); + storage.put_at_path("/", "prod1".to_string(), "value3".into(), 0); // Match all test keys let results: Vec<_> = storage.get("test.*").unwrap().collect(); @@ -62,13 +65,14 @@ fn test_storage_get_with_regex() { // Match specific key let results: Vec<_> = storage.get("test1").unwrap().collect(); assert_eq!(results.len(), 1); - assert_eq!(results[0].0, "test1"); + assert_eq!(results[0].0, "/"); + assert_eq!(results[0].1, "test1"); } #[test] fn test_storage_get_no_match() { let mut storage = StorageV5::new(); - storage.put("key1".to_string(), "value1".into()); + storage.put_at_path("/", "key1".to_string(), "value1".into(), 0); let results: Vec<_> = storage.get("nonexistent").unwrap().collect(); assert_eq!(results.len(), 0); @@ -77,14 +81,17 @@ fn test_storage_get_no_match() { #[test] fn test_storage_search() { let mut storage = StorageV5::new(); - storage.put("user_alice".to_string(), "value1".into()); - storage.put("user_bob".to_string(), "value2".into()); - storage.put("admin_charlie".to_string(), "value3".into()); + storage.put_at_path("/", "user_alice".to_string(), "value1".into(), 0); + storage.put_at_path("/", "user_bob".to_string(), "value2".into(), 0); + storage.put_at_path("/", "admin_charlie".to_string(), "value3".into(), 0); let keys: Vec<_> = storage.search("user_").unwrap().collect(); assert_eq!(keys.len(), 2); - assert!(keys.contains(&"user_alice")); - assert!(keys.contains(&"user_bob")); + assert!( + keys.iter() + .any(|(path, k)| path == "/" && *k == "user_alice") + ); + assert!(keys.iter().any(|(path, k)| path == "/" && *k == "user_bob")); let all_keys: Vec<_> = storage.search("").unwrap().collect(); assert_eq!(all_keys.len(), 3); @@ -93,8 +100,8 @@ fn test_storage_search() { #[test] fn test_storage_delete() { let mut storage = StorageV5::new(); - storage.put("key1".to_string(), "value1".into()); - storage.put("key2".to_string(), "value2".into()); + storage.put_at_path("/", "key1".to_string(), "value1".into(), 0); + storage.put_at_path("/", "key2".to_string(), "value2".into(), 0); assert!(storage.delete("key1")); assert_eq!(storage.root.secrets.len(), 1); @@ -107,13 +114,13 @@ fn test_storage_delete() { fn test_storage_history() { use std::time::{SystemTime, UNIX_EPOCH}; let mut storage = StorageV5::new(); - storage.put("key1".to_string(), "v1".into()); let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs(); - storage.put_ts("key1".to_string(), "v2".into(), timestamp.add(1)); - storage.put_ts("key1".to_string(), "v3".into(), timestamp.add(2)); + storage.put_at_path("/", "key1".to_string(), "v1".into(), timestamp); + storage.put_at_path("/", "key1".to_string(), "v2".into(), timestamp.add(1)); + storage.put_at_path("/", "key1".to_string(), "v3".into(), timestamp.add(2)); let history: Vec<_> = storage.history("key1").unwrap().collect(); assert_eq!(history.len(), 3); @@ -140,7 +147,7 @@ fn test_serialize_deserialize_empty() { #[test] fn test_serialize_deserialize_single_entry() { let mut storage = StorageV5::new(); - storage.put("key1".to_string(), "value1".into()); + storage.put_at_path("/", "key1".to_string(), "value1".into(), 0); let serialized = serialize_storage_v5_to_vec(&storage).unwrap(); let deserialized = deserialize_storage_v5_from_slice(&serialized).unwrap(); @@ -153,9 +160,9 @@ fn test_serialize_deserialize_single_entry() { #[test] fn test_serialize_deserialize_multiple_entries() { let mut storage = StorageV5::new(); - storage.put("key1".to_string(), "value1".into()); - storage.put("key2".to_string(), "value2".into()); - storage.put("key1".to_string(), "value1_updated".into()); + storage.put_at_path("/", "key1".to_string(), "value1".into(), 0); + storage.put_at_path("/", "key2".to_string(), "value2".into(), 0); + storage.put_at_path("/", "key1".to_string(), "value1_updated".into(), 0); let serialized = serialize_storage_v5_to_vec(&storage).unwrap(); let deserialized = deserialize_storage_v5_from_slice(&serialized).unwrap(); @@ -168,8 +175,8 @@ fn test_serialize_deserialize_multiple_entries() { #[test] fn test_serialize_deserialize_unicode() { let mut storage = StorageV5::new(); - storage.put("ключ".to_string(), "значение".into()); - storage.put("🔑".to_string(), "🎁".into()); + storage.put_at_path("/", "ключ".to_string(), "значение".into(), 0); + storage.put_at_path("/", "🔑".to_string(), "🎁".into(), 0); let serialized = serialize_storage_v5_to_vec(&storage).unwrap(); let deserialized = deserialize_storage_v5_from_slice(&serialized).unwrap(); @@ -209,8 +216,8 @@ fn test_load_save_storage_v5() { let cypher = Cypher::new(EncryptionKey::for_file("test_password", &path).unwrap()); let mut storage = StorageV5::new(); - storage.put("key1".to_string(), "value1".into()); - storage.put("key2".to_string(), "value2".into()); + storage.put_at_path("/", "key1".to_string(), "value1".into(), 0); + storage.put_at_path("/", "key2".to_string(), "value2".into(), 0); save_storage_v5(&cypher, &storage, &path).unwrap(); assert!(path.exists()); @@ -244,7 +251,7 @@ fn test_load_with_wrong_password() { let cypher2 = Cypher::new(EncryptionKey::for_file("test_password2", &path).unwrap()); let mut storage = StorageV5::new(); - storage.put("key1".to_string(), "value1".into()); + storage.put_at_path("/", "key1".to_string(), "value1".into(), 0); save_storage_v5(&cypher1, &storage, &path).unwrap(); @@ -258,35 +265,35 @@ fn test_storage_ordering() { let mut storage = StorageV5::new(); // Add keys in random order - storage.put("zebra".to_string(), "z".into()); - storage.put("alpha".to_string(), "a".into()); - storage.put("beta".to_string(), "b".into()); + storage.put_at_path("/", "zebra".to_string(), "z".into(), 0); + storage.put_at_path("/", "alpha".to_string(), "a".into(), 0); + storage.put_at_path("/", "beta".to_string(), "b".into(), 0); // Search should return sorted let keys: Vec<_> = storage.search("").unwrap().collect(); - assert_eq!(keys[0], "alpha"); - assert_eq!(keys[1], "beta"); - assert_eq!(keys[2], "zebra"); + assert_eq!(keys[0], ("/".to_string(), "alpha")); + assert_eq!(keys[1], ("/".to_string(), "beta")); + assert_eq!(keys[2], ("/".to_string(), "zebra")); } #[test] fn test_special_characters_in_keys() { let mut storage = StorageV5::new(); - storage.put("key-with-dash".to_string(), "value1".into()); - storage.put("key_with_underscore".to_string(), "value2".into()); - storage.put("key.with.dots".to_string(), "value3".into()); - storage.put("key@with@at".to_string(), "value4".into()); + storage.put_at_path("/", "key-with-dash".to_string(), "value1".into(), 0); + storage.put_at_path("/", "key_with_underscore".to_string(), "value2".into(), 0); + storage.put_at_path("/", "key.with.dots".to_string(), "value3".into(), 0); + storage.put_at_path("/", "key@with@at".to_string(), "value4".into(), 0); assert_eq!(storage.root.secrets.len(), 4); let result: Vec<_> = storage.get("key-with-dash").unwrap().collect(); - assert_eq!(result[0].1.as_bytes(), "value1".as_bytes()); + assert_eq!(result[0].2.as_bytes(), "value1".as_bytes()); let result: Vec<_> = storage.get("key_with_underscore").unwrap().collect(); - assert_eq!(result[0].1.as_bytes(), "value2".as_bytes()); + assert_eq!(result[0].2.as_bytes(), "value2".as_bytes()); let result: Vec<_> = storage.get("key.with.dots").unwrap().collect(); - assert_eq!(result[0].1.as_bytes(), "value3".as_bytes()); + assert_eq!(result[0].2.as_bytes(), "value3".as_bytes()); let result: Vec<_> = storage.get("key@with@at").unwrap().collect(); - assert_eq!(result[0].1.as_bytes(), "value4".as_bytes()); + assert_eq!(result[0].2.as_bytes(), "value4".as_bytes()); // Dot in regex matches any character let results: Vec<_> = storage.get("key.*").unwrap().collect(); @@ -305,7 +312,7 @@ fn test_concurrent_operations() { let storage_clone = Arc::clone(&storage); let handle = thread::spawn(move || { let mut s = storage_clone.lock().unwrap(); - s.put(format!("key{}", i), format!("value{}", i).into()); + s.put_at_path("/", format!("key{}", i), format!("value{}", i).into(), 0); }); handles.push(handle); } @@ -326,7 +333,7 @@ fn test_storage_persistence_across_sessions() { // Session 1: Create and save { let mut storage = StorageV5::new(); - storage.put("session1_key".to_string(), "session1_value".into()); + storage.put_at_path("/", "session1_key".to_string(), "session1_value".into(), 0); save_storage_v5(&cypher, &storage, &path).unwrap(); } @@ -334,7 +341,7 @@ fn test_storage_persistence_across_sessions() { { let mut storage = load_storage_v5(&cypher, &path).unwrap(); assert_eq!(storage.root.secrets.len(), 1); - storage.put("session2_key".to_string(), "session2_value".into()); + storage.put_at_path("/", "session2_key".to_string(), "session2_value".into(), 0); save_storage_v5(&cypher, &storage, &path).unwrap(); } @@ -350,8 +357,8 @@ fn test_storage_persistence_across_sessions() { #[test] fn test_empty_key_value() { let mut storage = StorageV5::new(); - storage.put("".to_string(), "value".into()); - storage.put("key".to_string(), "".into()); + storage.put_at_path("/", "".to_string(), "value".into(), 0); + storage.put_at_path("/", "key".to_string(), "".into(), 0); assert_eq!(storage.root.secrets.len(), 2); @@ -379,7 +386,7 @@ fn test_very_long_key_value() { let long_key = "k".repeat(10000); let long_value = "v".repeat(50000); - storage.put(long_key.clone(), long_value.clone().into()); + storage.put_at_path("/", long_key.clone(), long_value.clone().into(), 0); let serialized = serialize_storage_v5_to_vec(&storage).unwrap(); let deserialized = deserialize_storage_v5_from_slice(&serialized).unwrap(); @@ -391,3 +398,388 @@ fn test_very_long_key_value() { long_value.as_bytes() ); } + +#[test] +fn test_regex_matches_full_path() { + let mut storage = StorageV5::new(); + + // Create folder structure and add keys + storage.mkdir("/", "work").unwrap(); + storage.mkdir("/", "personal").unwrap(); + storage.put_at_path("/", "x_key".to_string(), "root_value".into(), 0); + storage.put_at_path("/work", "api_key".to_string(), "work_api".into(), 0); + storage.put_at_path("/work", "secret".to_string(), "work_secret".into(), 0); + storage.put_at_path("/personal", "password".to_string(), "personal_pw".into(), 0); + + // Pattern "x.*" should match: + // - "x_key" in root (path is "x_key") + // But NOT "api_key" in /work (path is "work/api_key", doesn't match "x.*") + let results: Vec<_> = storage.get_at_path("/", "x.*", true).unwrap().collect(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].0, "/"); + assert_eq!(results[0].1, "x_key"); + + // Pattern "work.*" should match: + // - "work/api_key" (matches "work.*") + // - "work/secret" (matches "work.*") + let results: Vec<_> = storage.get_at_path("/", "work.*", true).unwrap().collect(); + assert_eq!(results.len(), 2); + assert!( + results + .iter() + .any(|(p, k, _)| p == "/work" && *k == "api_key") + ); + assert!( + results + .iter() + .any(|(p, k, _)| p == "/work" && *k == "secret") + ); + + // Pattern "work/api.*" should match only "work/api_key" + let results: Vec<_> = storage + .get_at_path("/", "work/api.*", true) + .unwrap() + .collect(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].0, "/work"); + assert_eq!(results[0].1, "api_key"); + + // Search should work the same way + let keys: Vec<_> = storage + .search_at_path("/", "work.*", true) + .unwrap() + .collect(); + assert_eq!(keys.len(), 2); + assert!(keys.iter().any(|(p, k)| p == "/work" && *k == "api_key")); + assert!(keys.iter().any(|(p, k)| p == "/work" && *k == "secret")); +} + +#[test] +fn test_deep_nesting_operations() { + let mut storage = StorageV5::new(); + + // Create deeply nested folder structure (5 levels) + storage.mkdir("/", "level1").unwrap(); + storage.mkdir("/level1", "level2").unwrap(); + storage.mkdir("/level1/level2", "level3").unwrap(); + storage.mkdir("/level1/level2/level3", "level4").unwrap(); + storage + .mkdir("/level1/level2/level3/level4", "level5") + .unwrap(); + + // Add keys at different levels + storage.put_at_path("/", "root_key".to_string(), "root_val".into(), 0); + storage.put_at_path("/level1", "l1_key".to_string(), "l1_val".into(), 0); + storage.put_at_path("/level1/level2", "l2_key".to_string(), "l2_val".into(), 0); + storage.put_at_path( + "/level1/level2/level3", + "l3_key".to_string(), + "l3_val".into(), + 0, + ); + storage.put_at_path( + "/level1/level2/level3/level4", + "l4_key".to_string(), + "l4_val".into(), + 0, + ); + storage.put_at_path( + "/level1/level2/level3/level4/level5", + "l5_key".to_string(), + "l5_val".into(), + 0, + ); + + // Test get from different levels + let results: Vec<_> = storage + .get_at_path("/", "root_key", false) + .unwrap() + .collect(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].0, "/"); + assert_eq!(results[0].1, "root_key"); + + let results: Vec<_> = storage + .get_at_path("/level1/level2/level3", "l3_key", false) + .unwrap() + .collect(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].0, "/level1/level2/level3"); + assert_eq!(results[0].1, "l3_key"); + + let results: Vec<_> = storage + .get_at_path("/level1/level2/level3/level4/level5", "l5_key", false) + .unwrap() + .collect(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].0, "/level1/level2/level3/level4/level5"); + assert_eq!(results[0].1, "l5_key"); + + // Test recursive search from root + let all_keys: Vec<_> = storage.search_at_path("/", ".*", true).unwrap().collect(); + assert_eq!(all_keys.len(), 6); + + // Test recursive search from nested folder + let nested_keys: Vec<_> = storage + .search_at_path("/level1/level2", ".*", true) + .unwrap() + .collect(); + assert_eq!(nested_keys.len(), 4); // l2_key, l3_key, l4_key, l5_key +} + +#[test] +fn test_nested_folder_regex_matching() { + let mut storage = StorageV5::new(); + + // Create nested structure with multiple branches + storage.mkdir("/", "work").unwrap(); + storage.mkdir("/work", "projects").unwrap(); + storage.mkdir("/work", "configs").unwrap(); + storage.mkdir("/work/projects", "client_a").unwrap(); + storage.mkdir("/work/projects", "client_b").unwrap(); + + // Add keys with patterns + storage.put_at_path( + "/work/projects/client_a", + "api_key".to_string(), + "key_a".into(), + 0, + ); + storage.put_at_path( + "/work/projects/client_a", + "api_secret".to_string(), + "secret_a".into(), + 0, + ); + storage.put_at_path( + "/work/projects/client_b", + "api_key".to_string(), + "key_b".into(), + 0, + ); + storage.put_at_path( + "/work/configs", + "database".to_string(), + "db_config".into(), + 0, + ); + + // Pattern "work/projects/client_a/.*" should match only client_a keys + let results: Vec<_> = storage + .get_at_path("/", "work/projects/client_a/.*", true) + .unwrap() + .collect(); + assert_eq!(results.len(), 2); + assert!( + results + .iter() + .all(|(p, _, _)| p == "/work/projects/client_a") + ); + + // Pattern "work/projects/.*/api_key" should match both clients' api_key + let results: Vec<_> = storage + .get_at_path("/", "work/projects/.*/api_key", true) + .unwrap() + .collect(); + assert_eq!(results.len(), 2); + assert!( + results + .iter() + .any(|(p, k, _)| p == "/work/projects/client_a" && *k == "api_key") + ); + assert!( + results + .iter() + .any(|(p, k, _)| p == "/work/projects/client_b" && *k == "api_key") + ); + + // Pattern "work/.*/database" should match only database in configs + let results: Vec<_> = storage + .get_at_path("/", "work/.*/database", true) + .unwrap() + .collect(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].0, "/work/configs"); + assert_eq!(results[0].1, "database"); +} + +#[test] +fn test_nested_folder_history() { + use std::time::{SystemTime, UNIX_EPOCH}; + let mut storage = StorageV5::new(); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + // Create nested folder + storage.mkdir("/", "work").unwrap(); + storage.mkdir("/work", "projects").unwrap(); + + // Add multiple versions of a nested key + storage.put_at_path( + "/work/projects", + "secret".to_string(), + "v1".into(), + timestamp, + ); + storage.put_at_path( + "/work/projects", + "secret".to_string(), + "v2".into(), + timestamp.add(1), + ); + storage.put_at_path( + "/work/projects", + "secret".to_string(), + "v3".into(), + timestamp.add(2), + ); + + // Get history from nested folder + let history: Vec<_> = storage + .history_at_path("/work/projects", "secret") + .unwrap() + .collect(); + assert_eq!(history.len(), 3); + assert_eq!(history[0].encrypted_value().as_bytes(), "v1".as_bytes()); + assert_eq!(history[1].encrypted_value().as_bytes(), "v2".as_bytes()); + assert_eq!(history[2].encrypted_value().as_bytes(), "v3".as_bytes()); + + // Verify timestamps are in order + assert!(history[0].timestamp + 1 == history[1].timestamp); + assert!(history[1].timestamp + 1 == history[2].timestamp); +} + +#[test] +fn test_nested_folder_delete() { + let mut storage = StorageV5::new(); + + // Create nested structure with keys + storage.mkdir("/", "work").unwrap(); + storage.mkdir("/work", "projects").unwrap(); + storage.put_at_path("/work/projects", "secret1".to_string(), "val1".into(), 0); + storage.put_at_path("/work/projects", "secret2".to_string(), "val2".into(), 0); + + // Delete key from nested folder + assert!(storage.delete_at_path("/work/projects", "secret1")); + + // Verify only secret1 is deleted + let results: Vec<_> = storage + .get_at_path("/work/projects", "secret1", false) + .unwrap() + .collect(); + assert_eq!(results.len(), 0); + + let results: Vec<_> = storage + .get_at_path("/work/projects", "secret2", false) + .unwrap() + .collect(); + assert_eq!(results.len(), 1); + + // Try to delete non-existent key + assert!(!storage.delete_at_path("/work/projects", "secret1")); + assert!(!storage.delete_at_path("/work/projects", "nonexistent")); +} + +#[test] +fn test_nested_folder_serialization() { + let mut storage = StorageV5::new(); + + // Create complex nested structure + storage.mkdir("/", "org").unwrap(); + storage.mkdir("/org", "dept").unwrap(); + storage.mkdir("/org/dept", "team").unwrap(); + storage.put_at_path("/org", "org_key".to_string(), "org_val".into(), 0); + storage.put_at_path("/org/dept", "dept_key".to_string(), "dept_val".into(), 0); + storage.put_at_path( + "/org/dept/team", + "team_key".to_string(), + "team_val".into(), + 0, + ); + + // Serialize and deserialize + let serialized = serialize_storage_v5_to_vec(&storage).unwrap(); + let deserialized = deserialize_storage_v5_from_slice(&serialized).unwrap(); + + // Verify structure is preserved + assert!(deserialized.get_folder("/org").is_some()); + assert!(deserialized.get_folder("/org/dept").is_some()); + assert!(deserialized.get_folder("/org/dept/team").is_some()); + + // Verify keys are preserved + let results: Vec<_> = deserialized + .get_at_path("/org", "org_key", false) + .unwrap() + .collect(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].2.as_bytes(), "org_val".as_bytes()); + + let results: Vec<_> = deserialized + .get_at_path("/org/dept/team", "team_key", false) + .unwrap() + .collect(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].2.as_bytes(), "team_val".as_bytes()); +} + +#[test] +fn test_multiple_nested_branches() { + let mut storage = StorageV5::new(); + + // Create multiple independent nested branches + storage.mkdir("/", "work").unwrap(); + storage.mkdir("/work", "project_a").unwrap(); + storage.mkdir("/work", "project_b").unwrap(); + storage.mkdir("/", "personal").unwrap(); + storage.mkdir("/personal", "finance").unwrap(); + storage.mkdir("/personal", "health").unwrap(); + + // Add keys to different branches + storage.put_at_path("/work/project_a", "api".to_string(), "api_a".into(), 0); + storage.put_at_path("/work/project_b", "api".to_string(), "api_b".into(), 0); + storage.put_at_path( + "/personal/finance", + "account".to_string(), + "acc123".into(), + 0, + ); + storage.put_at_path( + "/personal/health", + "insurance".to_string(), + "ins456".into(), + 0, + ); + + // Search all work keys + let work_keys: Vec<_> = storage + .search_at_path("/work", ".*", true) + .unwrap() + .collect(); + assert_eq!(work_keys.len(), 2); + + // Search all personal keys + let personal_keys: Vec<_> = storage + .search_at_path("/personal", ".*", true) + .unwrap() + .collect(); + assert_eq!(personal_keys.len(), 2); + + // Search for specific pattern across all branches + let api_keys: Vec<_> = storage + .search_at_path("/", "work/.*/api", true) + .unwrap() + .collect(); + assert_eq!(api_keys.len(), 2); + assert!( + api_keys + .iter() + .any(|(p, k)| p == "/work/project_a" && *k == "api") + ); + assert!( + api_keys + .iter() + .any(|(p, k)| p == "/work/project_b" && *k == "api") + ); +} From 2920618dbc0d60a1f5de0b22140a6fd14ba64819 Mon Sep 17 00:00:00 2001 From: Roman Studenikin Date: Mon, 29 Dec 2025 19:36:48 +0000 Subject: [PATCH 03/12] Implement an mv command --- TODO | 53 +++++++-- src/cli/completer.rs | 148 ++++++++++++----------- src/cli/interactive.rs | 186 +++++++++++++++++++++++++++++ src/storage/v5.rs | 86 +++++++++++++ tests/cli_tests.rs | 193 ++++++++++++++++++++++++++++++ tests/storage_v5_tests.rs | 245 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 824 insertions(+), 87 deletions(-) diff --git a/TODO b/TODO index 98cfe8e..29482ac 100644 --- a/TODO +++ b/TODO @@ -1,15 +1,27 @@ # Implement a new extensible storage format that supports ## grouping secrets into folders -- everything is in the root folder by default -- secrets can be moved between folders +✅ **COMPLETED** +- Hierarchical folder structure with unlimited nesting depth +- Everything is in the root folder (/) by default +- Commands: `mkdir`, `cd`, `pwd` for folder navigation +- Full path support: `/work/projects/api_key` +- Relative paths: `../other_folder`, `./subfolder` +- Keys can be moved between folders using `mv` command +- Comprehensive test coverage for nested folders ## encryption domains -- default encryption domain(0) means encryption with default master key as it is done now in storage v4. -- non-default domain means secrets are encrypted with a custom key requiring another password -- can be attached to either individual keys or folders -- when a folder has a non-default encrypted domain, all its contents should be encrypted together, such that it is impossible to know what is stored inside. It should appear in the CLI UI as a regular key/value instead of a folder with a user-set value to confuse attackers in a case of master key is stolen. -- "unlock" command for such a key asks for a password, decrypts its content and opens it for querying by all commands like if it was unencrypted. +⚠️ **PARTIALLY COMPLETED** +- Data structures exist: `Folder` has `encryption_domain: u32` field +- `SecretValue` enum supports both plain and encrypted folder variants +- Default encryption domain (0) uses the master key (same as storage v4) + +⏳ **TODO: CLI commands and functionality** +- Non-default domains should encrypt secrets with a custom key requiring another password +- Can be attached to either individual keys or folders +- When a folder has a non-default encrypted domain, all its contents should be encrypted together, such that it is impossible to know what is stored inside +- Should appear in the CLI UI as a regular key/value instead of a folder with a user-set value to confuse attackers in case master key is stolen +- "unlock" command for such a key asks for a password, decrypts its content and opens it for querying by all commands as if it was unencrypted **Use case** Encryption: @@ -21,10 +33,27 @@ Decryption: - get/set/history commands work as always as if a PlaceHolderSecret was a real value. set command can override the value even if it holds an encrypted folder underneath. - "unlock key" command checks encryption domain and if a key for this domain is already cached, then it performs decryption and if it is a folder, decrypts its contents into memory making its contents available for get/set/history commands -## rename/move commands -- rename command allows renaming keys with a regexp. Before renaming, it performs checks confirming that there are no collisions and asks for a confirmation if multiple keys are being renamed. -- move command allows moving from/into folders. Default folder is "/" -- an encrypted folder must be unlocked to allow copying into/from it. Re-encryption is performed with a key from corresponding encryption domain. +## move command +✅ **COMPLETED: move command** +- `mv SOURCE_PATTERN DESTINATION` - shell-like move behavior +- Single match: destination can be folder or full path (move+rename) +- Multiple matches: destination must be a folder +- Recursive pattern matching with regex support +- Preserves full key history during move +- Collision detection with pre-validation + +⏳ **TODO: encryption domain support for move** +- Encrypted folders must be unlocked to allow moving/renaming keys from them +- Re-encryption should be performed with key from corresponding encryption domain when moving between domains ## support SecretType and optional metadata HashMap -- this will later be used for supporting storage of files. +⚠️ **PARTIALLY COMPLETED** +- Data structures exist: `SecretEntry` has `secret_type: SecretType` and `metadata: HashMap` fields +- Currently only `SecretType::Utf8String` is implemented +- Metadata field exists but not exposed in CLI + +⏳ **TODO** +- Add `SecretType::File` variant for file storage support +- Implement CLI commands to set/view metadata +- Add file upload/download functionality +- Support for binary data storage diff --git a/src/cli/completer.rs b/src/cli/completer.rs index 219e998..6337a0d 100644 --- a/src/cli/completer.rs +++ b/src/cli/completer.rs @@ -20,6 +20,61 @@ impl CypherCompleter { current_path, } } + + /// Helper function to complete paths with optional keys and/or folders + fn complete_path( + &self, + input: &str, + pos: usize, + include_keys: bool, + include_folders: bool, + ) -> (usize, Vec) { + let storage = self.storage.lock().expect("able to take a lock"); + let current_path = self.current_path.lock().expect("able to lock"); + + // Parse input to get resolved path and prefix + let (target_path, prefix) = parse_key_path(¤t_path, input); + + // Extract original dir part to preserve user's input style in completions + let dir_part = input.strip_suffix(prefix).unwrap_or(""); + + let mut matches: Vec = Vec::new(); + if let Some(folder) = storage.get_folder(&target_path) { + // Add matching secret keys + if include_keys { + for key in folder.secrets.keys() { + if key.starts_with(prefix) { + let full_path = format_full_path(dir_part, key, false); + matches.push(Pair { + display: full_path.clone(), + replacement: full_path, + }); + } + } + } + + // Add matching folder names + if include_folders { + for name in folder.subfolders.keys() { + if name.starts_with(prefix) { + let full_path = format_full_path(dir_part, name, true); + matches.push(Pair { + display: full_path.clone(), + replacement: full_path, + }); + } + } + } + } + + drop(current_path); + drop(storage); + + matches.sort_by(|a, b| a.replacement.cmp(&b.replacement)); + + let start = pos - input.len(); + (start, matches) + } } impl Completer for CypherCompleter { @@ -38,8 +93,8 @@ impl Completer for CypherCompleter { if parts.is_empty() || (parts.len() == 1 && !line.ends_with(' ')) { // Complete commands let commands = [ - "put", "get", "copy", "history", "search", "del", "rm", "mkdir", "cd", "pwd", - "help", + "put", "get", "copy", "history", "search", "del", "rm", "mkdir", "cd", "pwd", "mv", + "move", "help", ]; let prefix = parts.first().unwrap_or(&""); let matches: Vec = commands @@ -60,86 +115,29 @@ impl Completer for CypherCompleter { let cmd = parts[0]; match cmd { "cd" | "mkdir" => { - // Complete with folder names + // Complete with folder names only if parts.len() == 1 || (parts.len() == 2 && !line.ends_with(' ')) { let input = if parts.len() == 2 { parts[1] } else { "" }; - - let storage = self.storage.lock().expect("able to take a lock"); - let current_path = self.current_path.lock().expect("able to lock"); - - // Parse input to get resolved path and prefix - let (target_path, prefix) = parse_key_path(¤t_path, input); - - // Extract original dir part to preserve user's input style in completions - // (e.g., if user typed "work/api", complete as "work/api_key", not "/work/api_key") - let dir_part = input.strip_suffix(prefix).unwrap_or(""); - - let mut matches: Vec = Vec::new(); - if let Some(folder) = storage.get_folder(&target_path) { - for name in folder.subfolders.keys() { - if name.starts_with(prefix) { - let full_path = format_full_path(dir_part, name, true); - matches.push(Pair { - display: full_path.clone(), - replacement: full_path, - }); - } - } - } - drop(current_path); - drop(storage); - - matches.sort_by(|a, b| a.replacement.cmp(&b.replacement)); - - let start = pos - input.len(); - return Ok((start, matches)); + return Ok(self.complete_path(input, pos, false, true)); } } "get" | "history" | "del" | "rm" | "put" | "copy" | "search" => { - // Complete with keys and folders from current directory + // Complete with keys and folders if parts.len() == 1 || (parts.len() == 2 && !line.ends_with(' ')) { let input = if parts.len() == 2 { parts[1] } else { "" }; - - let storage = self.storage.lock().expect("able to take a lock"); - let current_path = self.current_path.lock().expect("able to lock"); - - // Parse input to get resolved path and prefix - let (target_path, prefix) = parse_key_path(¤t_path, input); - - // Extract original dir part to preserve user's input style in completions - // (e.g., if user typed "work/api", complete as "work/api_key", not "/work/api_key") - let dir_part = input.strip_suffix(prefix).unwrap_or(""); - - let mut matches: Vec = Vec::new(); - if let Some(folder) = storage.get_folder(&target_path) { - // Add matching secret keys - for key in folder.secrets.keys() { - if key.starts_with(prefix) { - let full_path = format_full_path(dir_part, key, false); - matches.push(Pair { - display: full_path.clone(), - replacement: full_path, - }); - } - } - // Add matching folder names - for name in folder.subfolders.keys() { - if name.starts_with(prefix) { - let full_path = format_full_path(dir_part, name, true); - matches.push(Pair { - display: full_path.clone(), - replacement: full_path, - }); - } - } - } - drop(current_path); - drop(storage); - - matches.sort_by(|a, b| a.replacement.cmp(&b.replacement)); - - let start = pos - input.len(); - return Ok((start, matches)); + return Ok(self.complete_path(input, pos, true, true)); + } + } + "mv" | "move" => { + // First argument: complete with keys and folders (source) + if parts.len() == 1 || (parts.len() == 2 && !line.ends_with(' ')) { + let input = if parts.len() == 2 { parts[1] } else { "" }; + return Ok(self.complete_path(input, pos, true, true)); + } + // Second argument: complete with folders only (destination) + else if parts.len() == 2 || (parts.len() == 3 && !line.ends_with(' ')) { + let input = if parts.len() == 3 { parts[2] } else { "" }; + return Ok(self.complete_path(input, pos, false, true)); } } _ => {} diff --git a/src/cli/interactive.rs b/src/cli/interactive.rs index f680f90..2852c9d 100644 --- a/src/cli/interactive.rs +++ b/src/cli/interactive.rs @@ -172,6 +172,12 @@ impl InteractiveCli { let path = if parts.len() > 1 { parts[1] } else { "/" }; self.cmd_cd(path, storage) } + "mv" | "move" => { + if parts.len() < 3 { + bail!("syntax: mv SOURCE_PATTERN DESTINATION"); + } + self.cmd_move(parts[1], parts[2], storage) + } "pwd" => { self.cmd_pwd(); Ok(()) @@ -342,6 +348,185 @@ impl InteractiveCli { Ok(()) } + fn cmd_move( + &self, + source_pattern: &str, + destination: &str, + storage: &mut StorageV5, + ) -> Result<()> { + let current_path = self.get_current_path(); + + // Find matching keys and folders + let key_count = storage + .get_at_path(¤t_path, source_pattern, true)? + .count(); + let folder_count = storage + .search_at_path(¤t_path, source_pattern, true)? + .filter(|(path, name)| { + // Only count folders (check if it exists as a subfolder) + let check_path = if path == "/" { + format!("/{name}") + } else { + format!("{path}/{name}") + }; + storage.get_folder(&check_path).is_some() + }) + .count(); + + let total_matches = key_count + folder_count; + + if total_matches == 0 { + bail!("No keys or folders matching pattern '{source_pattern}'"); + } + + if total_matches == 1 { + // Single match: could be a key or a folder + // Get the actual matched item (not the pattern) + let (source_path, source_name, is_folder) = if key_count == 1 { + // It's a key + let (folder, key, _) = storage + .get_at_path(¤t_path, source_pattern, true)? + .next() + .expect("key_count is 1"); + (folder, key.to_string(), false) + } else { + // It's a folder + let (path, name) = storage + .search_at_path(¤t_path, source_pattern, true)? + .find(|(path, name)| { + let check_path = if path == "/" { + format!("/{name}") + } else { + format!("{path}/{name}") + }; + storage.get_folder(&check_path).is_some() + }) + .expect("folder_count is 1"); + (path, name.to_string(), true) + }; + + // Determine destination + let dest_is_existing_folder = destination.ends_with('/') + || storage + .get_folder(&resolve_path(¤t_path, destination)) + .is_some(); + + if is_folder { + // Moving a folder + let dest_parent = if dest_is_existing_folder { + // Destination is a folder, move into it + resolve_path(¤t_path, destination) + } else { + // Destination is a new name (rename) + let (dest_parent, _) = parse_key_path(¤t_path, destination); + dest_parent + }; + + let dest_name = if dest_is_existing_folder { + None // Keep same name + } else { + let (_, name) = parse_key_path(¤t_path, destination); + Some(name) + }; + + storage.move_folder(&source_path, &source_name, &dest_parent, dest_name)?; + + let dest_display = if let Some(dn) = dest_name { + format_full_path(&dest_parent, dn, true) + } else { + format_full_path(&dest_parent, &source_name, true) + }; + + secure_print( + format!( + "Moved folder {} -> {}", + format_full_path(&source_path, &source_name, true), + dest_display + ), + self.insecure_stdout, + )?; + } else { + // Moving a key + let (dest_folder, dest_key_opt) = parse_key_path(¤t_path, destination); + + let dest_key = if dest_is_existing_folder { + None + } else { + Some(dest_key_opt) + }; + + storage.move_key(&source_path, &source_name, &dest_folder, dest_key)?; + + let dest_display = if let Some(dk) = dest_key { + format_full_path(&dest_folder, dk, false) + } else { + format_full_path(&dest_folder, &source_name, false) + }; + + secure_print( + format!( + "Moved {} -> {}", + format_full_path(&source_path, &source_name, false), + dest_display + ), + self.insecure_stdout, + )?; + } + } else { + // Multiple matches: destination must be a folder + let dest_folder = resolve_path(¤t_path, destination); + + if storage.get_folder(&dest_folder).is_none() { + bail!( + "Destination '{destination}' is not a folder (required when moving multiple items)" + ); + } + + // Collect keys to move + let keys_to_move: Vec<(String, String)> = storage + .get_at_path(¤t_path, source_pattern, true)? + .map(|(folder, key, _)| (folder, key.to_string())) + .collect(); + + // Collect folders to move + let folders_to_move: Vec<(String, String)> = storage + .search_at_path(¤t_path, source_pattern, true)? + .filter_map(|(path, name)| { + let check_path = if path == "/" { + format!("/{name}") + } else { + format!("{path}/{name}") + }; + if storage.get_folder(&check_path).is_some() { + Some((path, name.to_string())) + } else { + None + } + }) + .collect(); + + // Move all keys + for (source_folder, source_key) in &keys_to_move { + storage.move_key(source_folder, source_key, &dest_folder, None)?; + } + + // Move all folders + for (parent_path, folder_name) in &folders_to_move { + storage.move_folder(parent_path, folder_name, &dest_folder, None)?; + } + + let message = match (keys_to_move.len(), folders_to_move.len()) { + (k, 0) => format!("Moved {k} keys to {dest_folder}"), + (0, f) => format!("Moved {f} folders to {dest_folder}"), + (k, f) => format!("Moved {k} keys and {f} folders to {dest_folder}"), + }; + secure_print(message, self.insecure_stdout)?; + } + + save_storage_v5(&self.cypher, storage, &self.filename)?; + Ok(()) + } + fn cmd_pwd(&self) { println!("{}", self.get_current_path()); } @@ -359,6 +544,7 @@ fn print_help() { println!(" history KEY - Show history of changes for a key in current folder"); println!(" search REGEXP - Search for keys matching regexp (recursive)"); println!(" del|rm KEY - Delete a key from current folder"); + println!(" mv|move SRC DST - Move key(s) matching SRC to DST (folder or full path)"); println!(); println!("FOLDER COMMANDS:"); println!(" mkdir FOLDER - Create a new folder in current directory"); diff --git a/src/storage/v5.rs b/src/storage/v5.rs index 6ce7a4f..c4a3b40 100644 --- a/src/storage/v5.rs +++ b/src/storage/v5.rs @@ -162,6 +162,92 @@ impl StorageV5 { .is_some() } + /// Move a key from one location to another (like shell mv) + /// `source_folder`: folder containing the key + /// key: the key to move + /// `dest_folder`: destination folder + /// `dest_key`: optional new key name (if None, keeps same name) + pub fn move_key( + &mut self, + source_folder: &str, + key: &str, + dest_folder: &str, + dest_key: Option<&str>, + ) -> Result<()> { + let final_key = dest_key.unwrap_or(key); + + // Check destination folder exists and no collision (must do before removing from source) + { + let dest = self + .get_folder(dest_folder) + .ok_or_else(|| anyhow::anyhow!("Destination folder '{dest_folder}' not found"))?; + + if dest.secrets.contains_key(final_key) { + bail!("Key '{final_key}' already exists at destination '{dest_folder}'"); + } + } + + // Remove from source + let entries = self + .get_folder_mut(source_folder) + .ok_or_else(|| anyhow::anyhow!("Source folder '{source_folder}' not found"))? + .secrets + .remove(key) + .ok_or_else(|| anyhow::anyhow!("Key '{key}' not found in '{source_folder}'"))?; + + // Insert into dest + self.get_folder_mut(dest_folder) + .expect("dest folder exists") + .secrets + .insert(final_key.to_string(), entries); + + Ok(()) + } + + /// Move a folder from one location to another (like shell mv for directories) + /// `parent_path`: parent folder containing the folder to move + /// `folder_name`: name of the folder to move + /// `dest_parent`: destination parent folder + /// `dest_name`: optional new folder name (if None, keeps same name) + pub fn move_folder( + &mut self, + parent_path: &str, + folder_name: &str, + dest_parent: &str, + dest_name: Option<&str>, + ) -> Result<()> { + let final_name = dest_name.unwrap_or(folder_name); + + // Check destination parent exists and no collision + { + let dest = self + .get_folder(dest_parent) + .ok_or_else(|| anyhow::anyhow!("Destination folder '{dest_parent}' not found"))?; + + if dest.subfolders.contains_key(final_name) { + bail!("Folder '{final_name}' already exists at destination '{dest_parent}'"); + } + } + + // Remove from source + let folder = self + .get_folder_mut(parent_path) + .ok_or_else(|| anyhow::anyhow!("Source folder '{parent_path}' not found"))? + .subfolders + .remove(folder_name) + .ok_or_else(|| { + anyhow::anyhow!("Folder '{folder_name}' not found in '{parent_path}'") + })?; + + // Insert into dest + self.get_folder_mut(dest_parent) + .expect("dest parent exists") + .subfolders + .insert(final_name.to_string(), folder); + + Ok(()) + } + /// Returns an iterator over all keys matching the given regex pattern in root folder. /// /// # Sorting diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index 3414af8..da49526 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -724,3 +724,196 @@ fn test_update_with_mixed_nested_and_root_keys() { assert_eq!(lines[0], "/root2: root_value2"); assert_eq!(lines[1], "/work/nested2: nested_value2"); } + +#[test] +fn test_move_single_key_to_folder() { + let (_dir, file_path) = temp_test_file(); + + // Setup: create folders and keys + let mut commands = Vec::new(); + commands.extend_from_slice(b"mkdir source\n"); + commands.extend_from_slice(b"mkdir dest\n"); + commands.extend_from_slice(b"put source/key1 value1\n"); + run_commands(&file_path, commands); + + // Move key to dest folder + let mut commands = Vec::new(); + commands.extend_from_slice(b"mv source/key1 dest/\n"); + let lines = run_commands(&file_path, commands); + assert!(lines.iter().any(|l| l.contains("Moved"))); + + // Verify key moved + let mut commands = Vec::new(); + commands.extend_from_slice(b"get dest/key1\n"); + let lines = run_commands(&file_path, commands); + assert_eq!(lines[0], "/dest/key1: value1"); + + // Verify key gone from source + let mut commands = Vec::new(); + commands.extend_from_slice(b"get source/key1\n"); + let lines = run_commands(&file_path, commands); + assert!(lines[0].contains("not found") || lines[0].contains("No keys matching")); +} + +#[test] +fn test_move_with_rename() { + let (_dir, file_path) = temp_test_file(); + + // Setup + let mut commands = Vec::new(); + commands.extend_from_slice(b"put /old_name old_value\n"); + run_commands(&file_path, commands); + + // Move and rename + let mut commands = Vec::new(); + commands.extend_from_slice(b"mv old_name new_name\n"); + let lines = run_commands(&file_path, commands); + assert!(lines.iter().any(|l| l.contains("Moved"))); + + // Verify new name exists + let mut commands = Vec::new(); + commands.extend_from_slice(b"get new_name\n"); + let lines = run_commands(&file_path, commands); + assert_eq!(lines[0], "/new_name: old_value"); + + // Verify old name gone + let mut commands = Vec::new(); + commands.extend_from_slice(b"get old_name\n"); + let lines = run_commands(&file_path, commands); + assert!(lines[0].contains("not found") || lines[0].contains("No keys matching")); +} + +#[test] +fn test_move_multiple_keys_to_folder() { + let (_dir, file_path) = temp_test_file(); + + // Setup: create keys with pattern + let mut commands = Vec::new(); + commands.extend_from_slice(b"mkdir dest\n"); + commands.extend_from_slice(b"put /api_key1 value1\n"); + commands.extend_from_slice(b"put /api_key2 value2\n"); + commands.extend_from_slice(b"put /api_key3 value3\n"); + run_commands(&file_path, commands); + + // Move all api_* keys to dest + let mut commands = Vec::new(); + commands.extend_from_slice(b"mv api_.* dest/\n"); + let lines = run_commands(&file_path, commands); + assert!(lines.iter().any(|l| l.contains("Moved 3 keys"))); + + // Verify all keys moved + let mut commands = Vec::new(); + commands.extend_from_slice(b"search dest/.*\n"); + let lines = run_commands(&file_path, commands); + assert!(lines.iter().any(|l| l.contains("/dest/api_key1"))); + assert!(lines.iter().any(|l| l.contains("/dest/api_key2"))); + assert!(lines.iter().any(|l| l.contains("/dest/api_key3"))); +} + +#[test] +fn test_move_across_nested_folders() { + let (_dir, file_path) = temp_test_file(); + + // Setup: create nested structure + let mut commands = Vec::new(); + commands.extend_from_slice(b"mkdir work\n"); + commands.extend_from_slice(b"mkdir work/old_project\n"); + commands.extend_from_slice(b"mkdir work/new_project\n"); + commands.extend_from_slice(b"put work/old_project/secret old_secret\n"); + run_commands(&file_path, commands); + + // Move from nested folder to another nested folder + let mut commands = Vec::new(); + commands.extend_from_slice(b"mv work/old_project/secret work/new_project/\n"); + let lines = run_commands(&file_path, commands); + assert!(lines.iter().any(|l| l.contains("Moved"))); + + // Verify moved + let mut commands = Vec::new(); + commands.extend_from_slice(b"get work/new_project/secret\n"); + let lines = run_commands(&file_path, commands); + assert_eq!(lines[0], "/work/new_project/secret: old_secret"); +} + +#[test] +fn test_move_preserves_history() { + let (_dir, file_path) = temp_test_file(); + + // Setup: create key with history + let mut commands = Vec::new(); + commands.extend_from_slice(b"mkdir dest\n"); + commands.extend_from_slice(b"put /key1 v1\n"); + commands.extend_from_slice(b"put /key1 v2\n"); + commands.extend_from_slice(b"put /key1 v3\n"); + run_commands(&file_path, commands); + + // Move the key + let mut commands = Vec::new(); + commands.extend_from_slice(b"mv key1 dest/\n"); + run_commands(&file_path, commands); + + // Check history preserved + let mut commands = Vec::new(); + commands.extend_from_slice(b"cd dest\n"); + commands.extend_from_slice(b"history key1\n"); + let lines = run_commands(&file_path, commands); + assert!(lines.iter().any(|l| l.contains("v1"))); + assert!(lines.iter().any(|l| l.contains("v2"))); + assert!(lines.iter().any(|l| l.contains("v3"))); +} + +#[test] +fn test_move_single_match_with_pattern() { + let (_dir, file_path) = temp_test_file(); + + // Setup: create a key that matches a pattern uniquely + let mut commands = Vec::new(); + commands.extend_from_slice(b"mkdir dest\n"); + commands.extend_from_slice(b"put /api_key value1\n"); + commands.extend_from_slice(b"put /other_key value2\n"); + run_commands(&file_path, commands); + + // Move using pattern that matches only one key + let mut commands = Vec::new(); + commands.extend_from_slice(b"mv api_.* dest/\n"); + let lines = run_commands(&file_path, commands); + assert!(lines.iter().any(|l| l.contains("Moved"))); + + // Verify the key was moved + let mut commands = Vec::new(); + commands.extend_from_slice(b"get dest/api_key\n"); + let lines = run_commands(&file_path, commands); + assert!(lines.iter().any(|l| l.contains("value1"))); + + // Verify original location is empty + let mut commands = Vec::new(); + commands.extend_from_slice(b"get /api_key\n"); + let lines = run_commands(&file_path, commands); + assert!(lines.iter().any(|l| l.contains("No keys matching"))); +} + +#[test] +fn test_move_single_folder_with_pattern() { + let (_dir, file_path) = temp_test_file(); + + // Setup: create folders where pattern matches only one + let mut commands = Vec::new(); + commands.extend_from_slice(b"mkdir work_project\n"); + commands.extend_from_slice(b"mkdir personal\n"); + commands.extend_from_slice(b"mkdir archive\n"); + commands.extend_from_slice(b"put work_project/secret value1\n"); + run_commands(&file_path, commands); + + // Move folder using pattern + let mut commands = Vec::new(); + commands.extend_from_slice(b"mv work_.* archive/\n"); + let lines = run_commands(&file_path, commands); + eprintln!("Lines after mv work_.* archive/: {:?}", lines); + assert!(lines.iter().any(|l| l.contains("Moved folder"))); + + // Verify folder was moved with contents + let mut commands = Vec::new(); + commands.extend_from_slice(b"get archive/work_project/secret\n"); + let lines = run_commands(&file_path, commands); + assert!(lines.iter().any(|l| l.contains("value1"))); +} diff --git a/tests/storage_v5_tests.rs b/tests/storage_v5_tests.rs index 3fdcdd8..d17e729 100644 --- a/tests/storage_v5_tests.rs +++ b/tests/storage_v5_tests.rs @@ -783,3 +783,248 @@ fn test_multiple_nested_branches() { .any(|(p, k)| p == "/work/project_b" && *k == "api") ); } + +#[test] +fn test_move_key_same_folder() { + let mut storage = StorageV5::new(); + storage.put_at_path("/", "old_name".to_string(), "value".into(), 0); + + // Move with rename in same folder + storage + .move_key("/", "old_name", "/", Some("new_name")) + .unwrap(); + + // Old key should be gone + let results: Vec<_> = storage + .get_at_path("/", "old_name", false) + .unwrap() + .collect(); + assert_eq!(results.len(), 0); + + // New key should exist + let results: Vec<_> = storage + .get_at_path("/", "new_name", false) + .unwrap() + .collect(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].2.as_bytes(), "value".as_bytes()); +} + +#[test] +fn test_move_key_between_folders() { + let mut storage = StorageV5::new(); + storage.mkdir("/", "source").unwrap(); + storage.mkdir("/", "dest").unwrap(); + storage.put_at_path("/source", "key1".to_string(), "value1".into(), 0); + + // Move to different folder keeping same name + storage.move_key("/source", "key1", "/dest", None).unwrap(); + + // Should be gone from source + let results: Vec<_> = storage + .get_at_path("/source", "key1", false) + .unwrap() + .collect(); + assert_eq!(results.len(), 0); + + // Should exist in dest + let results: Vec<_> = storage + .get_at_path("/dest", "key1", false) + .unwrap() + .collect(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].2.as_bytes(), "value1".as_bytes()); +} + +#[test] +fn test_move_key_with_rename_between_folders() { + let mut storage = StorageV5::new(); + storage.mkdir("/", "source").unwrap(); + storage.mkdir("/", "dest").unwrap(); + storage.put_at_path("/source", "old_key".to_string(), "value".into(), 0); + + // Move and rename + storage + .move_key("/source", "old_key", "/dest", Some("new_key")) + .unwrap(); + + // Should be gone from source + let results: Vec<_> = storage + .get_at_path("/source", "old_key", false) + .unwrap() + .collect(); + assert_eq!(results.len(), 0); + + // Should exist in dest with new name + let results: Vec<_> = storage + .get_at_path("/dest", "new_key", false) + .unwrap() + .collect(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].2.as_bytes(), "value".as_bytes()); +} + +#[test] +fn test_move_key_preserves_history() { + use std::time::{SystemTime, UNIX_EPOCH}; + let mut storage = StorageV5::new(); + let timestamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + + storage.mkdir("/", "source").unwrap(); + storage.mkdir("/", "dest").unwrap(); + + // Add multiple versions + storage.put_at_path("/source", "key1".to_string(), "v1".into(), timestamp); + storage.put_at_path("/source", "key1".to_string(), "v2".into(), timestamp.add(1)); + storage.put_at_path("/source", "key1".to_string(), "v3".into(), timestamp.add(2)); + + // Move the key + storage.move_key("/source", "key1", "/dest", None).unwrap(); + + // Check history is preserved + let history: Vec<_> = storage.history_at_path("/dest", "key1").unwrap().collect(); + assert_eq!(history.len(), 3); + assert_eq!(history[0].encrypted_value().as_bytes(), "v1".as_bytes()); + assert_eq!(history[1].encrypted_value().as_bytes(), "v2".as_bytes()); + assert_eq!(history[2].encrypted_value().as_bytes(), "v3".as_bytes()); +} + +#[test] +fn test_move_key_collision_error() { + let mut storage = StorageV5::new(); + storage.mkdir("/", "source").unwrap(); + storage.mkdir("/", "dest").unwrap(); + storage.put_at_path("/source", "key1".to_string(), "value1".into(), 0); + storage.put_at_path("/dest", "key1".to_string(), "existing".into(), 0); + + // Should fail due to collision + let result = storage.move_key("/source", "key1", "/dest", None); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("already exists")); + + // Source should still have the key (move was not performed) + let results: Vec<_> = storage + .get_at_path("/source", "key1", false) + .unwrap() + .collect(); + assert_eq!(results.len(), 1); +} + +#[test] +fn test_move_key_nonexistent_source() { + let mut storage = StorageV5::new(); + storage.mkdir("/", "dest").unwrap(); + + let result = storage.move_key("/", "nonexistent", "/dest", None); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("not found")); +} + +#[test] +fn test_move_key_nonexistent_dest_folder() { + let mut storage = StorageV5::new(); + storage.put_at_path("/", "key1".to_string(), "value".into(), 0); + + let result = storage.move_key("/", "key1", "/nonexistent", None); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("not found")); +} + +#[test] +fn test_move_folder_between_parents() { + let mut storage = StorageV5::new(); + storage.mkdir("/", "source").unwrap(); + storage.mkdir("/source", "folder1").unwrap(); + storage.mkdir("/", "dest").unwrap(); + storage.put_at_path("/source/folder1", "key1".to_string(), "value1".into(), 0); + + // Move folder1 from /source to /dest + storage + .move_folder("/source", "folder1", "/dest", None) + .unwrap(); + + // Should be gone from source + assert!(storage.get_folder("/source/folder1").is_none()); + + // Should exist in dest with contents + assert!(storage.get_folder("/dest/folder1").is_some()); + let results: Vec<_> = storage + .get_at_path("/dest/folder1", "key1", false) + .unwrap() + .collect(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].2.as_bytes(), "value1".as_bytes()); +} + +#[test] +fn test_move_folder_with_rename() { + let mut storage = StorageV5::new(); + storage.mkdir("/", "old_name").unwrap(); + storage.put_at_path("/old_name", "key1".to_string(), "value1".into(), 0); + + // Rename folder + storage + .move_folder("/", "old_name", "/", Some("new_name")) + .unwrap(); + + // Old should be gone + assert!(storage.get_folder("/old_name").is_none()); + + // New should exist with contents + assert!(storage.get_folder("/new_name").is_some()); + let results: Vec<_> = storage + .get_at_path("/new_name", "key1", false) + .unwrap() + .collect(); + assert_eq!(results.len(), 1); +} + +#[test] +fn test_move_folder_preserves_nested_structure() { + let mut storage = StorageV5::new(); + storage.mkdir("/", "source").unwrap(); + storage.mkdir("/source", "folder1").unwrap(); + storage.mkdir("/source/folder1", "subfolder").unwrap(); + storage.put_at_path( + "/source/folder1/subfolder", + "deep_key".to_string(), + "deep_value".into(), + 0, + ); + storage.mkdir("/", "dest").unwrap(); + + // Move entire folder tree + storage + .move_folder("/source", "folder1", "/dest", None) + .unwrap(); + + // Verify nested structure preserved + assert!(storage.get_folder("/dest/folder1").is_some()); + assert!(storage.get_folder("/dest/folder1/subfolder").is_some()); + let results: Vec<_> = storage + .get_at_path("/dest/folder1/subfolder", "deep_key", false) + .unwrap() + .collect(); + assert_eq!(results.len(), 1); + assert_eq!(results[0].2.as_bytes(), "deep_value".as_bytes()); +} + +#[test] +fn test_move_folder_collision_error() { + let mut storage = StorageV5::new(); + storage.mkdir("/", "source").unwrap(); + storage.mkdir("/source", "folder1").unwrap(); + storage.mkdir("/", "dest").unwrap(); + storage.mkdir("/dest", "folder1").unwrap(); // Collision + + // Should fail + let result = storage.move_folder("/source", "folder1", "/dest", None); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("already exists")); + + // Source should still have it + assert!(storage.get_folder("/source/folder1").is_some()); +} From 91d56f3b56a1b1513ed4c2ef0c5bd1413f4e5663 Mon Sep 17 00:00:00 2001 From: Roman Studenikin Date: Tue, 30 Dec 2025 01:25:48 +0000 Subject: [PATCH 04/12] Simplify folder structure. Remove the requirement of a secret folder that pretends to be a simple key. This doesn't make sense for an opensource tool. If the attack vector includes stolen master password, then it the code can be recompiled to behave as one wants --- TODO | 35 +- src/cli/completer.rs | 39 +- src/cli/interactive.rs | 241 +++++------- src/cli/update.rs | 66 ++-- src/main.rs | 14 +- src/storage/v5.rs | 790 +++++++++++++++++++++++++------------- tests/storage_v5_tests.rs | 64 +-- 7 files changed, 728 insertions(+), 521 deletions(-) diff --git a/TODO b/TODO index 29482ac..9489bc6 100644 --- a/TODO +++ b/TODO @@ -11,27 +11,32 @@ - Comprehensive test coverage for nested folders ## encryption domains -⚠️ **PARTIALLY COMPLETED** -- Data structures exist: `Folder` has `encryption_domain: u32` field -- `SecretValue` enum supports both plain and encrypted folder variants +⚠️ **IN PROGRESS - Refactoring to unified structure** +- ✅ `FolderItem` enum with Secret/Folder/EncryptedFolder variants +- ✅ Unified `items` BTreeMap in Folder (no separate secrets/subfolders) +- ✅ Helper methods on FolderItem for ergonomic access - Default encryption domain (0) uses the master key (same as storage v4) -⏳ **TODO: CLI commands and functionality** -- Non-default domains should encrypt secrets with a custom key requiring another password -- Can be attached to either individual keys or folders -- When a folder has a non-default encrypted domain, all its contents should be encrypted together, such that it is impossible to know what is stored inside -- Should appear in the CLI UI as a regular key/value instead of a folder with a user-set value to confuse attackers in case master key is stolen -- "unlock" command for such a key asks for a password, decrypts its content and opens it for querying by all commands as if it was unencrypted +⏳ **TODO: Simplified encryption approach** +- Non-default domains encrypt secrets/folders with a custom key requiring password +- Can be attached to individual secrets or entire folders +- Encrypted folders are VISIBLE as encrypted folders (no hiding with placeholder values) +- When locked: `get` returns "**LOCKED**" indicator +- When unlocked: folder becomes navigable, contents accessible +- "unlock {folder}" command asks for password, decrypts and populates in-memory +- "lock {folder}" command clears decrypted data from memory +- Delete operation works uniformly for secrets, folders, and encrypted folders -**Use case** +**Simplified Use Case** Encryption: -- User can call a command "lock {folder/key}" which would ask which encryption domain to use or to create a new one, password for it and a PlaceholderSecret value to show when it is locked. -- The value or a folder is immediately encrypted and stored encrypted in memory -- get/set/history commands work as if a PlaceholderSecret is a real stored secret value. +- User calls "lock {folder/key}" which asks for encryption domain and password +- Content is immediately encrypted and stored encrypted +- Locked items show "**LOCKED**" when accessed Decryption: -- get/set/history commands work as always as if a PlaceHolderSecret was a real value. set command can override the value even if it holds an encrypted folder underneath. -- "unlock key" command checks encryption domain and if a key for this domain is already cached, then it performs decryption and if it is a folder, decrypts its contents into memory making its contents available for get/set/history commands +- "unlock {item}" asks for password +- Decrypts into memory, making content accessible +- Subsequent commands work normally on decrypted content ## move command ✅ **COMPLETED: move command** diff --git a/src/cli/completer.rs b/src/cli/completer.rs index 6337a0d..17bd984 100644 --- a/src/cli/completer.rs +++ b/src/cli/completer.rs @@ -40,29 +40,26 @@ impl CypherCompleter { let mut matches: Vec = Vec::new(); if let Some(folder) = storage.get_folder(&target_path) { - // Add matching secret keys - if include_keys { - for key in folder.secrets.keys() { - if key.starts_with(prefix) { - let full_path = format_full_path(dir_part, key, false); - matches.push(Pair { - display: full_path.clone(), - replacement: full_path, - }); - } + // Iterate through all items in the folder + for (name, item) in &folder.items { + if !name.starts_with(prefix) { + continue; } - } - // Add matching folder names - if include_folders { - for name in folder.subfolders.keys() { - if name.starts_with(prefix) { - let full_path = format_full_path(dir_part, name, true); - matches.push(Pair { - display: full_path.clone(), - replacement: full_path, - }); - } + // Check if we should include this item based on its type + let should_include = match item { + _ if item.is_secret() => include_keys, + _ if item.is_navigable() || item.is_locked() => include_folders, + _ => false, + }; + + if should_include { + let is_folder = item.is_any_folder(); + let full_path = format_full_path(dir_part, name, is_folder); + matches.push(Pair { + display: full_path.clone(), + replacement: full_path, + }); } } } diff --git a/src/cli/interactive.rs b/src/cli/interactive.rs index 2852c9d..f701b84 100644 --- a/src/cli/interactive.rs +++ b/src/cli/interactive.rs @@ -354,170 +354,91 @@ impl InteractiveCli { destination: &str, storage: &mut StorageV5, ) -> Result<()> { + use regex::Regex; let current_path = self.get_current_path(); - // Find matching keys and folders - let key_count = storage - .get_at_path(¤t_path, source_pattern, true)? - .count(); - let folder_count = storage - .search_at_path(¤t_path, source_pattern, true)? - .filter(|(path, name)| { - // Only count folders (check if it exists as a subfolder) - let check_path = if path == "/" { - format!("/{name}") - } else { - format!("{path}/{name}") - }; - storage.get_folder(&check_path).is_some() - }) - .count(); - - let total_matches = key_count + folder_count; - - if total_matches == 0 { + // Parse the source pattern to extract folder path and item pattern + let (source_folder, item_pattern) = parse_key_path(¤t_path, source_pattern); + let re = Regex::new(&format!("^{item_pattern}$"))?; + + // Find all matching items, avoiding double-counting when folder matches + let mut matches = Vec::new(); + find_matching_items(storage, &source_folder, &re, &mut matches); + + if matches.is_empty() { bail!("No keys or folders matching pattern '{source_pattern}'"); } - if total_matches == 1 { - // Single match: could be a key or a folder - // Get the actual matched item (not the pattern) - let (source_path, source_name, is_folder) = if key_count == 1 { - // It's a key - let (folder, key, _) = storage - .get_at_path(¤t_path, source_pattern, true)? - .next() - .expect("key_count is 1"); - (folder, key.to_string(), false) + // Determine if destination is an existing folder + let dest_is_folder = destination.ends_with('/') + || storage + .get_folder(&resolve_path(¤t_path, destination)) + .is_some(); + + if matches.len() == 1 { + // Single match: can move to folder or rename + let (parent_path, item_name, is_folder) = &matches[0]; + + let (dest_parent, dest_name) = if dest_is_folder { + // Move into existing folder, keep name + (resolve_path(¤t_path, destination), None) } else { - // It's a folder - let (path, name) = storage - .search_at_path(¤t_path, source_pattern, true)? - .find(|(path, name)| { - let check_path = if path == "/" { - format!("/{name}") - } else { - format!("{path}/{name}") - }; - storage.get_folder(&check_path).is_some() - }) - .expect("folder_count is 1"); - (path, name.to_string(), true) + // Rename to new path + let (folder, name) = parse_key_path(¤t_path, destination); + (folder, Some(name)) }; - // Determine destination - let dest_is_existing_folder = destination.ends_with('/') - || storage - .get_folder(&resolve_path(¤t_path, destination)) - .is_some(); - - if is_folder { - // Moving a folder - let dest_parent = if dest_is_existing_folder { - // Destination is a folder, move into it - resolve_path(¤t_path, destination) - } else { - // Destination is a new name (rename) - let (dest_parent, _) = parse_key_path(¤t_path, destination); - dest_parent - }; - - let dest_name = if dest_is_existing_folder { - None // Keep same name - } else { - let (_, name) = parse_key_path(¤t_path, destination); - Some(name) - }; - - storage.move_folder(&source_path, &source_name, &dest_parent, dest_name)?; - - let dest_display = if let Some(dn) = dest_name { - format_full_path(&dest_parent, dn, true) - } else { - format_full_path(&dest_parent, &source_name, true) - }; - - secure_print( - format!( - "Moved folder {} -> {}", - format_full_path(&source_path, &source_name, true), - dest_display - ), - self.insecure_stdout, - )?; + storage.move_item(parent_path, item_name, &dest_parent, dest_name)?; + + let final_name = dest_name.unwrap_or(item_name.as_str()); + let dest_display = format_full_path(&dest_parent, final_name, *is_folder); + let source_display = format_full_path(parent_path, item_name, *is_folder); + + let message = if *is_folder { + format!("Moved folder {source_display} -> {dest_display}") } else { - // Moving a key - let (dest_folder, dest_key_opt) = parse_key_path(¤t_path, destination); - - let dest_key = if dest_is_existing_folder { - None - } else { - Some(dest_key_opt) - }; - - storage.move_key(&source_path, &source_name, &dest_folder, dest_key)?; - - let dest_display = if let Some(dk) = dest_key { - format_full_path(&dest_folder, dk, false) - } else { - format_full_path(&dest_folder, &source_name, false) - }; - - secure_print( - format!( - "Moved {} -> {}", - format_full_path(&source_path, &source_name, false), - dest_display - ), - self.insecure_stdout, - )?; - } + format!("Moved {source_display} -> {dest_display}") + }; + secure_print(message, self.insecure_stdout)?; } else { // Multiple matches: destination must be a folder - let dest_folder = resolve_path(¤t_path, destination); - - if storage.get_folder(&dest_folder).is_none() { - bail!( - "Destination '{destination}' is not a folder (required when moving multiple items)" - ); + if !dest_is_folder { + bail!("Destination must be a folder when moving multiple items"); } - // Collect keys to move - let keys_to_move: Vec<(String, String)> = storage - .get_at_path(¤t_path, source_pattern, true)? - .map(|(folder, key, _)| (folder, key.to_string())) - .collect(); - - // Collect folders to move - let folders_to_move: Vec<(String, String)> = storage - .search_at_path(¤t_path, source_pattern, true)? - .filter_map(|(path, name)| { - let check_path = if path == "/" { - format!("/{name}") - } else { - format!("{path}/{name}") - }; - if storage.get_folder(&check_path).is_some() { - Some((path, name.to_string())) - } else { - None - } - }) - .collect(); - - // Move all keys - for (source_folder, source_key) in &keys_to_move { - storage.move_key(source_folder, source_key, &dest_folder, None)?; + let dest_folder = resolve_path(¤t_path, destination); + if storage.get_folder(&dest_folder).is_none() { + bail!("Destination folder '{dest_folder}' does not exist"); } - // Move all folders - for (parent_path, folder_name) in &folders_to_move { - storage.move_folder(parent_path, folder_name, &dest_folder, None)?; + // Count keys and folders + let key_count = matches + .iter() + .filter(|(_, _, is_folder)| !is_folder) + .count(); + let folder_count = matches + .iter() + .filter(|(_, _, is_folder)| *is_folder) + .count(); + + // Move all items + for (parent_path, item_name, _) in &matches { + storage.move_item(parent_path, item_name, &dest_folder, None)?; } - let message = match (keys_to_move.len(), folders_to_move.len()) { - (k, 0) => format!("Moved {k} keys to {dest_folder}"), - (0, f) => format!("Moved {f} folders to {dest_folder}"), + let message = match (key_count, folder_count) { + (0, n) => format!( + "Moved {} {} to {}", + n, + if n == 1 { "folder" } else { "folders" }, + dest_folder + ), + (n, 0) => format!( + "Moved {} {} to {}", + n, + if n == 1 { "key" } else { "keys" }, + dest_folder + ), (k, f) => format!("Moved {k} keys and {f} folders to {dest_folder}"), }; secure_print(message, self.insecure_stdout)?; @@ -531,6 +452,32 @@ impl InteractiveCli { println!("{}", self.get_current_path()); } } +fn find_matching_items( + storage: &StorageV5, + path: &str, + pattern: ®ex::Regex, + results: &mut Vec<(String, String, bool)>, +) { + let Some(folder) = storage.get_folder(path) else { + return; + }; + + for (name, item) in &folder.items { + let matches = pattern.is_match(name); + + if matches { + // Item matches - add it to results + let is_folder = item.is_folder() || item.is_locked(); + results.push((path.to_string(), name.clone(), is_folder)); + // KEY: Don't recurse into matched folders to avoid double-counting + } else if item.is_folder() { + // Item doesn't match but is a folder - recurse to find matches inside + let subfolder_path = format_full_path(path, name, true); + find_matching_items(storage, &subfolder_path, pattern, results); + } + // Locked folders that don't match are skipped (can't recurse into them) + } +} fn clear_screen() { print!("\x1B[2J\x1B[1;1H"); // Clear screen diff --git a/src/cli/update.rs b/src/cli/update.rs index 25d5e9b..66ee6e2 100644 --- a/src/cli/update.rs +++ b/src/cli/update.rs @@ -36,42 +36,46 @@ fn find_updates_in_folder( }; // Check secrets in this folder - for (key, update_entries) in &update_folder.secrets { - let update_latest = update_entries.last().expect("entries should not be empty"); - let main_latest = main_storage - .get_folder(folder_path) - .and_then(|f| f.secrets.get(key)) - .and_then(|entries| entries.last()); - - let should_update = main_latest.is_none_or(|main_entry| { - // Key exists - decrypt and compare values - let main_decrypted = main_entry - .encrypted_value() - .decrypt(main_cypher) - .expect("Failed to decrypt main file"); - let update_decrypted = update_latest - .encrypted_value() - .decrypt(update_cypher) - .expect("Failed to decrypt update file"); - - // Update if values differ and update is newer or same timestamp - *main_decrypted != *update_decrypted && update_latest.timestamp >= main_entry.timestamp - }); - - if should_update { - updates.push(UpdateEntry { - folder_path: folder_path.to_string(), - key: key.clone(), - new_value: update_latest.encrypted_value().clone(), - new_timestamp: update_latest.timestamp, - old_value: main_latest.map(|e| e.encrypted_value().clone()), - old_timestamp: main_latest.map(|e| e.timestamp), + for (key, item) in update_folder.secrets() { + if let Some(update_entries) = item.get_entries() { + let update_latest = update_entries.last().expect("entries should not be empty"); + let main_latest = main_storage + .get_folder(folder_path) + .and_then(|f| f.get_item(key)) + .and_then(|item| item.get_entries()) + .and_then(|entries| entries.last()); + + let should_update = main_latest.is_none_or(|main_entry| { + // Key exists - decrypt and compare values + let main_decrypted = main_entry + .encrypted_value() + .decrypt(main_cypher) + .expect("Failed to decrypt main file"); + let update_decrypted = update_latest + .encrypted_value() + .decrypt(update_cypher) + .expect("Failed to decrypt update file"); + + // Update if values differ and update is newer or same timestamp + *main_decrypted != *update_decrypted + && update_latest.timestamp >= main_entry.timestamp }); + + if should_update { + updates.push(UpdateEntry { + folder_path: folder_path.to_string(), + key: key.clone(), + new_value: update_latest.encrypted_value().clone(), + new_timestamp: update_latest.timestamp, + old_value: main_latest.map(|e| e.encrypted_value().clone()), + old_timestamp: main_latest.map(|e| e.timestamp), + }); + } } } // Recursively check subfolders - for subfolder_name in update_folder.subfolders.keys() { + for (subfolder_name, _) in update_folder.navigable_folders() { let subfolder_path = if folder_path == "/" { format!("/{subfolder_name}") } else { diff --git a/src/main.rs b/src/main.rs index d8af1e5..f740eac 100644 --- a/src/main.rs +++ b/src/main.rs @@ -157,12 +157,14 @@ fn run_upgrade_storage( let mut new_storage = StorageV5::new(); let new_cypher = Cypher::new(new_key); - for (key, entries) in old_storage.root.secrets { - for entry in entries { - let mut secret = entry.encrypted_value().decrypt(&old_cypher)?; - let new_value = EncryptedValue::encrypt(&new_cypher, &secret)?; - new_storage.put_at_path("/", key.clone(), new_value, entry.timestamp); - secret.zeroize(); + for (key, item) in old_storage.root.secrets() { + if let Some(entries) = item.get_entries() { + for entry in entries { + let mut secret = entry.encrypted_value().decrypt(&old_cypher)?; + let new_value = EncryptedValue::encrypt(&new_cypher, &secret)?; + new_storage.put_at_path("/", key.clone(), new_value, entry.timestamp); + secret.zeroize(); + } } } diff --git a/src/storage/v5.rs b/src/storage/v5.rs index c4a3b40..731baff 100644 --- a/src/storage/v5.rs +++ b/src/storage/v5.rs @@ -38,7 +38,7 @@ impl StorageV5 { let mut current = &self.root; for part in parts { - current = current.subfolders.get(part)?; + current = current.get_subfolder(part)?; } Some(current) @@ -54,7 +54,7 @@ impl StorageV5 { let mut current = &mut self.root; for part in parts { - current = current.subfolders.get_mut(part)?; + current = current.get_subfolder_mut(part)?; } Some(current) @@ -66,13 +66,14 @@ impl StorageV5 { .get_folder_mut(path) .ok_or_else(|| anyhow::anyhow!("Parent folder '{path}' not found"))?; - if parent.subfolders.contains_key(folder_name) { - bail!("Folder '{folder_name}' already exists"); + if parent.items.contains_key(folder_name) { + bail!("Item '{folder_name}' already exists"); } - parent.subfolders.insert( + let new_folder = Folder::new(folder_name.to_string(), parent.encryption_domain); + parent.items.insert( folder_name.to_string(), - Folder::new(folder_name.to_string(), parent.encryption_domain), + FolderItem::new_folder(folder_name.to_string(), new_folder), ); Ok(()) @@ -81,11 +82,17 @@ impl StorageV5 { /// Store a secret value at a specific path pub fn put_at_path(&mut self, path: &str, key: String, value: EncryptedValue, timestamp: u64) { if let Some(folder) = self.get_folder_mut(path) { + let entry = SecretEntry::new(value, timestamp); + folder - .secrets - .entry(key) - .or_default() - .push(SecretEntry::new_plain(value, timestamp, 0)); + .items + .entry(key.clone()) + .and_modify(|item| { + if let Some(entries) = item.get_entries_mut() { + entries.push(entry.clone()); + } + }) + .or_insert_with(|| FolderItem::new_secret(key, vec![entry], 0)); } } @@ -140,13 +147,15 @@ impl StorageV5 { } /// Returns an iterator over all historical values for a given key at a specific path. + /// Returns None for folders (regular or encrypted). pub fn history_at_path( &self, path: &str, key: &str, ) -> Option + '_> { self.get_folder(path) - .and_then(|folder| folder.secrets.get(key)) + .and_then(|folder| folder.get_item(key)) + .and_then(|item| item.get_entries()) .map(|entries| entries.iter()) } @@ -155,26 +164,27 @@ impl StorageV5 { self.delete_at_path("/", key) } - /// Delete a key and all its history from a specific path + /// Delete an item (secret, folder, or encrypted folder) from a specific path pub fn delete_at_path(&mut self, path: &str, key: &str) -> bool { self.get_folder_mut(path) - .and_then(|folder| folder.secrets.remove(key)) + .and_then(|folder| folder.items.remove(key)) .is_some() } - /// Move a key from one location to another (like shell mv) - /// `source_folder`: folder containing the key - /// key: the key to move + /// Move an item (secret, folder, or encrypted folder) from one location to another + /// Works like shell `mv` - handles both files and directories uniformly + /// `source_folder`: folder containing the item + /// `item_name`: name of the item to move /// `dest_folder`: destination folder - /// `dest_key`: optional new key name (if None, keeps same name) - pub fn move_key( + /// `dest_name`: optional new name (if None, keeps same name) + pub fn move_item( &mut self, source_folder: &str, - key: &str, + item_name: &str, dest_folder: &str, - dest_key: Option<&str>, + dest_name: Option<&str>, ) -> Result<()> { - let final_key = dest_key.unwrap_or(key); + let final_name = dest_name.unwrap_or(item_name); // Check destination folder exists and no collision (must do before removing from source) { @@ -182,33 +192,53 @@ impl StorageV5 { .get_folder(dest_folder) .ok_or_else(|| anyhow::anyhow!("Destination folder '{dest_folder}' not found"))?; - if dest.secrets.contains_key(final_key) { - bail!("Key '{final_key}' already exists at destination '{dest_folder}'"); + if dest.items.contains_key(final_name) { + bail!("Item '{final_name}' already exists at destination '{dest_folder}'"); } } // Remove from source - let entries = self + let mut item = self .get_folder_mut(source_folder) .ok_or_else(|| anyhow::anyhow!("Source folder '{source_folder}' not found"))? - .secrets - .remove(key) - .ok_or_else(|| anyhow::anyhow!("Key '{key}' not found in '{source_folder}'"))?; + .items + .remove(item_name) + .ok_or_else(|| anyhow::anyhow!("Item '{item_name}' not found in '{source_folder}'"))?; + + // Update the item's name if renaming + if final_name != item_name { + match &mut item { + FolderItem::Secret { name, .. } + | FolderItem::Folder { name, .. } + | FolderItem::EncryptedFolder { name, .. } => { + *name = final_name.to_string(); + } + } + } // Insert into dest self.get_folder_mut(dest_folder) .expect("dest folder exists") - .secrets - .insert(final_key.to_string(), entries); + .items + .insert(final_name.to_string(), item); Ok(()) } - /// Move a folder from one location to another (like shell mv for directories) - /// `parent_path`: parent folder containing the folder to move - /// `folder_name`: name of the folder to move - /// `dest_parent`: destination parent folder - /// `dest_name`: optional new folder name (if None, keeps same name) + /// Legacy method - redirects to `move_item` for backwards compatibility + #[deprecated(note = "Use move_item instead")] + pub fn move_key( + &mut self, + source_folder: &str, + key: &str, + dest_folder: &str, + dest_key: Option<&str>, + ) -> Result<()> { + self.move_item(source_folder, key, dest_folder, dest_key) + } + + /// Legacy method - redirects to `move_item` for backwards compatibility + #[deprecated(note = "Use move_item instead")] pub fn move_folder( &mut self, parent_path: &str, @@ -216,36 +246,7 @@ impl StorageV5 { dest_parent: &str, dest_name: Option<&str>, ) -> Result<()> { - let final_name = dest_name.unwrap_or(folder_name); - - // Check destination parent exists and no collision - { - let dest = self - .get_folder(dest_parent) - .ok_or_else(|| anyhow::anyhow!("Destination folder '{dest_parent}' not found"))?; - - if dest.subfolders.contains_key(final_name) { - bail!("Folder '{final_name}' already exists at destination '{dest_parent}'"); - } - } - - // Remove from source - let folder = self - .get_folder_mut(parent_path) - .ok_or_else(|| anyhow::anyhow!("Source folder '{parent_path}' not found"))? - .subfolders - .remove(folder_name) - .ok_or_else(|| { - anyhow::anyhow!("Folder '{folder_name}' not found in '{parent_path}'") - })?; - - // Insert into dest - self.get_folder_mut(dest_parent) - .expect("dest parent exists") - .subfolders - .insert(final_name.to_string(), folder); - - Ok(()) + self.move_item(parent_path, folder_name, dest_parent, dest_name) } /// Returns an iterator over all keys matching the given regex pattern in root folder. @@ -287,34 +288,29 @@ impl StorageV5 { } // ============================================================================ -// Recursive Iterators - Zero-copy iteration through folder hierarchies +// Zero-copy iterators through Folder structure // ============================================================================ -type SecretsIterator<'a> = std::collections::btree_map::Iter<'a, String, Vec>; -type KeysIterator<'a> = std::collections::btree_map::Keys<'a, String, Vec>; -type FolderIterator<'a> = std::collections::btree_map::Iter<'a, String, Folder>; +type ItemsIterator<'a> = std::collections::btree_map::Iter<'a, String, FolderItem>; + /// Iterator over secrets in a folder and optionally its subfolders pub struct RecursiveSecretIterator<'a> { - // Stack of (current_path, folder, secrets_iter) for depth-first traversal - stack: Vec<(String, &'a Folder, SecretsIterator<'a>)>, + // Single stack of (current_path, items_iter) for depth-first traversal + stack: Vec<(String, ItemsIterator<'a>)>, regex: Regex, recursive: bool, - // For tracking subfolders to visit - stores (current_path, subfolders_iter) - subfolders_stack: Vec<(String, FolderIterator<'a>)>, // Search root for computing relative paths in regex matching search_root: String, } impl<'a> RecursiveSecretIterator<'a> { fn new(folder: &'a Folder, regex: Regex, recursive: bool, initial_path: String) -> Self { - let secrets_iter = folder.secrets.iter(); - let subfolders_iter = folder.subfolders.iter(); + let items_iter = folder.items.iter(); Self { - stack: vec![(initial_path.clone(), folder, secrets_iter)], + stack: vec![(initial_path.clone(), items_iter)], regex, recursive, - subfolders_stack: vec![(initial_path.clone(), subfolders_iter)], search_root: initial_path, } } @@ -325,49 +321,57 @@ impl<'a> Iterator for RecursiveSecretIterator<'a> { fn next(&mut self) -> Option { loop { - // Try to get next secret from current folder - if let Some((current_path, _, secrets_iter)) = self.stack.last_mut() { + // Try to get next item from current folder + let mut descended = false; + if let Some((current_path, items_iter)) = self.stack.last_mut() { let path = current_path.clone(); // Compute relative path from search root for regex matching let relative_folder = relative_path_from(&self.search_root, &path); - for (key, entries) in secrets_iter.by_ref() { + for (key, item) in items_iter.by_ref() { // Match regex against full relative path (folder + key) let full_path = format_full_path(&relative_folder, key, false); + + // Check if this is a secret with matching pattern if self.regex.is_match(&full_path) - && !entries.is_empty() - && let Some(entry) = entries.last() + && let Some(value) = item.get_latest_value() + { + return Some((path, key.as_str(), value)); + } + + // If recursive and this is a navigable folder, descend into it + if self.recursive + && let Some(subfolder) = item.get_folder() { - return Some((path, key.as_str(), entry.encrypted_value())); + let new_path = format_full_path(&path, key, true); + self.stack.push((new_path, subfolder.items.iter())); + descended = true; + // Break to process the new folder + break; } } } - // Current folder exhausted, try to descend into subfolder - if self.recursive - && let Some((current_path, subfolders_iter)) = self.subfolders_stack.last_mut() - && let Some((subfolder_name, subfolder)) = subfolders_iter.next() - { - // Build path for the subfolder - let new_path = if current_path == "/" { - format!("/{subfolder_name}") - } else { - format!("{current_path}/{subfolder_name}") - }; - - // Push new folder onto stack - self.stack - .push((new_path.clone(), subfolder, subfolder.secrets.iter())); - self.subfolders_stack - .push((new_path, subfolder.subfolders.iter())); + // If we descended into a subfolder, continue to process it + if descended { continue; } - // No more subfolders, pop the stack - self.stack.pop(); - self.subfolders_stack.pop(); + // Current folder exhausted, pop and continue with parent + if self.stack.len() > 1 { + self.stack.pop(); + continue; + } - if self.stack.is_empty() { + // Nothing left to iterate + if self.stack.is_empty() || self.stack.len() == 1 { + // Check if the last stack has more items + if let Some((_, items_iter)) = self.stack.last_mut() { + if items_iter.len() == 0 { + return None; + } + continue; + } return None; } } @@ -376,26 +380,22 @@ impl<'a> Iterator for RecursiveSecretIterator<'a> { /// Iterator over keys in a folder and optionally its subfolders pub struct RecursiveKeyIterator<'a> { - // Stack of (current_path, keys_iter) for tracking keys in each folder - stack: Vec<(String, KeysIterator<'a>)>, + // Single stack of (current_path, items_iter) for depth-first traversal + stack: Vec<(String, ItemsIterator<'a>)>, regex: Regex, recursive: bool, - // Stack of (current_path, folder, subfolders_iter) for descending into subfolders - folder_stack: Vec<(String, &'a Folder, FolderIterator<'a>)>, // Search root for computing relative paths in regex matching search_root: String, } impl<'a> RecursiveKeyIterator<'a> { fn new(folder: &'a Folder, regex: Regex, recursive: bool, initial_path: String) -> Self { - let keys_iter = folder.secrets.keys(); - let subfolders_iter = folder.subfolders.iter(); + let items_iter = folder.items.iter(); Self { - stack: vec![(initial_path.clone(), keys_iter)], + stack: vec![(initial_path.clone(), items_iter)], regex, recursive, - folder_stack: vec![(initial_path.clone(), folder, subfolders_iter)], search_root: initial_path, } } @@ -406,46 +406,55 @@ impl<'a> Iterator for RecursiveKeyIterator<'a> { fn next(&mut self) -> Option { loop { + let mut descended = false; // Try to get next key from current folder - if let Some((current_path, keys_iter)) = self.stack.last_mut() { + if let Some((current_path, items_iter)) = self.stack.last_mut() { let path = current_path.clone(); // Compute relative path from search root for regex matching let relative_folder = relative_path_from(&self.search_root, &path); - for key in keys_iter.by_ref() { + for (key, item) in items_iter.by_ref() { // Match regex against full relative path (folder + key) let full_path = format_full_path(&relative_folder, key, false); - if self.regex.is_match(&full_path) { + + // Check if this is a secret (not a folder) with matching pattern + if self.regex.is_match(&full_path) && item.is_secret() { return Some((path, key.as_str())); } + + // If recursive and this is a navigable folder, descend into it + if self.recursive + && let Some(subfolder) = item.get_folder() + { + let new_path = format_full_path(&path, key, true); + self.stack.push((new_path, subfolder.items.iter())); + descended = true; + // Break to process the new folder + break; + } } } - // Current folder exhausted, try to descend into subfolder - if self.recursive - && let Some((current_path, _, subfolders_iter)) = self.folder_stack.last_mut() - && let Some((subfolder_name, subfolder)) = subfolders_iter.next() - { - // Build path for the subfolder - let new_path = if current_path == "/" { - format!("/{subfolder_name}") - } else { - format!("{current_path}/{subfolder_name}") - }; - - // Push new folder onto stack - self.stack - .push((new_path.clone(), subfolder.secrets.keys())); - self.folder_stack - .push((new_path, subfolder, subfolder.subfolders.iter())); + // If we descended into a subfolder, continue to process it + if descended { continue; } - // No more subfolders, pop the stack - self.stack.pop(); - self.folder_stack.pop(); + // Current folder exhausted, pop and continue with parent + if self.stack.len() > 1 { + self.stack.pop(); + continue; + } - if self.stack.is_empty() || self.folder_stack.is_empty() { + // Nothing left to iterate + if self.stack.is_empty() || self.stack.len() == 1 { + // Check if the last stack has more items + if let Some((_, items_iter)) = self.stack.last_mut() { + if items_iter.len() == 0 { + return None; + } + continue; + } return None; } } @@ -459,25 +468,274 @@ impl Default for StorageV5 { } // ============================================================================ -// Folder - Hierarchical container for secrets +// FolderItem - Unified enum for secrets and folders +// ============================================================================ + +/// An item in a folder - can be either a secret or a subfolder +#[derive(Debug, Clone, Encode, Decode)] +pub enum FolderItem { + /// Regular secret with history + Secret { + name: String, + entries: Vec, + encryption_domain: u32, + }, + + /// Regular folder that can be navigated + Folder { name: String, folder: Box }, + + /// Encrypted folder - visible as encrypted, shows "**LOCKED**" when locked + EncryptedFolder { + name: String, + /// Serialized encrypted folder bytes + encrypted_data: Vec, + /// Which encryption domain key to use + encryption_domain: u32, + /// Decrypted folder when unlocked (always None when serialized, populated in memory only) + decrypted_folder: Option>, + }, +} + +impl FolderItem { + // ========== Constructors ========== + + pub const fn new_secret( + name: String, + entries: Vec, + encryption_domain: u32, + ) -> Self { + Self::Secret { + name, + entries, + encryption_domain, + } + } + + pub fn new_folder(name: String, folder: Folder) -> Self { + Self::Folder { + name, + folder: Box::new(folder), + } + } + + pub const fn new_encrypted_folder( + name: String, + encrypted_data: Vec, + encryption_domain: u32, + ) -> Self { + Self::EncryptedFolder { + name, + encrypted_data, + encryption_domain, + decrypted_folder: None, + } + } + + // ========== Basic Getters ========== + + /// Get the name of this item + pub fn name(&self) -> &str { + match self { + Self::Secret { name, .. } + | Self::Folder { name, .. } + | Self::EncryptedFolder { name, .. } => name, + } + } + + /// Get the encryption domain for this item + /// Returns folder's default domain for regular folders + pub fn encryption_domain(&self) -> u32 { + match self { + Self::Secret { + encryption_domain, .. + } + | Self::EncryptedFolder { + encryption_domain, .. + } => *encryption_domain, + Self::Folder { folder, .. } => folder.encryption_domain, + } + } + + // ========== Type Checking ========== + + pub const fn is_secret(&self) -> bool { + matches!(self, Self::Secret { .. }) + } + + pub const fn is_folder(&self) -> bool { + matches!(self, Self::Folder { .. }) + } + + pub const fn is_encrypted_folder(&self) -> bool { + matches!(self, Self::EncryptedFolder { .. }) + } + + /// Check if this is any kind of folder (regular or encrypted) + pub const fn is_any_folder(&self) -> bool { + matches!(self, Self::Folder { .. } | Self::EncryptedFolder { .. }) + } + + /// Check if this is a locked encrypted folder + pub const fn is_locked(&self) -> bool { + matches!( + self, + Self::EncryptedFolder { + decrypted_folder: None, + .. + } + ) + } + + /// Check if this is an unlocked encrypted folder + pub const fn is_unlocked(&self) -> bool { + matches!( + self, + Self::EncryptedFolder { + decrypted_folder: Some(_), + .. + } + ) + } + + /// Check if this item can be navigated into (regular folder or unlocked encrypted folder) + pub const fn is_navigable(&self) -> bool { + matches!( + self, + Self::Folder { .. } + | Self::EncryptedFolder { + decrypted_folder: Some(_), + .. + } + ) + } + + // ========== Accessing Folder Data ========== + + /// Get folder reference if this is a navigable folder + /// Returns regular folder or unlocked encrypted folder + pub fn get_folder(&self) -> Option<&Folder> { + match self { + Self::Folder { folder, .. } + | Self::EncryptedFolder { + decrypted_folder: Some(folder), + .. + } => Some(folder), + _ => None, + } + } + + /// Get mutable folder reference if this is a navigable folder + pub fn get_folder_mut(&mut self) -> Option<&mut Folder> { + match self { + Self::Folder { folder, .. } + | Self::EncryptedFolder { + decrypted_folder: Some(folder), + .. + } => Some(folder), + _ => None, + } + } + + /// Get the underlying Folder box (for moving folders around) + pub fn take_folder(self) -> Option> { + match self { + Self::Folder { folder, .. } + | Self::EncryptedFolder { + decrypted_folder: Some(folder), + .. + } => Some(folder), + _ => None, + } + } + + // ========== Accessing Secret Data ========== + + /// Get secret entries (only for secrets, not folders) + pub const fn get_entries(&self) -> Option<&Vec> { + match self { + Self::Secret { entries, .. } => Some(entries), + _ => None, + } + } + + /// Get mutable secret entries (only for secrets) + pub const fn get_entries_mut(&mut self) -> Option<&mut Vec> { + match self { + Self::Secret { entries, .. } => Some(entries), + _ => None, + } + } + + /// Get the latest secret entry (most recent value) + pub fn get_latest_entry(&self) -> Option<&SecretEntry> { + self.get_entries()?.last() + } + + /// Get the latest encrypted value (for display/copy) + pub fn get_latest_value(&self) -> Option<&EncryptedValue> { + self.get_latest_entry().map(SecretEntry::encrypted_value) + } + + // ========== Encrypted Folder Operations ========== + + /// Unlock an encrypted folder with the decrypted data + /// Returns error if not an encrypted folder or already unlocked + pub fn unlock(&mut self, decrypted_folder: Folder) -> Result<()> { + match self { + Self::EncryptedFolder { + decrypted_folder: df, + .. + } => { + if df.is_some() { + bail!("Folder already unlocked"); + } + *df = Some(Box::new(decrypted_folder)); + Ok(()) + } + _ => bail!("Not an encrypted folder"), + } + } + + /// Lock an encrypted folder (clear the decrypted data) + pub fn lock(&mut self) -> Result<()> { + match self { + Self::EncryptedFolder { + decrypted_folder: df, + .. + } => { + *df = None; + Ok(()) + } + _ => bail!("Not an encrypted folder"), + } + } + + /// Get the encrypted data bytes (for re-encryption or storage) + pub fn get_encrypted_data(&self) -> Option<&[u8]> { + match self { + Self::EncryptedFolder { encrypted_data, .. } => Some(encrypted_data), + _ => None, + } + } +} + +// ============================================================================ +// Folder - Hierarchical container with unified items // ============================================================================ -/// A folder containing secrets and subfolders +/// A folder containing a unified collection of secrets and subfolders #[derive(Debug, Clone, Encode, Decode)] pub struct Folder { /// Folder name (empty string for root) pub name: String, - /// Which encryption domain this folder belongs to + /// Default encryption domain for new items created in this folder /// - 0 = default domain (master key) /// - N > 0 = custom domain (requires separate password) pub encryption_domain: u32, - /// Secrets in this folder (key -> history of values) - pub secrets: BTreeMap>, - - /// Subfolders - pub subfolders: BTreeMap, + /// All items in this folder (secrets AND subfolders) + pub items: BTreeMap, } impl Folder { @@ -486,8 +744,7 @@ impl Folder { Self { name: String::new(), encryption_domain: 0, - secrets: BTreeMap::new(), - subfolders: BTreeMap::new(), + items: BTreeMap::new(), } } @@ -496,10 +753,48 @@ impl Folder { Self { name, encryption_domain, - secrets: BTreeMap::new(), - subfolders: BTreeMap::new(), + items: BTreeMap::new(), } } + + // ========== Helper Methods ========== + + /// Get all secrets (excluding folders) + pub fn secrets(&self) -> impl Iterator { + self.items.iter().filter(|(_, item)| item.is_secret()) + } + + /// Get all folders (regular, encrypted, locked or unlocked) + pub fn all_folders(&self) -> impl Iterator { + self.items.iter().filter(|(_, item)| item.is_any_folder()) + } + + /// Get all navigable folders (regular + unlocked encrypted) + pub fn navigable_folders(&self) -> impl Iterator { + self.items.iter().filter(|(_, item)| item.is_navigable()) + } + + /// Get an item by name + pub fn get_item(&self, name: &str) -> Option<&FolderItem> { + self.items.get(name) + } + + /// Get a mutable item by name + pub fn get_item_mut(&mut self, name: &str) -> Option<&mut FolderItem> { + self.items.get_mut(name) + } + + /// Get a subfolder by name (only if navigable) + pub fn get_subfolder(&self, name: &str) -> Option<&Self> { + self.items.get(name).and_then(|item| item.get_folder()) + } + + /// Get a mutable subfolder by name (only if navigable) + pub fn get_subfolder_mut(&mut self, name: &str) -> Option<&mut Self> { + self.items + .get_mut(name) + .and_then(|item| item.get_folder_mut()) + } } // ============================================================================ @@ -509,8 +804,8 @@ impl Folder { /// A secret entry with timestamp and metadata #[derive(Debug, Clone, Encode, Decode)] pub struct SecretEntry { - /// The secret value (may be plain or encrypted folder) - pub value: SecretValue, + /// The encrypted secret value + pub value: EncryptedValue, /// When this version was created (seconds since UNIX epoch) pub timestamp: u64, @@ -523,17 +818,10 @@ pub struct SecretEntry { } impl SecretEntry { - /// Create a new secret entry with plain value - pub fn new_plain( - encrypted_value: EncryptedValue, - timestamp: u64, - encryption_domain: u32, - ) -> Self { + /// Create a new secret entry + pub fn new(encrypted_value: EncryptedValue, timestamp: u64) -> Self { Self { - value: SecretValue::Plain { - data: encrypted_value, - encryption_domain, - }, + value: encrypted_value, timestamp, secret_type: SecretType::Utf8String, metadata: HashMap::new(), @@ -542,66 +830,7 @@ impl SecretEntry { /// Get the encrypted value from this entry pub const fn encrypted_value(&self) -> &EncryptedValue { - match &self.value { - SecretValue::Plain { data, .. } => data, - // For encrypted folders, we return the placeholder - // (actual folder decryption will be handled separately) - SecretValue::EncryptedFolder { - placeholder_data, .. - } => placeholder_data, - } - } -} - -// ============================================================================ -// Secret Value - Either plain data or encrypted folder -// ============================================================================ - -/// The actual secret value -#[derive(Debug, Clone, Encode, Decode)] -pub enum SecretValue { - /// Regular secret encrypted with domain key - /// - Domain 0: encrypted with master key (like V4) - /// - Domain N: encrypted with custom domain key - Plain { - /// The encrypted data - data: EncryptedValue, - - /// Which encryption domain encrypts this data - encryption_domain: u32, - }, - - /// Locked folder disguised as a regular secret - /// When locked, CLI shows `placeholder_data` - /// When unlocked, `encrypted_folder` is decrypted and merged into parent - EncryptedFolder { - /// What to show when locked (appears as regular secret value) - placeholder_data: EncryptedValue, - - /// The actual folder serialized and encrypted with domain key - encrypted_folder: Vec, - - /// Which encryption domain encrypts this folder - encryption_domain: u32, - }, -} - -impl SecretValue { - /// Check if this is an encrypted folder - pub const fn is_encrypted_folder(&self) -> bool { - matches!(self, Self::EncryptedFolder { .. }) - } - - /// Get the encryption domain for this value - pub const fn encryption_domain(&self) -> u32 { - match self { - Self::Plain { - encryption_domain, .. - } - | Self::EncryptedFolder { - encryption_domain, .. - } => *encryption_domain, - } + &self.value } } @@ -675,12 +904,22 @@ pub fn deserialize_storage_v5_from_slice(data: &[u8]) -> Result { /// Sort all secret entries by timestamp (recursive) fn sort_folder_entries(folder: &mut Folder) { - for entries in folder.secrets.values_mut() { - entries.sort_by_key(|e| e.timestamp); - } - - for subfolder in folder.subfolders.values_mut() { - sort_folder_entries(subfolder); + for item in folder.items.values_mut() { + match item { + FolderItem::Secret { entries, .. } => { + entries.sort_by_key(|e| e.timestamp); + } + FolderItem::Folder { folder, .. } => { + sort_folder_entries(folder); + } + FolderItem::EncryptedFolder { + decrypted_folder, .. + } => { + if let Some(df) = decrypted_folder { + sort_folder_entries(df); + } + } + } } } @@ -700,7 +939,8 @@ pub fn migrate_v4_to_v5(v4: StorageV4) -> StorageV5 { for (key, entries) in v4.data { let secrets: Vec = entries.into_iter().map(value_entry_to_secret).collect(); - root.secrets.insert(key, secrets); + root.items + .insert(key.clone(), FolderItem::new_secret(key, secrets, 0)); } StorageV5 { root } @@ -708,15 +948,7 @@ pub fn migrate_v4_to_v5(v4: StorageV4) -> StorageV5 { /// Convert V4 `ValueEntry` to V5 `SecretEntry` fn value_entry_to_secret(entry: ValueEntry) -> SecretEntry { - SecretEntry { - value: SecretValue::Plain { - data: entry.value, - encryption_domain: 0, // Default domain - }, - timestamp: entry.timestamp, - secret_type: SecretType::Utf8String, - metadata: HashMap::new(), - } + SecretEntry::new(entry.value, entry.timestamp) } // ============================================================================ @@ -733,34 +965,39 @@ mod tests { let serialized = serialize_storage_v5_to_vec(&storage).unwrap(); let deserialized = deserialize_storage_v5_from_slice(&serialized).unwrap(); - assert!(deserialized.root.secrets.is_empty()); - assert!(deserialized.root.subfolders.is_empty()); + assert!(deserialized.root.items.is_empty()); } #[test] fn test_simple_secret_v5() { let mut storage = StorageV5::new(); let test_value = EncryptedValue::from_ciphertext(b"test_value".to_vec()); - storage.root.secrets.insert( + storage.root.items.insert( "test_key".to_string(), - vec![SecretEntry::new_plain(test_value, 12345, 0)], + FolderItem::new_secret( + "test_key".to_string(), + vec![SecretEntry::new(test_value, 12345)], + 0, + ), ); let serialized = serialize_storage_v5_to_vec(&storage).unwrap(); let deserialized = deserialize_storage_v5_from_slice(&serialized).unwrap(); - assert_eq!(deserialized.root.secrets.len(), 1); - let entry = &deserialized.root.secrets["test_key"][0]; + assert_eq!(deserialized.root.items.len(), 1); + let item = &deserialized.root.items["test_key"]; - match &entry.value { - SecretValue::Plain { - data, + match item { + FolderItem::Secret { + entries, encryption_domain, + .. } => { - assert_eq!(data.as_bytes(), b"test_value"); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].encrypted_value().as_bytes(), b"test_value"); assert_eq!(*encryption_domain, 0); } - _ => panic!("Expected Plain variant"), + _ => panic!("Expected Secret variant"), } } @@ -771,50 +1008,55 @@ mod tests { // Add a subfolder let mut subfolder = Folder::new("work".to_string(), 0); let api_key_value = EncryptedValue::from_ciphertext(b"secret123".to_vec()); - subfolder.secrets.insert( + subfolder.items.insert( "api_key".to_string(), - vec![SecretEntry::new_plain(api_key_value, 12345, 0)], + FolderItem::new_secret( + "api_key".to_string(), + vec![SecretEntry::new(api_key_value, 12345)], + 0, + ), ); - storage - .root - .subfolders - .insert("work".to_string(), subfolder); + storage.root.items.insert( + "work".to_string(), + FolderItem::new_folder("work".to_string(), subfolder), + ); let serialized = serialize_storage_v5_to_vec(&storage).unwrap(); let deserialized = deserialize_storage_v5_from_slice(&serialized).unwrap(); - assert_eq!(deserialized.root.subfolders.len(), 1); - assert!(deserialized.root.subfolders.contains_key("work")); - assert_eq!(deserialized.root.subfolders["work"].secrets.len(), 1); + assert_eq!(deserialized.root.items.len(), 1); + assert!(deserialized.root.items.contains_key("work")); + let work_item = &deserialized.root.items["work"]; + assert!(work_item.is_folder()); + if let Some(work_folder) = work_item.get_folder() { + assert_eq!(work_folder.items.len(), 1); + } else { + panic!("Expected folder"); + } } #[test] fn test_encrypted_folder_variant() { let mut storage = StorageV5::new(); - // Create an encrypted folder secret - let placeholder = EncryptedValue::from_ciphertext(b"placeholder".to_vec()); - storage.root.secrets.insert( + // Create an encrypted folder + storage.root.items.insert( "secret_folder".to_string(), - vec![SecretEntry { - value: SecretValue::EncryptedFolder { - placeholder_data: placeholder, - encrypted_folder: vec![1, 2, 3, 4], // Mock encrypted data - encryption_domain: 1, - }, - timestamp: 12345, - secret_type: SecretType::Utf8String, - metadata: HashMap::new(), - }], + FolderItem::new_encrypted_folder( + "secret_folder".to_string(), + vec![1, 2, 3, 4], // Mock encrypted data + 1, + ), ); let serialized = serialize_storage_v5_to_vec(&storage).unwrap(); let deserialized = deserialize_storage_v5_from_slice(&serialized).unwrap(); - let entry = &deserialized.root.secrets["secret_folder"][0]; - assert!(entry.value.is_encrypted_folder()); - assert_eq!(entry.value.encryption_domain(), 1); + let item = &deserialized.root.items["secret_folder"]; + assert!(item.is_encrypted_folder()); + assert!(item.is_locked()); + assert_eq!(item.encryption_domain(), 1); } #[test] @@ -825,9 +1067,9 @@ mod tests { let v5 = migrate_v4_to_v5(v4); - assert_eq!(v5.root.secrets.len(), 2); - assert!(v5.root.secrets.contains_key("key1")); - assert!(v5.root.secrets.contains_key("key2")); + assert_eq!(v5.root.items.len(), 2); + assert!(v5.root.items.contains_key("key1")); + assert!(v5.root.items.contains_key("key2")); assert_eq!(v5.root.encryption_domain, 0); } diff --git a/tests/storage_v5_tests.rs b/tests/storage_v5_tests.rs index d17e729..91355f3 100644 --- a/tests/storage_v5_tests.rs +++ b/tests/storage_v5_tests.rs @@ -13,7 +13,7 @@ fn temp_test_file() -> (TempDir, PathBuf) { #[test] fn test_storage_new() { let storage = StorageV5::new(); - assert_eq!(storage.root.secrets.len(), 0); + assert_eq!(storage.root.items.len(), 0); } #[test] @@ -104,7 +104,7 @@ fn test_storage_delete() { storage.put_at_path("/", "key2".to_string(), "value2".into(), 0); assert!(storage.delete("key1")); - assert_eq!(storage.root.secrets.len(), 1); + assert_eq!(storage.root.items.len(), 1); assert!(!storage.delete("key1")); // Already deleted assert!(!storage.delete("nonexistent")); @@ -141,7 +141,7 @@ fn test_serialize_deserialize_empty() { let serialized = serialize_storage_v5_to_vec(&storage).unwrap(); let deserialized = deserialize_storage_v5_from_slice(&serialized).unwrap(); - assert_eq!(deserialized.root.secrets.len(), 0); + assert_eq!(deserialized.root.items.len(), 0); } #[test] @@ -152,8 +152,8 @@ fn test_serialize_deserialize_single_entry() { let serialized = serialize_storage_v5_to_vec(&storage).unwrap(); let deserialized = deserialize_storage_v5_from_slice(&serialized).unwrap(); - assert_eq!(deserialized.root.secrets.len(), 1); - let entry = &deserialized.root.secrets["key1"][0]; + assert_eq!(deserialized.root.items.len(), 1); + let entry = &deserialized.root.items["key1"].get_entries().unwrap()[0]; assert_eq!(entry.encrypted_value().as_bytes(), "value1".as_bytes()); } @@ -167,9 +167,15 @@ fn test_serialize_deserialize_multiple_entries() { let serialized = serialize_storage_v5_to_vec(&storage).unwrap(); let deserialized = deserialize_storage_v5_from_slice(&serialized).unwrap(); - assert_eq!(deserialized.root.secrets.len(), 2); - assert_eq!(deserialized.root.secrets["key1"].len(), 2); - assert_eq!(deserialized.root.secrets["key2"].len(), 1); + assert_eq!(deserialized.root.items.len(), 2); + assert_eq!( + deserialized.root.items["key1"].get_entries().unwrap().len(), + 2 + ); + assert_eq!( + deserialized.root.items["key2"].get_entries().unwrap().len(), + 1 + ); } #[test] @@ -181,15 +187,15 @@ fn test_serialize_deserialize_unicode() { let serialized = serialize_storage_v5_to_vec(&storage).unwrap(); let deserialized = deserialize_storage_v5_from_slice(&serialized).unwrap(); - assert_eq!(deserialized.root.secrets.len(), 2); + assert_eq!(deserialized.root.items.len(), 2); assert_eq!( - deserialized.root.secrets["ключ"][0] + deserialized.root.items["ключ"].get_entries().unwrap()[0] .encrypted_value() .as_bytes(), "значение".as_bytes() ); assert_eq!( - deserialized.root.secrets["🔑"][0] + deserialized.root.items["🔑"].get_entries().unwrap()[0] .encrypted_value() .as_bytes(), "🎁".as_bytes() @@ -223,13 +229,17 @@ fn test_load_save_storage_v5() { assert!(path.exists()); let loaded = load_storage_v5(&cypher, &path).unwrap(); - assert_eq!(loaded.root.secrets.len(), 2); + assert_eq!(loaded.root.items.len(), 2); assert_eq!( - loaded.root.secrets["key1"][0].encrypted_value().as_bytes(), + loaded.root.items["key1"].get_entries().unwrap()[0] + .encrypted_value() + .as_bytes(), "value1".as_bytes() ); assert_eq!( - loaded.root.secrets["key2"][0].encrypted_value().as_bytes(), + loaded.root.items["key2"].get_entries().unwrap()[0] + .encrypted_value() + .as_bytes(), "value2".as_bytes() ); } @@ -241,7 +251,7 @@ fn test_load_nonexistent_file() { let cypher = Cypher::new(EncryptionKey::for_file("test_password", &path).unwrap()); let storage = load_storage_v5(&cypher, &path).unwrap(); - assert_eq!(storage.root.secrets.len(), 0); + assert_eq!(storage.root.items.len(), 0); } #[test] @@ -257,7 +267,7 @@ fn test_load_with_wrong_password() { // Should fail or return garbage let result = load_storage_v5(&cypher2, &path); - assert!(result.is_err() || result.unwrap().root.secrets.is_empty()); + assert!(result.is_err() || result.unwrap().root.items.is_empty()); } #[test] @@ -284,7 +294,7 @@ fn test_special_characters_in_keys() { storage.put_at_path("/", "key.with.dots".to_string(), "value3".into(), 0); storage.put_at_path("/", "key@with@at".to_string(), "value4".into(), 0); - assert_eq!(storage.root.secrets.len(), 4); + assert_eq!(storage.root.items.len(), 4); let result: Vec<_> = storage.get("key-with-dash").unwrap().collect(); assert_eq!(result[0].2.as_bytes(), "value1".as_bytes()); @@ -322,7 +332,7 @@ fn test_concurrent_operations() { } let final_storage = storage.lock().unwrap(); - assert_eq!(final_storage.root.secrets.len(), 10); + assert_eq!(final_storage.root.items.len(), 10); } #[test] @@ -340,7 +350,7 @@ fn test_storage_persistence_across_sessions() { // Session 2: Load and add { let mut storage = load_storage_v5(&cypher, &path).unwrap(); - assert_eq!(storage.root.secrets.len(), 1); + assert_eq!(storage.root.items.len(), 1); storage.put_at_path("/", "session2_key".to_string(), "session2_value".into(), 0); save_storage_v5(&cypher, &storage, &path).unwrap(); } @@ -348,9 +358,9 @@ fn test_storage_persistence_across_sessions() { // Session 3: Verify both keys exist { let storage = load_storage_v5(&cypher, &path).unwrap(); - assert_eq!(storage.root.secrets.len(), 2); - assert!(storage.root.secrets.contains_key("session1_key")); - assert!(storage.root.secrets.contains_key("session2_key")); + assert_eq!(storage.root.items.len(), 2); + assert!(storage.root.items.contains_key("session1_key")); + assert!(storage.root.items.contains_key("session2_key")); } } @@ -360,20 +370,20 @@ fn test_empty_key_value() { storage.put_at_path("/", "".to_string(), "value".into(), 0); storage.put_at_path("/", "key".to_string(), "".into(), 0); - assert_eq!(storage.root.secrets.len(), 2); + assert_eq!(storage.root.items.len(), 2); let serialized = serialize_storage_v5_to_vec(&storage).unwrap(); let deserialized = deserialize_storage_v5_from_slice(&serialized).unwrap(); - assert_eq!(deserialized.root.secrets.len(), 2); + assert_eq!(deserialized.root.items.len(), 2); assert_eq!( - deserialized.root.secrets[""][0] + deserialized.root.items[""].get_entries().unwrap()[0] .encrypted_value() .as_bytes(), "value".as_bytes() ); assert_eq!( - deserialized.root.secrets["key"][0] + deserialized.root.items["key"].get_entries().unwrap()[0] .encrypted_value() .as_bytes(), "".as_bytes() @@ -392,7 +402,7 @@ fn test_very_long_key_value() { let deserialized = deserialize_storage_v5_from_slice(&serialized).unwrap(); assert_eq!( - deserialized.root.secrets[&long_key][0] + deserialized.root.items[&long_key].get_entries().unwrap()[0] .encrypted_value() .as_bytes(), long_value.as_bytes() From ef5f37b49c428927bae0fee1565f81ab8fbcef40 Mon Sep 17 00:00:00 2001 From: Roman Studenikin Date: Tue, 30 Dec 2025 13:30:27 +0000 Subject: [PATCH 05/12] Move path formatting methods into path_utils.rs --- src/cli/update.rs | 14 +- src/cli/utils.rs | 113 +----------- src/lib.rs | 4 + src/path_utils.rs | 372 ++++++++++++++++++++++++++++++++++++++ src/storage/v5.rs | 32 +--- tests/storage_v5_tests.rs | 22 +-- 6 files changed, 400 insertions(+), 157 deletions(-) create mode 100644 src/path_utils.rs diff --git a/src/cli/update.rs b/src/cli/update.rs index 66ee6e2..0090b94 100644 --- a/src/cli/update.rs +++ b/src/cli/update.rs @@ -1,4 +1,4 @@ -use crate::cli::utils::{format_timestamp, secure_print}; +use crate::cli::utils::{format_full_path, format_timestamp, secure_print}; use crate::{Cypher, EncryptedValue, EncryptionKey, StorageV5, load_storage_v5, save_storage_v5}; use anyhow::Result; use std::io; @@ -76,11 +76,7 @@ fn find_updates_in_folder( // Recursively check subfolders for (subfolder_name, _) in update_folder.navigable_folders() { - let subfolder_path = if folder_path == "/" { - format!("/{subfolder_name}") - } else { - format!("{folder_path}/{subfolder_name}") - }; + let subfolder_path = format_full_path(folder_path, subfolder_name, true); find_updates_in_folder( &subfolder_path, main_storage, @@ -213,11 +209,7 @@ fn ensure_folder_path(storage: &mut StorageV5, path: &str) -> Result<()> { for part in parts { // Check if folder exists - let folder_path = if current_path == "/" { - format!("/{part}") - } else { - format!("{current_path}/{part}") - }; + let folder_path = format_full_path(¤t_path, part, true); if storage.get_folder(&folder_path).is_none() { // Create the folder diff --git a/src/cli/utils.rs b/src/cli/utils.rs index 87cf738..30c5499 100644 --- a/src/cli/utils.rs +++ b/src/cli/utils.rs @@ -10,6 +10,11 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use zeroize::{Zeroize, Zeroizing}; +// Re-export path utilities for backward compatibility +pub use crate::path_utils::{ + format_full_path, normalize_path, parse_key_path, relative_path_from, resolve_path, +}; + pub fn format_timestamp(ts: u64) -> String { if ts == 0 { return "N/A".to_string(); @@ -20,114 +25,6 @@ pub fn format_timestamp(ts: u64) -> String { dt.format("%Y-%m-%d %H:%M:%S").to_string() } -/// Format a full path from folder path and key -/// Handles both absolute paths ("/", "/work") and relative paths ("", "work/", "../work") -/// If `is_folder` is true, appends a trailing "/" to the result -pub fn format_full_path(folder_path: &str, key: &str, is_folder: bool) -> String { - let path = if folder_path.is_empty() { - key.to_string() - } else if folder_path == "/" { - format!("/{key}") - } else { - format!("{}/{key}", folder_path.trim_end_matches('/')) - }; - - if is_folder { format!("{path}/") } else { path } -} - -/// Compute relative path from root to path -/// Examples: -/// - `relative_path_from`("/", "/work") = "work" -/// - `relative_path_from`("/", "/work/api") = "work/api" -/// - `relative_path_from("/work`", "/work/api") = "api" -/// - `relative_path_from`("/", "/") = "" -pub fn relative_path_from(root: &str, path: &str) -> String { - if path == root { - String::new() - } else if root == "/" { - path.strip_prefix('/').unwrap_or(path).to_string() - } else { - path.strip_prefix(root) - .and_then(|p| p.strip_prefix('/')) - .unwrap_or("") - .to_string() - } -} - -/// Resolve a path (absolute or relative) from a given current directory -pub fn resolve_path(current_path: &str, path: &str) -> String { - if path.is_empty() { - return current_path.to_string(); - } - - if path.starts_with('/') { - // Absolute path - return normalize_path(path); - } - - // Relative path - resolve from current directory - let mut components: Vec<&str> = if current_path == "/" { - Vec::new() - } else { - current_path.trim_matches('/').split('/').collect() - }; - - // Process each component of the path - for component in path.trim_end_matches('/').split('/') { - match component { - "" | "." => {} - ".." => { - components.pop(); - } - name => { - components.push(name); - } - } - } - - if components.is_empty() { - String::from("/") - } else { - format!("/{}", components.join("/")) - } -} - -/// Normalize an absolute path by resolving . and .. components -pub fn normalize_path(path: &str) -> String { - let mut components: Vec<&str> = Vec::new(); - - for component in path.split('/') { - match component { - "" | "." => {} - ".." => { - components.pop(); - } - name => { - components.push(name); - } - } - } - - if components.is_empty() { - String::from("/") - } else { - format!("/{}", components.join("/")) - } -} - -/// Parse a key argument that may include a path (e.g., "`work/api_key`") -/// Returns (`resolved_folder_path`, `key_name`) -pub fn parse_key_path<'a>(current_path: &str, key_arg: &'a str) -> (String, &'a str) { - if let Some(last_slash) = key_arg.rfind('/') { - let dir_part = &key_arg[..last_slash]; - let key_name = &key_arg[last_slash + 1..]; - let resolved_path = resolve_path(current_path, dir_part); - (resolved_path, key_name) - } else { - (current_path.to_string(), key_arg) - } -} - /// Prints directly to tty to avoid /// - snooping passwords from process stdout /// - lingering passwords in memory diff --git a/src/lib.rs b/src/lib.rs index da510ac..f9892e5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -22,6 +22,7 @@ pub mod cli; mod constants; mod crypto; +pub mod path_utils; mod security; mod storage; mod version; @@ -29,6 +30,9 @@ mod version; // Public re-exports (maintaining exact same API) pub use cli::utils::{Spinner, ThreadStopGuard, copy_to_clipboard, format_timestamp, secure_print}; pub use crypto::{Argon2Params, Cypher, EncryptionKey}; +pub use path_utils::{ + format_full_path, normalize_path, parse_key_path, relative_path_from, resolve_path, +}; pub use security::{disable_core_dumps, enable_ptrace_protection, is_debugger_attached}; pub use storage::{ EncryptedValue, SecretEntry, StorageV4, StorageV5, deserialize_storage_v4, diff --git a/src/path_utils.rs b/src/path_utils.rs new file mode 100644 index 0000000..8fc9ca6 --- /dev/null +++ b/src/path_utils.rs @@ -0,0 +1,372 @@ +//! Path manipulation utilities for hierarchical folder navigation +//! +//! This module provides functions for working with Unix-style paths in the storage system. +//! All paths use forward slashes and follow these conventions: +//! - Root is always "/" +//! - No trailing slashes except for display purposes (`format_full_path` with `is_folder=true`) +//! - ".." navigates up one level +//! - "." refers to current directory + +/// Format a full path by joining folder path and item name +/// +/// # Arguments +/// * `folder_path` - The folder path (e.g., "/", "/work", "work") +/// * `key` - The item name (e.g., "secret", "`api_key`") +/// * `is_folder` - Whether to add a trailing slash for display +/// +/// # Examples +/// ``` +/// use rcypher::format_full_path; +/// assert_eq!(format_full_path("/", "key", false), "/key"); +/// assert_eq!(format_full_path("/work", "key", false), "/work/key"); +/// assert_eq!(format_full_path("/work", "folder", true), "/work/folder/"); +/// assert_eq!(format_full_path("", "key", false), "key"); +/// ``` +pub fn format_full_path(folder_path: &str, key: &str, is_folder: bool) -> String { + let path = if folder_path.is_empty() { + key.to_string() + } else if folder_path == "/" { + format!("/{key}") + } else { + format!("{}/{key}", folder_path.trim_end_matches('/')) + }; + + if is_folder { format!("{path}/") } else { path } +} + +/// Compute relative path from root to path +/// +/// Returns the path relative to root, with no leading or trailing slashes. +/// If path equals root, returns empty string. +/// +/// # Arguments +/// * `root` - The root path to compute relative to +/// * `path` - The target path +/// +/// # Examples +/// ``` +/// use rcypher::relative_path_from; +/// assert_eq!(relative_path_from("/", "/work"), "work"); +/// assert_eq!(relative_path_from("/", "/work/api"), "work/api"); +/// assert_eq!(relative_path_from("/work", "/work/api"), "api"); +/// assert_eq!(relative_path_from("/", "/"), ""); +/// // Handles trailing slashes +/// assert_eq!(relative_path_from("/", "/work/"), "work"); +/// assert_eq!(relative_path_from("/work/", "/work/api"), "api"); +/// ``` +pub fn relative_path_from(root: &str, path: &str) -> String { + // Normalize both paths by trimming slashes + let root_normalized = root.trim_matches('/'); + let path_normalized = path.trim_matches('/'); + + if root_normalized.is_empty() { + // Root is "/" + path_normalized.to_string() + } else if path_normalized == root_normalized { + // Same path + String::new() + } else if let Some(relative) = path_normalized.strip_prefix(root_normalized) { + // path is under root + relative.trim_start_matches('/').to_string() + } else { + // path is not under root - return as is + path_normalized.to_string() + } +} + +/// Normalize a path by resolving . and .. components +/// +/// # Arguments +/// * `path` - The path to normalize +/// +/// # Examples +/// ``` +/// use rcypher::normalize_path; +/// assert_eq!(normalize_path("/work/../personal"), "/personal"); +/// assert_eq!(normalize_path("/work/./secret"), "/work/secret"); +/// assert_eq!(normalize_path("/work//secret"), "/work/secret"); +/// assert_eq!(normalize_path("/../.."), "/"); +/// ``` +pub fn normalize_path(path: &str) -> String { + let mut components: Vec<&str> = Vec::new(); + + for component in path.split('/') { + match component { + "" | "." => {} + ".." => { + components.pop(); + } + name => { + components.push(name); + } + } + } + + if components.is_empty() { + String::from("/") + } else { + format!("/{}", components.join("/")) + } +} + +/// Resolve a path (absolute or relative) from a given current directory +/// +/// Handles both absolute paths (starting with /) and relative paths (using . and ..). +/// +/// # Arguments +/// * `current_path` - The current working directory +/// * `path` - The path to resolve (absolute or relative) +/// +/// # Examples +/// ``` +/// use rcypher::resolve_path; +/// assert_eq!(resolve_path("/work", "../personal"), "/personal"); +/// assert_eq!(resolve_path("/work", "secret"), "/work/secret"); +/// assert_eq!(resolve_path("/work", "/absolute"), "/absolute"); +/// assert_eq!(resolve_path("/", "work"), "/work"); +/// assert_eq!(resolve_path("/work/api", ".."), "/work"); +/// ``` +pub fn resolve_path(current_path: &str, path: &str) -> String { + if path.is_empty() { + return current_path.to_string(); + } + + if path.starts_with('/') { + // Absolute path + return normalize_path(path); + } + + // Relative path - resolve from current directory + let mut components: Vec<&str> = if current_path == "/" { + Vec::new() + } else { + current_path.trim_matches('/').split('/').collect() + }; + + // Process each component of the path + for component in path.trim_end_matches('/').split('/') { + match component { + "" | "." => {} + ".." => { + components.pop(); + } + name => { + components.push(name); + } + } + } + + if components.is_empty() { + String::from("/") + } else { + format!("/{}", components.join("/")) + } +} + +/// Parse a key path into folder path and key name +/// +/// Splits a path like "work/secret" into ("/work", "secret") relative to `current_path`. +/// If no slash is present, returns (`current_path`, `key_arg`). +/// +/// # Arguments +/// * `current_path` - The current working directory +/// * `key_arg` - The key path argument (may contain slashes) +/// +/// # Examples +/// ``` +/// use rcypher::parse_key_path; +/// assert_eq!(parse_key_path("/", "work/secret"), ("/work".to_string(), "secret")); +/// assert_eq!(parse_key_path("/", "secret"), ("/".to_string(), "secret")); +/// assert_eq!(parse_key_path("/work", "../personal/key"), ("/personal".to_string(), "key")); +/// assert_eq!(parse_key_path("/work", "key"), ("/work".to_string(), "key")); +/// ``` +pub fn parse_key_path<'a>(current_path: &str, key_arg: &'a str) -> (String, &'a str) { + if let Some(last_slash) = key_arg.rfind('/') { + let dir_part = &key_arg[..last_slash]; + let key_name = &key_arg[last_slash + 1..]; + let resolved_path = resolve_path(current_path, dir_part); + (resolved_path, key_name) + } else { + (current_path.to_string(), key_arg) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_format_full_path_root() { + assert_eq!(format_full_path("/", "key", false), "/key"); + assert_eq!(format_full_path("/", "folder", true), "/folder/"); + } + + #[test] + fn test_format_full_path_subfolder() { + assert_eq!(format_full_path("/work", "secret", false), "/work/secret"); + assert_eq!( + format_full_path("/work", "subfolder", true), + "/work/subfolder/" + ); + } + + #[test] + fn test_format_full_path_nested() { + assert_eq!(format_full_path("/work/api", "key", false), "/work/api/key"); + assert_eq!( + format_full_path("/work/api", "folder", true), + "/work/api/folder/" + ); + } + + #[test] + fn test_format_full_path_empty_folder() { + assert_eq!(format_full_path("", "key", false), "key"); + assert_eq!(format_full_path("", "folder", true), "folder/"); + } + + #[test] + fn test_format_full_path_trailing_slash() { + // Should handle trailing slashes in folder_path + assert_eq!(format_full_path("/work/", "key", false), "/work/key"); + } + + #[test] + fn test_relative_path_from_root() { + assert_eq!(relative_path_from("/", "/work"), "work"); + assert_eq!(relative_path_from("/", "/work/api"), "work/api"); + assert_eq!(relative_path_from("/", "/"), ""); + } + + #[test] + fn test_relative_path_from_subfolder() { + assert_eq!(relative_path_from("/work", "/work/api"), "api"); + assert_eq!(relative_path_from("/work", "/work/api/key"), "api/key"); + assert_eq!(relative_path_from("/work", "/work"), ""); + } + + #[test] + fn test_relative_path_from_trailing_slashes() { + // Should handle trailing slashes in both arguments + assert_eq!(relative_path_from("/", "/work/"), "work"); + assert_eq!(relative_path_from("/work/", "/work/api"), "api"); + assert_eq!(relative_path_from("/work/", "/work/api/"), "api"); + } + + #[test] + fn test_relative_path_from_not_under_root() { + // When path is not under root, return path as-is (normalized) + assert_eq!(relative_path_from("/work", "/personal"), "personal"); + } + + #[test] + fn test_normalize_path_simple() { + assert_eq!(normalize_path("/work/secret"), "/work/secret"); + assert_eq!(normalize_path("/"), "/"); + } + + #[test] + fn test_normalize_path_parent_dir() { + assert_eq!(normalize_path("/work/../personal"), "/personal"); + assert_eq!(normalize_path("/work/api/../../personal"), "/personal"); + assert_eq!(normalize_path("/../.."), "/"); + } + + #[test] + fn test_normalize_path_current_dir() { + assert_eq!(normalize_path("/work/./secret"), "/work/secret"); + assert_eq!(normalize_path("/./work"), "/work"); + } + + #[test] + fn test_normalize_path_double_slashes() { + assert_eq!(normalize_path("/work//secret"), "/work/secret"); + assert_eq!(normalize_path("//work///secret//"), "/work/secret"); + } + + #[test] + fn test_resolve_path_absolute() { + assert_eq!(resolve_path("/work", "/absolute"), "/absolute"); + assert_eq!(resolve_path("/", "/work/secret"), "/work/secret"); + } + + #[test] + fn test_resolve_path_relative() { + assert_eq!(resolve_path("/work", "secret"), "/work/secret"); + assert_eq!(resolve_path("/", "work"), "/work"); + assert_eq!(resolve_path("/work", "api/key"), "/work/api/key"); + } + + #[test] + fn test_resolve_path_parent_dir() { + assert_eq!(resolve_path("/work", ".."), "/"); + assert_eq!(resolve_path("/work/api", ".."), "/work"); + assert_eq!(resolve_path("/work", "../personal"), "/personal"); + assert_eq!(resolve_path("/work/api", "../../personal"), "/personal"); + } + + #[test] + fn test_resolve_path_current_dir() { + assert_eq!(resolve_path("/work", "."), "/work"); + assert_eq!(resolve_path("/work", "./secret"), "/work/secret"); + } + + #[test] + fn test_resolve_path_empty() { + assert_eq!(resolve_path("/work", ""), "/work"); + assert_eq!(resolve_path("/", ""), "/"); + } + + #[test] + fn test_resolve_path_from_root() { + assert_eq!(resolve_path("/", "work"), "/work"); + assert_eq!(resolve_path("/", "work/api"), "/work/api"); + assert_eq!(resolve_path("/", ".."), "/"); + } + + #[test] + fn test_parse_key_path_simple() { + assert_eq!(parse_key_path("/", "secret"), ("/".to_string(), "secret")); + assert_eq!(parse_key_path("/work", "key"), ("/work".to_string(), "key")); + } + + #[test] + fn test_parse_key_path_with_folder() { + assert_eq!( + parse_key_path("/", "work/secret"), + ("/work".to_string(), "secret") + ); + assert_eq!( + parse_key_path("/", "work/api/key"), + ("/work/api".to_string(), "key") + ); + } + + #[test] + fn test_parse_key_path_absolute() { + assert_eq!( + parse_key_path("/work", "/personal/key"), + ("/personal".to_string(), "key") + ); + } + + #[test] + fn test_parse_key_path_parent_dir() { + assert_eq!( + parse_key_path("/work", "../personal/key"), + ("/personal".to_string(), "key") + ); + assert_eq!( + parse_key_path("/work/api", "../../key"), + ("/".to_string(), "key") + ); + } + + #[test] + fn test_parse_key_path_current_dir() { + assert_eq!( + parse_key_path("/work", "./key"), + ("/work".to_string(), "key") + ); + } +} diff --git a/src/storage/v5.rs b/src/storage/v5.rs index 731baff..abdf5dd 100644 --- a/src/storage/v5.rs +++ b/src/storage/v5.rs @@ -6,7 +6,7 @@ use bincode::{Decode, Encode, config}; use regex::Regex; use super::value::EncryptedValue; -use crate::cli::utils::{format_full_path, relative_path_from}; +use crate::path_utils::{format_full_path, relative_path_from}; use crate::version::StoreVersion; // ============================================================================ @@ -225,30 +225,6 @@ impl StorageV5 { Ok(()) } - /// Legacy method - redirects to `move_item` for backwards compatibility - #[deprecated(note = "Use move_item instead")] - pub fn move_key( - &mut self, - source_folder: &str, - key: &str, - dest_folder: &str, - dest_key: Option<&str>, - ) -> Result<()> { - self.move_item(source_folder, key, dest_folder, dest_key) - } - - /// Legacy method - redirects to `move_item` for backwards compatibility - #[deprecated(note = "Use move_item instead")] - pub fn move_folder( - &mut self, - parent_path: &str, - folder_name: &str, - dest_parent: &str, - dest_name: Option<&str>, - ) -> Result<()> { - self.move_item(parent_path, folder_name, dest_parent, dest_name) - } - /// Returns an iterator over all keys matching the given regex pattern in root folder. /// /// # Sorting @@ -343,7 +319,8 @@ impl<'a> Iterator for RecursiveSecretIterator<'a> { if self.recursive && let Some(subfolder) = item.get_folder() { - let new_path = format_full_path(&path, key, true); + // Use is_folder=false to avoid trailing slash in internal path tracking + let new_path = format_full_path(&path, key, false); self.stack.push((new_path, subfolder.items.iter())); descended = true; // Break to process the new folder @@ -426,7 +403,8 @@ impl<'a> Iterator for RecursiveKeyIterator<'a> { if self.recursive && let Some(subfolder) = item.get_folder() { - let new_path = format_full_path(&path, key, true); + // Use is_folder=false to avoid trailing slash in internal path tracking + let new_path = format_full_path(&path, key, false); self.stack.push((new_path, subfolder.items.iter())); descended = true; // Break to process the new folder diff --git a/tests/storage_v5_tests.rs b/tests/storage_v5_tests.rs index 91355f3..6697748 100644 --- a/tests/storage_v5_tests.rs +++ b/tests/storage_v5_tests.rs @@ -801,7 +801,7 @@ fn test_move_key_same_folder() { // Move with rename in same folder storage - .move_key("/", "old_name", "/", Some("new_name")) + .move_item("/", "old_name", "/", Some("new_name")) .unwrap(); // Old key should be gone @@ -828,7 +828,7 @@ fn test_move_key_between_folders() { storage.put_at_path("/source", "key1".to_string(), "value1".into(), 0); // Move to different folder keeping same name - storage.move_key("/source", "key1", "/dest", None).unwrap(); + storage.move_item("/source", "key1", "/dest", None).unwrap(); // Should be gone from source let results: Vec<_> = storage @@ -855,7 +855,7 @@ fn test_move_key_with_rename_between_folders() { // Move and rename storage - .move_key("/source", "old_key", "/dest", Some("new_key")) + .move_item("/source", "old_key", "/dest", Some("new_key")) .unwrap(); // Should be gone from source @@ -892,7 +892,7 @@ fn test_move_key_preserves_history() { storage.put_at_path("/source", "key1".to_string(), "v3".into(), timestamp.add(2)); // Move the key - storage.move_key("/source", "key1", "/dest", None).unwrap(); + storage.move_item("/source", "key1", "/dest", None).unwrap(); // Check history is preserved let history: Vec<_> = storage.history_at_path("/dest", "key1").unwrap().collect(); @@ -911,7 +911,7 @@ fn test_move_key_collision_error() { storage.put_at_path("/dest", "key1".to_string(), "existing".into(), 0); // Should fail due to collision - let result = storage.move_key("/source", "key1", "/dest", None); + let result = storage.move_item("/source", "key1", "/dest", None); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("already exists")); @@ -928,7 +928,7 @@ fn test_move_key_nonexistent_source() { let mut storage = StorageV5::new(); storage.mkdir("/", "dest").unwrap(); - let result = storage.move_key("/", "nonexistent", "/dest", None); + let result = storage.move_item("/", "nonexistent", "/dest", None); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("not found")); } @@ -938,7 +938,7 @@ fn test_move_key_nonexistent_dest_folder() { let mut storage = StorageV5::new(); storage.put_at_path("/", "key1".to_string(), "value".into(), 0); - let result = storage.move_key("/", "key1", "/nonexistent", None); + let result = storage.move_item("/", "key1", "/nonexistent", None); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("not found")); } @@ -953,7 +953,7 @@ fn test_move_folder_between_parents() { // Move folder1 from /source to /dest storage - .move_folder("/source", "folder1", "/dest", None) + .move_item("/source", "folder1", "/dest", None) .unwrap(); // Should be gone from source @@ -977,7 +977,7 @@ fn test_move_folder_with_rename() { // Rename folder storage - .move_folder("/", "old_name", "/", Some("new_name")) + .move_item("/", "old_name", "/", Some("new_name")) .unwrap(); // Old should be gone @@ -1008,7 +1008,7 @@ fn test_move_folder_preserves_nested_structure() { // Move entire folder tree storage - .move_folder("/source", "folder1", "/dest", None) + .move_item("/source", "folder1", "/dest", None) .unwrap(); // Verify nested structure preserved @@ -1031,7 +1031,7 @@ fn test_move_folder_collision_error() { storage.mkdir("/dest", "folder1").unwrap(); // Collision // Should fail - let result = storage.move_folder("/source", "folder1", "/dest", None); + let result = storage.move_item("/source", "folder1", "/dest", None); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("already exists")); From 5ff960bd1c6b2d638f9c6311fd462dde60693167 Mon Sep 17 00:00:00 2001 From: Roman Studenikin Date: Tue, 30 Dec 2025 14:47:24 +0000 Subject: [PATCH 06/12] Use insecure Argon2id params in all the tests to speed up execution --- src/cli/update.rs | 10 +++++--- tests/encryption_tests.rs | 51 ++++++++++++++++++++++++++++++++------- tests/storage_v4_tests.rs | 25 +++++++++++++++---- tests/storage_v5_tests.rs | 25 +++++++++++++++---- 4 files changed, 89 insertions(+), 22 deletions(-) diff --git a/src/cli/update.rs b/src/cli/update.rs index 0090b94..1aec7ce 100644 --- a/src/cli/update.rs +++ b/src/cli/update.rs @@ -386,11 +386,15 @@ pub fn run_update_with( #[cfg(test)] mod tests { use super::*; - use crate::{CypherVersion, StorageV5}; + use crate::{Argon2Params, CypherVersion, StorageV5}; fn create_test_cypher() -> Cypher { - let key = EncryptionKey::from_password(CypherVersion::default(), "test_password") - .expect("Failed to create key"); + let key = EncryptionKey::from_password_with_params( + CypherVersion::default(), + "test_password", + &Argon2Params::insecure(), + ) + .expect("Failed to create key"); Cypher::new(key) } diff --git a/tests/encryption_tests.rs b/tests/encryption_tests.rs index d1cdcc6..2daa1b6 100644 --- a/tests/encryption_tests.rs +++ b/tests/encryption_tests.rs @@ -16,7 +16,12 @@ fn temp_test_file() -> (TempDir, PathBuf) { #[test] fn test_encrypt_decrypt_basic() { let cypher = Cypher::new( - EncryptionKey::from_password(CypherVersion::V7WithKdf, "test_password").unwrap(), + EncryptionKey::from_password_with_params( + CypherVersion::V7WithKdf, + "test_password", + &Argon2Params::insecure(), + ) + .unwrap(), ); let data = b"Hello, World!"; @@ -31,7 +36,12 @@ fn test_encrypt_decrypt_basic() { #[test] fn test_encrypt_decrypt_empty() { let cypher = Cypher::new( - EncryptionKey::from_password(CypherVersion::V7WithKdf, "test_password").unwrap(), + EncryptionKey::from_password_with_params( + CypherVersion::V7WithKdf, + "test_password", + &Argon2Params::insecure(), + ) + .unwrap(), ); let data = b""; @@ -43,7 +53,12 @@ fn test_encrypt_decrypt_empty() { #[test] fn test_encrypt_decrypt_large_data() { let cypher = Cypher::new( - EncryptionKey::from_password(CypherVersion::V7WithKdf, "test_password").unwrap(), + EncryptionKey::from_password_with_params( + CypherVersion::V7WithKdf, + "test_password", + &Argon2Params::insecure(), + ) + .unwrap(), ); let data = vec![42u8; 10000]; // 10KB of data @@ -54,10 +69,22 @@ fn test_encrypt_decrypt_large_data() { #[test] fn test_decrypt_wrong_password() { - let cypher1 = - Cypher::new(EncryptionKey::from_password(CypherVersion::V7WithKdf, "password1").unwrap()); - let cypher2 = - Cypher::new(EncryptionKey::from_password(CypherVersion::V7WithKdf, "password2").unwrap()); + let cypher1 = Cypher::new( + EncryptionKey::from_password_with_params( + CypherVersion::V7WithKdf, + "password1", + &Argon2Params::insecure(), + ) + .unwrap(), + ); + let cypher2 = Cypher::new( + EncryptionKey::from_password_with_params( + CypherVersion::V7WithKdf, + "password2", + &Argon2Params::insecure(), + ) + .unwrap(), + ); let data = b"Secret data"; let encrypted = cypher1.encrypt(data).unwrap(); @@ -72,7 +99,12 @@ fn test_decrypt_wrong_password() { #[test] fn test_decrypt_corrupted_data() { let cypher = Cypher::new( - EncryptionKey::from_password(CypherVersion::V7WithKdf, "test_password").unwrap(), + EncryptionKey::from_password_with_params( + CypherVersion::V7WithKdf, + "test_password", + &Argon2Params::insecure(), + ) + .unwrap(), ); // Too short @@ -91,9 +123,10 @@ fn encrypt_decrypt( output_path: &Path, in_between: impl FnOnce() -> (), ) -> Result> { - let cypher = Cypher::new(EncryptionKey::from_password( + let cypher = Cypher::new(EncryptionKey::from_password_with_params( CypherVersion::V7WithKdf, "test_password", + &Argon2Params::insecure(), )?); // Encrypt diff --git a/tests/storage_v4_tests.rs b/tests/storage_v4_tests.rs index 4a3f55b..8ed08a7 100644 --- a/tests/storage_v4_tests.rs +++ b/tests/storage_v4_tests.rs @@ -199,7 +199,10 @@ fn test_deserialize_corrupted_data() { fn test_load_save_storage() { let (_dir, path) = temp_test_file(); - let cypher = Cypher::new(EncryptionKey::for_file("test_password", &path).unwrap()); + let cypher = Cypher::new( + EncryptionKey::for_file_with_params("test_password", &path, &Argon2Params::insecure()) + .unwrap(), + ); let mut storage = StorageV4::new(); storage.put("key1".to_string(), "value1".into()); @@ -218,7 +221,10 @@ fn test_load_save_storage() { fn test_load_nonexistent_file() { let (_dir, path) = temp_test_file(); - let cypher = Cypher::new(EncryptionKey::for_file("test_password", &path).unwrap()); + let cypher = Cypher::new( + EncryptionKey::for_file_with_params("test_password", &path, &Argon2Params::insecure()) + .unwrap(), + ); let storage = load_storage_v4(&cypher, &path).unwrap(); assert_eq!(storage.data.len(), 0); @@ -227,8 +233,14 @@ fn test_load_nonexistent_file() { #[test] fn test_load_with_wrong_password() { let (_dir, path) = temp_test_file(); - let cypher1 = Cypher::new(EncryptionKey::for_file("test_password", &path).unwrap()); - let cypher2 = Cypher::new(EncryptionKey::for_file("test_password2", &path).unwrap()); + let cypher1 = Cypher::new( + EncryptionKey::for_file_with_params("test_password", &path, &Argon2Params::insecure()) + .unwrap(), + ); + let cypher2 = Cypher::new( + EncryptionKey::for_file_with_params("test_password2", &path, &Argon2Params::insecure()) + .unwrap(), + ); let mut storage = StorageV4::new(); storage.put("key1".to_string(), "value1".into()); @@ -308,7 +320,10 @@ fn test_concurrent_operations() { #[test] fn test_storage_persistence_across_sessions() { let (_dir, path) = temp_test_file(); - let cypher = Cypher::new(EncryptionKey::for_file("test_password", &path).unwrap()); + let cypher = Cypher::new( + EncryptionKey::for_file_with_params("test_password", &path, &Argon2Params::insecure()) + .unwrap(), + ); // Session 1: Create and save { diff --git a/tests/storage_v5_tests.rs b/tests/storage_v5_tests.rs index 6697748..073704c 100644 --- a/tests/storage_v5_tests.rs +++ b/tests/storage_v5_tests.rs @@ -219,7 +219,10 @@ fn test_deserialize_corrupted_data() { fn test_load_save_storage_v5() { let (_dir, path) = temp_test_file(); - let cypher = Cypher::new(EncryptionKey::for_file("test_password", &path).unwrap()); + let cypher = Cypher::new( + EncryptionKey::for_file_with_params("test_password", &path, &Argon2Params::insecure()) + .unwrap(), + ); let mut storage = StorageV5::new(); storage.put_at_path("/", "key1".to_string(), "value1".into(), 0); @@ -248,7 +251,10 @@ fn test_load_save_storage_v5() { fn test_load_nonexistent_file() { let (_dir, path) = temp_test_file(); - let cypher = Cypher::new(EncryptionKey::for_file("test_password", &path).unwrap()); + let cypher = Cypher::new( + EncryptionKey::for_file_with_params("test_password", &path, &Argon2Params::insecure()) + .unwrap(), + ); let storage = load_storage_v5(&cypher, &path).unwrap(); assert_eq!(storage.root.items.len(), 0); @@ -257,8 +263,14 @@ fn test_load_nonexistent_file() { #[test] fn test_load_with_wrong_password() { let (_dir, path) = temp_test_file(); - let cypher1 = Cypher::new(EncryptionKey::for_file("test_password", &path).unwrap()); - let cypher2 = Cypher::new(EncryptionKey::for_file("test_password2", &path).unwrap()); + let cypher1 = Cypher::new( + EncryptionKey::for_file_with_params("test_password", &path, &Argon2Params::insecure()) + .unwrap(), + ); + let cypher2 = Cypher::new( + EncryptionKey::for_file_with_params("test_password2", &path, &Argon2Params::insecure()) + .unwrap(), + ); let mut storage = StorageV5::new(); storage.put_at_path("/", "key1".to_string(), "value1".into(), 0); @@ -338,7 +350,10 @@ fn test_concurrent_operations() { #[test] fn test_storage_persistence_across_sessions() { let (_dir, path) = temp_test_file(); - let cypher = Cypher::new(EncryptionKey::for_file("test_password", &path).unwrap()); + let cypher = Cypher::new( + EncryptionKey::for_file_with_params("test_password", &path, &Argon2Params::insecure()) + .unwrap(), + ); // Session 1: Create and save { From f558d14d5c2644ae81ac005a00f52df3282252fb Mon Sep 17 00:00:00 2001 From: Roman Studenikin Date: Tue, 30 Dec 2025 20:09:21 +0000 Subject: [PATCH 07/12] Add EncryptionDomainManager This is to be used for storing different Cyphers for different encryption domains --- src/crypto/domain_keys.rs | 276 ++++++++++++++++++++++++++++++++++++++ src/crypto/mod.rs | 4 + src/lib.rs | 5 +- 3 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 src/crypto/domain_keys.rs diff --git a/src/crypto/domain_keys.rs b/src/crypto/domain_keys.rs new file mode 100644 index 0000000..73f9bc8 --- /dev/null +++ b/src/crypto/domain_keys.rs @@ -0,0 +1,276 @@ +use std::collections::HashMap; + +use anyhow::{Result, bail}; + +use crate::{Argon2Params, Cypher, CypherVersion, EncryptionKey}; + +/// Master domain ID (uses the storage file password) +pub const MASTER_DOMAIN_ID: u32 = 0; + +/// Master domain name +pub const MASTER_DOMAIN_NAME: &str = "master"; + +/// Encryption domain with a name and cypher +pub struct EncryptionDomain { + pub name: String, + pub cypher: Cypher, +} + +impl EncryptionDomain { + pub const fn new(name: String, cypher: Cypher) -> Self { + Self { name, cypher } + } +} + +/// Manages encryption domains and their keys during a session. +/// +/// Domain metadata (id -> name mapping) is stored in `StorageV5` and persisted to disk. +/// Domain keys (cyphers) are derived from passwords and stored only in memory during +/// the session. Users must re-enter passwords each session for maximum security. +pub struct EncryptionDomainManager { + domains: HashMap, +} + +impl EncryptionDomainManager { + /// Creates a new encryption domain manager with the master domain (domain 0) + /// + /// # Arguments + /// * `master_cypher` - The cypher for domain 0 (derived from storage password) + pub fn new(master_cypher: Cypher) -> Self { + let mut domains = HashMap::new(); + domains.insert( + MASTER_DOMAIN_ID, + EncryptionDomain::new(MASTER_DOMAIN_NAME.to_string(), master_cypher), + ); + Self { domains } + } + + /// Unlocks an encryption domain by deriving a key from the provided password + /// + /// # Arguments + /// * `domain_id` - The domain to unlock + /// * `name` - Name of the domain (from `StorageV5` metadata) + /// * `password` - Password to derive the domain key from + /// * `argon2_params` - Argon2 parameters for key derivation + /// + /// # Errors + /// * If the domain is already unlocked + /// * If key derivation fails + pub fn unlock_domain( + &mut self, + domain_id: u32, + name: String, + password: &str, + argon2_params: &Argon2Params, + ) -> Result<()> { + if self.domains.contains_key(&domain_id) { + bail!("Domain {domain_id} is already unlocked"); + } + + let key = EncryptionKey::from_password_with_params( + CypherVersion::default(), + password, + argon2_params, + )?; + + let cypher = Cypher::new(key); + self.domains + .insert(domain_id, EncryptionDomain::new(name, cypher)); + + Ok(()) + } + + /// Locks an encryption domain by removing its key from memory + /// + /// # Arguments + /// * `domain_id` - The domain to lock + /// + /// # Errors + /// * If the domain is not currently unlocked + pub fn lock_domain(&mut self, domain_id: u32) -> Result<()> { + if self.domains.remove(&domain_id).is_none() { + bail!("Domain {domain_id} is not unlocked"); + } + + Ok(()) + } + + /// Checks if a domain is currently unlocked + pub fn is_domain_unlocked(&self, domain_id: u32) -> bool { + self.domains.contains_key(&domain_id) + } + + /// Gets the cypher for a specific domain + /// + /// # Returns + /// * `Some(&Cypher)` if the domain is unlocked + /// * `None` if the domain is locked + pub fn get_cypher(&self, domain_id: u32) -> Option<&Cypher> { + self.domains.get(&domain_id).map(|d| &d.cypher) + } + + /// Gets the encryption domain (name + cypher) for a specific domain + /// + /// # Returns + /// * `Some(&EncryptionDomain)` if the domain is unlocked + /// * `None` if the domain is locked + pub fn get_domain(&self, domain_id: u32) -> Option<&EncryptionDomain> { + self.domains.get(&domain_id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_cypher() -> Cypher { + let key = EncryptionKey::from_password_with_params( + CypherVersion::default(), + "master_password", + &Argon2Params::insecure(), + ) + .unwrap(); + Cypher::new(key) + } + + #[test] + fn test_new_manager() { + let cypher = create_test_cypher(); + let manager = EncryptionDomainManager::new(cypher); + + assert!(manager.is_domain_unlocked(MASTER_DOMAIN_ID)); + assert!(!manager.is_domain_unlocked(1)); + + let master = manager.get_domain(MASTER_DOMAIN_ID).unwrap(); + assert_eq!(master.name, MASTER_DOMAIN_NAME); + } + + #[test] + fn test_unlock_domain() { + let cypher = create_test_cypher(); + let mut manager = EncryptionDomainManager::new(cypher); + + manager + .unlock_domain( + 1, + "work".to_string(), + "domain1_password", + &Argon2Params::insecure(), + ) + .unwrap(); + + assert!(manager.is_domain_unlocked(1)); + assert!(manager.get_cypher(1).is_some()); + + let domain = manager.get_domain(1).unwrap(); + assert_eq!(domain.name, "work"); + } + + #[test] + fn test_unlock_already_unlocked_fails() { + let cypher = create_test_cypher(); + let mut manager = EncryptionDomainManager::new(cypher); + + manager + .unlock_domain(1, "test".to_string(), "password", &Argon2Params::insecure()) + .unwrap(); + + let result = + manager.unlock_domain(1, "test".to_string(), "password", &Argon2Params::insecure()); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("already unlocked")); + } + + #[test] + fn test_lock_domain() { + let cypher = create_test_cypher(); + let mut manager = EncryptionDomainManager::new(cypher); + + manager + .unlock_domain(1, "test".to_string(), "password", &Argon2Params::insecure()) + .unwrap(); + assert!(manager.is_domain_unlocked(1)); + + manager.lock_domain(1).unwrap(); + assert!(!manager.is_domain_unlocked(1)); + assert!(manager.get_cypher(1).is_none()); + } + + #[test] + fn test_lock_master_domain() { + let cypher = create_test_cypher(); + let mut manager = EncryptionDomainManager::new(cypher); + + // Can lock master domain (no special treatment) + manager.lock_domain(MASTER_DOMAIN_ID).unwrap(); + assert!(!manager.is_domain_unlocked(MASTER_DOMAIN_ID)); + } + + #[test] + fn test_lock_not_unlocked_fails() { + let cypher = create_test_cypher(); + let mut manager = EncryptionDomainManager::new(cypher); + + let result = manager.lock_domain(1); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("not unlocked")); + } + + #[test] + fn test_multiple_domains() { + let cypher = create_test_cypher(); + let mut manager = EncryptionDomainManager::new(cypher); + + manager + .unlock_domain( + 1, + "personal".to_string(), + "pass1", + &Argon2Params::insecure(), + ) + .unwrap(); + manager + .unlock_domain(2, "work".to_string(), "pass2", &Argon2Params::insecure()) + .unwrap(); + manager + .unlock_domain(3, "shared".to_string(), "pass3", &Argon2Params::insecure()) + .unwrap(); + + assert!(manager.is_domain_unlocked(1)); + assert!(manager.is_domain_unlocked(2)); + assert!(manager.is_domain_unlocked(3)); + + assert_eq!(manager.get_domain(1).unwrap().name, "personal"); + assert_eq!(manager.get_domain(2).unwrap().name, "work"); + assert_eq!(manager.get_domain(3).unwrap().name, "shared"); + + manager.lock_domain(2).unwrap(); + + assert!(manager.is_domain_unlocked(1)); + assert!(!manager.is_domain_unlocked(2)); + assert!(manager.is_domain_unlocked(3)); + } + + #[test] + fn test_encrypt_decrypt_with_domain_key() { + let cypher = create_test_cypher(); + let mut manager = EncryptionDomainManager::new(cypher); + + manager + .unlock_domain( + 1, + "test".to_string(), + "domain_password", + &Argon2Params::insecure(), + ) + .unwrap(); + + let domain_cypher = manager.get_cypher(1).unwrap(); + let plaintext = "secret data"; + + let encrypted = domain_cypher.encrypt(plaintext.as_bytes()).unwrap(); + let decrypted = domain_cypher.decrypt(&encrypted).unwrap(); + + assert_eq!(decrypted.as_slice(), plaintext.as_bytes()); + } +} diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs index 3f18d06..ae85841 100644 --- a/src/crypto/mod.rs +++ b/src/crypto/mod.rs @@ -1,8 +1,12 @@ mod cipher; +mod domain_keys; mod key; mod stream_ops; mod utils; pub use cipher::Cypher; +pub use domain_keys::{ + EncryptionDomain, EncryptionDomainManager, MASTER_DOMAIN_ID, MASTER_DOMAIN_NAME, +}; pub use key::{Argon2Params, EncryptionKey}; pub use utils::LimitedReader; diff --git a/src/lib.rs b/src/lib.rs index f9892e5..8544498 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -29,7 +29,10 @@ mod version; // Public re-exports (maintaining exact same API) pub use cli::utils::{Spinner, ThreadStopGuard, copy_to_clipboard, format_timestamp, secure_print}; -pub use crypto::{Argon2Params, Cypher, EncryptionKey}; +pub use crypto::{ + Argon2Params, Cypher, EncryptionDomain, EncryptionDomainManager, EncryptionKey, + MASTER_DOMAIN_ID, MASTER_DOMAIN_NAME, +}; pub use path_utils::{ format_full_path, normalize_path, parse_key_path, relative_path_from, resolve_path, }; From 515f176205dfc6d238148668cdf3b82cbfbba2af Mon Sep 17 00:00:00 2001 From: Roman Studenikin Date: Tue, 30 Dec 2025 20:23:05 +0000 Subject: [PATCH 08/12] Extend StorageV5 with encryption_domains MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Added encryption_domains field: - HashMap mapping domain_id → domain_name - Automatically serialized/deserialized with bincode - Initialized with master domain (id=0, name="master") in new() 2. Domain management methods: - create_encryption_domain(name) - creates new domain, returns ID - get_domain_name(domain_id) - gets domain name by ID - encryption_domains_iter() - iterates all domains --- src/storage/v5.rs | 71 +++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 2 deletions(-) diff --git a/src/storage/v5.rs b/src/storage/v5.rs index abdf5dd..b7889e2 100644 --- a/src/storage/v5.rs +++ b/src/storage/v5.rs @@ -18,13 +18,21 @@ use crate::version::StoreVersion; pub struct StorageV5 { /// Root folder containing all secrets and subfolders pub root: Folder, + /// Encryption domain metadata (`domain_id` -> `domain_name`) + /// Domain 0 is always "master" (uses storage password) + pub encryption_domains: std::collections::HashMap, } impl StorageV5 { /// Create a new empty V5 storage - pub const fn new() -> Self { + pub fn new() -> Self { + use crate::{MASTER_DOMAIN_ID, MASTER_DOMAIN_NAME}; + let mut encryption_domains = std::collections::HashMap::new(); + encryption_domains.insert(MASTER_DOMAIN_ID, MASTER_DOMAIN_NAME.to_string()); + Self { root: Folder::new_root(), + encryption_domains, } } @@ -261,6 +269,56 @@ impl StorageV5 { normalized_path, )) } + + // ======================================================================== + // Encryption Domain Management + // ======================================================================== + + /// Creates a new encryption domain with the given name + /// + /// # Arguments + /// * `name` - Name for the new encryption domain + /// + /// # Returns + /// * The new domain ID + /// + /// # Errors + /// * If a domain with this name already exists + pub fn create_encryption_domain(&mut self, name: String) -> Result { + // Check if domain name already exists + if self + .encryption_domains + .values() + .any(|existing_name| existing_name == &name) + { + bail!("Encryption domain '{name}' already exists"); + } + + // Find next available domain ID (start from 1, skip 0 which is master) + let domain_id = (1..=u32::MAX) + .find(|id| !self.encryption_domains.contains_key(id)) + .ok_or_else(|| anyhow::anyhow!("No available domain IDs"))?; + + self.encryption_domains.insert(domain_id, name); + Ok(domain_id) + } + + /// Gets the name of an encryption domain + /// + /// # Returns + /// * `Some(&str)` if the domain exists + /// * `None` if the domain ID is not registered + pub fn get_domain_name(&self, domain_id: u32) -> Option<&str> { + self.encryption_domains.get(&domain_id).map(String::as_str) + } + + /// Gets all encryption domains + /// + /// # Returns + /// * Iterator of (`domain_id`, `domain_name`) pairs + pub fn encryption_domains_iter(&self) -> impl Iterator { + self.encryption_domains.iter() + } } // ============================================================================ @@ -911,6 +969,8 @@ use super::value::ValueEntry; /// Migrate V4 storage to V5 format /// All secrets go into root folder with default encryption domain (0) pub fn migrate_v4_to_v5(v4: StorageV4) -> StorageV5 { + use crate::{MASTER_DOMAIN_ID, MASTER_DOMAIN_NAME}; + let mut root = Folder::new_root(); // Migrate flat structure to root folder @@ -921,7 +981,14 @@ pub fn migrate_v4_to_v5(v4: StorageV4) -> StorageV5 { .insert(key.clone(), FolderItem::new_secret(key, secrets, 0)); } - StorageV5 { root } + // Initialize encryption domains with master domain + let mut encryption_domains = std::collections::HashMap::new(); + encryption_domains.insert(MASTER_DOMAIN_ID, MASTER_DOMAIN_NAME.to_string()); + + StorageV5 { + root, + encryption_domains, + } } /// Convert V4 `ValueEntry` to V5 `SecretEntry` From a537d93c4f2319c883a60747cb05280748628f73 Mon Sep 17 00:00:00 2001 From: Roman Studenikin Date: Tue, 30 Dec 2025 21:35:30 +0000 Subject: [PATCH 09/12] Add custom Encode and Decode for FolderItem This is required to make sure that unencrypted content of unlocked encrypted folder is not written to disk. The implementation just skips the decrypted_folder field --- src/lib.rs | 2 +- src/storage/mod.rs | 3 +- src/storage/v5.rs | 134 +++++++++++++++++++++++++++++++++++++- tests/storage_v5_tests.rs | 111 +++++++++++++++++++++++++++++++ 4 files changed, 245 insertions(+), 5 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 8544498..4c12568 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -38,7 +38,7 @@ pub use path_utils::{ }; pub use security::{disable_core_dumps, enable_ptrace_protection, is_debugger_attached}; pub use storage::{ - EncryptedValue, SecretEntry, StorageV4, StorageV5, deserialize_storage_v4, + EncryptedValue, Folder, FolderItem, SecretEntry, StorageV4, StorageV5, deserialize_storage_v4, deserialize_storage_v5_from_slice, load_storage_v4, load_storage_v5, save_storage_v4, save_storage_v5, serialize_storage_v4, serialize_storage_v5_to_vec, }; diff --git a/src/storage/mod.rs b/src/storage/mod.rs index d04e398..165f953 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -9,6 +9,7 @@ pub use serialization::{ }; pub use store::StorageV4; pub use v5::{ - SecretEntry, StorageV5, deserialize_storage_v5_from_slice, serialize_storage_v5_to_vec, + Folder, FolderItem, SecretEntry, StorageV5, deserialize_storage_v5_from_slice, + serialize_storage_v5_to_vec, }; pub use value::EncryptedValue; diff --git a/src/storage/v5.rs b/src/storage/v5.rs index b7889e2..f6a6326 100644 --- a/src/storage/v5.rs +++ b/src/storage/v5.rs @@ -508,7 +508,7 @@ impl Default for StorageV5 { // ============================================================================ /// An item in a folder - can be either a secret or a subfolder -#[derive(Debug, Clone, Encode, Decode)] +#[derive(Debug, Clone)] pub enum FolderItem { /// Regular secret with history Secret { @@ -753,7 +753,134 @@ impl FolderItem { _ => None, } } + + // ======================================================================== + // Test/Debug helpers + // ======================================================================== + + #[cfg(any(test, debug_assertions))] + /// Test helper: Manually set `decrypted_folder` (to test serialization behavior) + /// Only available in test/debug builds + pub fn test_set_decrypted_folder(&mut self, folder: Folder) -> Result<()> { + match self { + Self::EncryptedFolder { + decrypted_folder, .. + } => { + *decrypted_folder = Some(Box::new(folder)); + Ok(()) + } + _ => bail!("Not an encrypted folder"), + } + } + + #[cfg(any(test, debug_assertions))] + /// Test helper: Check if `decrypted_folder` is populated + /// Only available in test/debug builds + pub const fn test_has_decrypted_folder(&self) -> bool { + matches!( + self, + Self::EncryptedFolder { + decrypted_folder: Some(_), + .. + } + ) + } + + #[cfg(any(test, debug_assertions))] + /// Test helper: Get `encrypted_data` bytes + /// Only available in test/debug builds + pub fn test_get_encrypted_data(&self) -> Option<&[u8]> { + match self { + Self::EncryptedFolder { encrypted_data, .. } => Some(encrypted_data), + _ => None, + } + } +} + +// Custom Encode/Decode implementations to ensure decrypted_folder is never serialized +impl Encode for FolderItem { + fn encode( + &self, + encoder: &mut E, + ) -> core::result::Result<(), bincode::error::EncodeError> { + match self { + Self::Secret { + name, + entries, + encryption_domain, + } => { + // Variant 0 + Encode::encode(&0u32, encoder)?; + Encode::encode(name, encoder)?; + Encode::encode(entries, encoder)?; + Encode::encode(encryption_domain, encoder)?; + } + Self::Folder { name, folder } => { + // Variant 1 + Encode::encode(&1u32, encoder)?; + Encode::encode(name, encoder)?; + Encode::encode(folder, encoder)?; + } + Self::EncryptedFolder { + name, + encrypted_data, + encryption_domain, + decrypted_folder: _, // Always skip this field + } => { + // Variant 2 + Encode::encode(&2u32, encoder)?; + Encode::encode(name, encoder)?; + Encode::encode(encrypted_data, encoder)?; + Encode::encode(encryption_domain, encoder)?; + // CRITICAL: decrypted_folder is NEVER encoded (security requirement) + } + } + Ok(()) + } +} + +impl Decode for FolderItem { + fn decode>( + decoder: &mut D, + ) -> core::result::Result { + let variant = >::decode(decoder)?; + match variant { + 0 => { + let name = >::decode(decoder)?; + let entries = as Decode>::decode(decoder)?; + let encryption_domain = >::decode(decoder)?; + Ok(Self::Secret { + name, + entries, + encryption_domain, + }) + } + 1 => { + let name = >::decode(decoder)?; + let folder = as Decode>::decode(decoder)?; + Ok(Self::Folder { name, folder }) + } + 2 => { + let name = >::decode(decoder)?; + let encrypted_data = as Decode>::decode(decoder)?; + let encryption_domain = >::decode(decoder)?; + // CRITICAL: decrypted_folder is always None after deserialization + Ok(Self::EncryptedFolder { + name, + encrypted_data, + encryption_domain, + decrypted_folder: None, + }) + } + _ => Err(bincode::error::DecodeError::UnexpectedVariant { + found: variant, + allowed: &bincode::error::AllowedEnumVariants::Range { min: 0, max: 2 }, + type_name: "FolderItem", + }), + } + } } +bincode::impl_borrow_decode!(FolderItem); // ============================================================================ // Folder - Hierarchical container with unified items @@ -1126,8 +1253,9 @@ mod tests { let results: Vec<_> = storage.get("test_key").unwrap().collect(); assert_eq!(results.len(), 1); - assert_eq!(results[0].0, "test_key"); - assert_eq!(results[0].1.as_bytes(), b"test_value"); + assert_eq!(results[0].0, "/"); // full_path + assert_eq!(results[0].1, "test_key"); // key name + assert_eq!(results[0].2.as_bytes(), b"test_value"); // value } #[test] diff --git a/tests/storage_v5_tests.rs b/tests/storage_v5_tests.rs index 073704c..48e01eb 100644 --- a/tests/storage_v5_tests.rs +++ b/tests/storage_v5_tests.rs @@ -1053,3 +1053,114 @@ fn test_move_folder_collision_error() { // Source should still have it assert!(storage.get_folder("/source/folder1").is_some()); } + +#[test] +fn test_encrypted_folder_decrypted_state_not_serialized() { + // This test verifies that decrypted_folder (in-memory state) is NOT serialized + // Thanks to #[serde(skip)], unlocked folders return to locked state on load + + use rcypher::*; + + let (_dir, path) = temp_test_file(); + let cypher = Cypher::new( + EncryptionKey::from_password_with_params( + CypherVersion::V7WithKdf, + "test_password", + &Argon2Params::insecure(), + ) + .unwrap(), + ); + + // Create storage with an encrypted folder + let mut storage = StorageV5::new(); + + // Create a regular folder with some content + storage.mkdir("/", "subfolder").unwrap(); + storage.put_at_path( + "/subfolder", + "secret1".to_string(), + EncryptedValue::encrypt(&cypher, "value1").unwrap(), + 0, + ); + + // Create an encrypted folder with some encrypted_data + use std::collections::BTreeMap; + let mut encrypted_folder = FolderItem::new_encrypted_folder( + "locked_folder".to_string(), + vec![1, 2, 3, 4, 5], // dummy encrypted bytes + 1, // custom domain + ); + + // Create a decrypted folder with content + let mut decrypted = Folder { + name: "decrypted_content".to_string(), + encryption_domain: 1, + items: BTreeMap::new(), + }; + decrypted.items.insert( + "inner_secret".to_string(), + FolderItem::new_secret( + "inner_secret".to_string(), + vec![SecretEntry::new( + EncryptedValue::encrypt(&cypher, "sensitive_data").unwrap(), + 12345, + )], + 1, + ), + ); + + // Simulate unlocking: populate decrypted_folder (test helper only available in test/debug) + encrypted_folder + .test_set_decrypted_folder(decrypted) + .unwrap(); + + // Verify folder is unlocked before save + assert!( + encrypted_folder.test_has_decrypted_folder(), + "Folder should be unlocked before serialization" + ); + + // Add to storage + storage + .root + .items + .insert("locked_folder".to_string(), encrypted_folder); + + // Save storage + save_storage_v5(&cypher, &storage, &path).unwrap(); + + // Load storage back + let loaded_storage = load_storage_v5(&cypher, &path).unwrap(); + + // CRITICAL TEST: decrypted_folder should be None after deserialization + // This verifies #[serde(skip)] works correctly + let loaded_item = loaded_storage.root.items.get("locked_folder").unwrap(); + assert!( + !loaded_item.test_has_decrypted_folder(), + "Folder MUST be locked after deserialization - decrypted_folder should be None" + ); + + // Verify encrypted_data is preserved + assert_eq!( + loaded_item.test_get_encrypted_data(), + Some(&[1, 2, 3, 4, 5][..]), + "encrypted_data should be preserved" + ); + + // Verify encryption_domain is preserved + assert_eq!( + loaded_item.encryption_domain(), + 1, + "encryption_domain should be preserved" + ); + + // Verify other content is still there + assert_eq!(loaded_storage.root.items.len(), 2, "Should have 2 items"); + assert!( + loaded_storage.get_folder("/subfolder").is_some(), + "Regular folder should still exist" + ); + + println!("✓ Critical test passed: decrypted_folder is NOT serialized"); + println!("✓ Unlocked folders return to locked state on disk"); +} From 099286c8e86bdcc45d30cac70ed73d3bdd0e2f96 Mon Sep 17 00:00:00 2001 From: Roman Studenikin Date: Sun, 4 Jan 2026 17:40:05 +0000 Subject: [PATCH 10/12] Implement encryption functionality for different encryption domains --- TODO | 39 +- src/cli/interactive.rs | 99 ++- src/cli/update.rs | 330 +++++--- src/crypto/domain_keys.rs | 5 + src/main.rs | 24 +- src/storage/serialization.rs | 19 +- src/storage/v5.rs | 792 ++++++++++++------ tests/cli_tests.rs | 22 +- tests/storage_v5_tests.rs | 1458 ++++++++++++++++++++++++++-------- 9 files changed, 2059 insertions(+), 729 deletions(-) diff --git a/TODO b/TODO index 9489bc6..08c1187 100644 --- a/TODO +++ b/TODO @@ -18,25 +18,32 @@ - Default encryption domain (0) uses the master key (same as storage v4) ⏳ **TODO: Simplified encryption approach** -- Non-default domains encrypt secrets/folders with a custom key requiring password -- Can be attached to individual secrets or entire folders -- Encrypted folders are VISIBLE as encrypted folders (no hiding with placeholder values) -- When locked: `get` returns "**LOCKED**" indicator -- When unlocked: folder becomes navigable, contents accessible -- "unlock {folder}" command asks for password, decrypts and populates in-memory -- "lock {folder}" command clears decrypted data from memory +- ✅ Non-default domains encrypt secrets/folders with a custom key requiring password +- ✅ Can be attached to individual secrets or entire folders +- ✅ Encrypted folders are VISIBLE as encrypted folders (no hiding with placeholder values) +- ✅ Domain keys are session-only (not persisted to disk) +- ✅ Critical: `decrypted_folder` field never serialized (verified by test) +- When domain locked: items show "**LOCKED**" indicator +- When domain unlocked: items decrypt lazily on access (folders navigable, secrets readable) - Delete operation works uniformly for secrets, folders, and encrypted folders -**Simplified Use Case** -Encryption: -- User calls "lock {folder/key}" which asks for encryption domain and password -- Content is immediately encrypted and stored encrypted -- Locked items show "**LOCKED**" when accessed +**Command Semantics** +Domain Management: +- `unlock ` - Unlocks encryption domain by asking for password, loads cypher into memory +- Domain remains unlocked for the session (until process exits) +- Domain keys never persisted to disk (re-enter password each session) +- No `unlock ` command - items decrypt automatically when accessed if domain unlocked -Decryption: -- "unlock {item}" asks for password -- Decrypts into memory, making content accessible -- Subsequent commands work normally on decrypted content +Changing Encryption Domains: +- `lock ` - Changes item's encryption domain and re-encrypts +- Re-encryption uses the cypher from the target domain (must be unlocked first) +- Works for both individual secrets and entire folders + +Accessing Encrypted Content: +- Decryption happens lazily on first access (not during domain unlock) +- If domain locked: items show "**LOCKED**" when accessed +- If domain unlocked: items decrypt automatically on access +- Encrypted folders become navigable once their domain is unlocked ## move command ✅ **COMPLETED: move command** diff --git a/src/cli/interactive.rs b/src/cli/interactive.rs index f701b84..be8c4ac 100644 --- a/src/cli/interactive.rs +++ b/src/cli/interactive.rs @@ -1,5 +1,6 @@ use crate::Cypher; -use crate::EncryptedValue; +use crate::EncryptionDomainManager; +use crate::FolderItem; use crate::StorageV5; use crate::cli::CLIPBOARD_TTL_MS; use crate::cli::STANDBY_TIMEOUT; @@ -24,22 +25,32 @@ use zeroize::Zeroize; pub struct InteractiveCli { prompt: String, insecure_stdout: bool, - cypher: Cypher, filename: PathBuf, current_path: Arc>, + domain_manager: EncryptionDomainManager, } impl InteractiveCli { - pub fn new(prompt: String, insecure_stdout: bool, cypher: Cypher, filename: PathBuf) -> Self { + pub fn new( + prompt: String, + insecure_stdout: bool, + domain_manager: EncryptionDomainManager, + filename: PathBuf, + ) -> Self { Self { prompt, insecure_stdout, - cypher, filename, current_path: Arc::new(Mutex::new(String::from("/"))), + domain_manager, } } + /// Get the master cypher for direct encryption/decryption operations + fn get_master_cypher(&self) -> &Cypher { + self.domain_manager.get_master_cypher() + } + fn get_current_path(&self) -> String { let path = self.current_path.lock().expect("able to lock"); if path.is_empty() { @@ -55,7 +66,10 @@ impl InteractiveCli { } pub fn run(&mut self) -> Result<()> { - let storage = Arc::new(Mutex::new(load_storage_v5(&self.cypher, &self.filename)?)); + let storage = Arc::new(Mutex::new(load_storage_v5( + self.get_master_cypher(), + &self.filename, + )?)); let config = Config::builder() .completion_type(CompletionType::List) @@ -83,7 +97,7 @@ impl InteractiveCli { // Check timeout if last_use_time .elapsed() - .expect("time moves forward") + .expect("time goes forward") .as_secs() > STANDBY_TIMEOUT { @@ -193,34 +207,45 @@ impl InteractiveCli { } fn cmd_put(&self, key: &str, value: &str, storage: &mut StorageV5) -> Result<()> { - let encrypted_value = EncryptedValue::encrypt(&self.cypher, value)?; let (folder_path, key_name) = parse_key_path(&self.get_current_path(), key); let timestamp = SystemTime::now() .duration_since(std::time::UNIX_EPOCH) - .expect("time should go forward") + .expect("time goes forward") .as_secs(); + + // Determine encryption domain: + // 1. If item already exists, preserve its encryption domain + // 2. Otherwise, inherit from parent folder hierarchy + let encryption_domain_id = storage + .get_folder(&folder_path) + .and_then(|folder| folder.get_item(key_name)) + .and_then(FolderItem::encryption_domain) + .unwrap_or_else(|| storage.get_encryption_domain_for_path(&folder_path)); + storage.put_at_path( &folder_path, key_name.to_string(), - encrypted_value, + value, timestamp, - ); + encryption_domain_id, + &self.domain_manager, + )?; secure_print(format!("{key} stored"), self.insecure_stdout)?; - save_storage_v5(&self.cypher, storage, &self.filename)?; + save_storage_v5(&self.domain_manager, storage, &self.filename)?; Ok(()) } - fn cmd_get(&self, pattern: &str, storage: &StorageV5) -> Result<()> { + fn cmd_get(&self, pattern: &str, storage: &mut StorageV5) -> Result<()> { let (folder_path, key_pattern) = parse_key_path(&self.get_current_path(), pattern); - match storage.get_at_path(&folder_path, key_pattern, true) { + match storage.get_at_path(&folder_path, key_pattern, true, &self.domain_manager) { // Recursive search Ok(results) => { let mut found = false; for (folder_path, key, val) in results { found = true; - let mut secret = val.decrypt(&self.cypher)?; + let mut secret = val.decrypt(self.get_master_cypher())?; let output = format!( "{}: {}", format_full_path(&folder_path, key, false), @@ -238,9 +263,9 @@ impl InteractiveCli { Ok(()) } - fn cmd_copy(&self, key: &str, storage: &StorageV5) -> Result<()> { + fn cmd_copy(&self, key: &str, storage: &mut StorageV5) -> Result<()> { let current_path = self.get_current_path(); - match storage.get_at_path(¤t_path, key, true) { + match storage.get_at_path(¤t_path, key, true, &self.domain_manager) { Ok(mut results) => { let first = results.next(); let second = results.next(); @@ -267,12 +292,11 @@ impl InteractiveCli { } (Some((_, _, val)), None) => { // Exactly one result - copy to clipboard - let mut secret = val.decrypt(&self.cypher)?; + let secret = val.decrypt(self.get_master_cypher())?; copy_to_clipboard( secret.as_ref(), std::time::Duration::from_millis(CLIPBOARD_TTL_MS), )?; - secret.zeroize(); } } } @@ -281,11 +305,12 @@ impl InteractiveCli { Ok(()) } - fn cmd_history(&self, key: &str, storage: &StorageV5) -> Result<()> { + fn cmd_history(&self, key: &str, storage: &mut StorageV5) -> Result<()> { let (folder_path, key_name) = parse_key_path(&self.get_current_path(), key); - if let Some(entries) = storage.history_at_path(&folder_path, key_name) { + if let Some(entries) = storage.history_at_path(&folder_path, key_name, &self.domain_manager) + { for entry in entries { - let mut secret = entry.encrypted_value().decrypt(&self.cypher)?; + let mut secret = entry.encrypted_value().decrypt(self.get_master_cypher())?; let output = format!("[{}]: {}", format_timestamp(entry.timestamp), &*secret); secret.zeroize(); secure_print(output, self.insecure_stdout)?; @@ -296,9 +321,9 @@ impl InteractiveCli { Ok(()) } - fn cmd_search(&self, pattern: &str, storage: &StorageV5) -> Result<()> { + fn cmd_search(&self, pattern: &str, storage: &mut StorageV5) -> Result<()> { let (folder_path, key_pattern) = parse_key_path(&self.get_current_path(), pattern); - match storage.search_at_path(&folder_path, key_pattern, true) { + match storage.search_at_path(&folder_path, key_pattern, true, &self.domain_manager) { // Recursive search Ok(keys) => { for (folder_path, key) in keys { @@ -315,9 +340,9 @@ impl InteractiveCli { fn cmd_delete(&self, key: &str, storage: &mut StorageV5) -> Result<()> { let (folder_path, key_name) = parse_key_path(&self.get_current_path(), key); - if storage.delete_at_path(&folder_path, key_name) { + if storage.delete_at_path(&folder_path, key_name, &self.domain_manager) { secure_print(format!("{key} deleted"), self.insecure_stdout)?; - save_storage_v5(&self.cypher, storage, &self.filename)?; + save_storage_v5(&self.domain_manager, storage, &self.filename)?; } else { bail!("No such key '{key}' found"); } @@ -326,12 +351,12 @@ impl InteractiveCli { fn cmd_mkdir(&self, folder_name: &str, storage: &mut StorageV5) -> Result<()> { let (parent_path, new_folder_name) = parse_key_path(&self.get_current_path(), folder_name); - storage.mkdir(&parent_path, new_folder_name)?; + storage.mkdir(&parent_path, new_folder_name, &self.domain_manager)?; secure_print( format!("Folder '{folder_name}' created"), self.insecure_stdout, )?; - save_storage_v5(&self.cypher, storage, &self.filename)?; + save_storage_v5(&self.domain_manager, storage, &self.filename)?; Ok(()) } @@ -388,7 +413,14 @@ impl InteractiveCli { (folder, Some(name)) }; - storage.move_item(parent_path, item_name, &dest_parent, dest_name)?; + storage.move_item( + parent_path, + item_name, + &dest_parent, + dest_name, + None, + &self.domain_manager, + )?; let final_name = dest_name.unwrap_or(item_name.as_str()); let dest_display = format_full_path(&dest_parent, final_name, *is_folder); @@ -423,7 +455,14 @@ impl InteractiveCli { // Move all items for (parent_path, item_name, _) in &matches { - storage.move_item(parent_path, item_name, &dest_folder, None)?; + storage.move_item( + parent_path, + item_name, + &dest_folder, + None, + None, + &self.domain_manager, + )?; } let message = match (key_count, folder_count) { @@ -444,7 +483,7 @@ impl InteractiveCli { secure_print(message, self.insecure_stdout)?; } - save_storage_v5(&self.cypher, storage, &self.filename)?; + save_storage_v5(&self.domain_manager, storage, &self.filename)?; Ok(()) } diff --git a/src/cli/update.rs b/src/cli/update.rs index 1aec7ce..f485976 100644 --- a/src/cli/update.rs +++ b/src/cli/update.rs @@ -1,10 +1,12 @@ use crate::cli::utils::{format_full_path, format_timestamp, secure_print}; -use crate::{Cypher, EncryptedValue, EncryptionKey, StorageV5, load_storage_v5, save_storage_v5}; +use crate::{ + Cypher, EncryptedValue, EncryptionDomainManager, EncryptionKey, StorageV5, load_storage_v5, + save_storage_v5, +}; use anyhow::Result; use std::io; use std::io::Write; use std::path::Path; -use zeroize::Zeroize; #[derive(Debug)] struct UpdateEntry { @@ -27,16 +29,28 @@ fn find_updates_in_folder( folder_path: &str, main_storage: &StorageV5, update_storage: &StorageV5, - main_cypher: &Cypher, - update_cypher: &Cypher, + main_domain_manager: &EncryptionDomainManager, + update_domain_manager: &EncryptionDomainManager, updates: &mut Vec, + skipped_count: &mut usize, ) { let Some(update_folder) = update_storage.get_folder(folder_path) else { return; }; + let main_cypher = main_domain_manager.get_master_cypher(); + let update_cypher = update_domain_manager.get_master_cypher(); + // Check secrets in this folder for (key, item) in update_folder.secrets() { + // Skip items in non-default encryption domains + if let Some(domain) = item.encryption_domain() + && domain != crate::MASTER_DOMAIN_ID + { + *skipped_count += 1; + continue; + } + if let Some(update_entries) = item.get_entries() { let update_latest = update_entries.last().expect("entries should not be empty"); let main_latest = main_storage @@ -74,16 +88,25 @@ fn find_updates_in_folder( } } - // Recursively check subfolders - for (subfolder_name, _) in update_folder.navigable_folders() { + // Recursively check subfolders (only navigable = unlocked or regular folders) + for (subfolder_name, item) in update_folder.navigable_folders() { + // Skip encrypted folders in non-default domains + if let Some(domain) = item.encryption_domain() + && domain != crate::MASTER_DOMAIN_ID + { + *skipped_count += 1; + continue; + } + let subfolder_path = format_full_path(folder_path, subfolder_name, true); find_updates_in_folder( &subfolder_path, main_storage, update_storage, - main_cypher, - update_cypher, + main_domain_manager, + update_domain_manager, updates, + skipped_count, ); } } @@ -92,19 +115,21 @@ fn find_updates_in_folder( fn find_updates( main_storage: &StorageV5, update_storage: &StorageV5, - main_cypher: &Cypher, - update_cypher: &Cypher, -) -> Vec { + main_domain_manager: &EncryptionDomainManager, + update_domain_manager: &EncryptionDomainManager, +) -> (Vec, usize) { let mut updates = Vec::new(); + let mut skipped_count = 0; // Start recursive search from root find_updates_in_folder( "/", main_storage, update_storage, - main_cypher, - update_cypher, + main_domain_manager, + update_domain_manager, &mut updates, + &mut skipped_count, ); // Sort by folder path then key name for consistent presentation @@ -113,7 +138,7 @@ fn find_updates( .cmp(&b.folder_path) .then_with(|| a.key.cmp(&b.key)) }); - updates + (updates, skipped_count) } /// Format and display a single update entry @@ -164,10 +189,13 @@ fn display_update_entry( /// Display summary of updates to the user fn display_update_summary( updates: &[UpdateEntry], - main_cypher: &Cypher, - update_cypher: &Cypher, + main_domain_manager: &EncryptionDomainManager, + update_domain_manager: &EncryptionDomainManager, insecure_stdout: bool, ) -> Result<(usize, usize)> { + let main_cypher = main_domain_manager.get_master_cypher(); + let update_cypher = update_domain_manager.get_master_cypher(); + println!( "\nFound {} key{} with different values:", updates.len(), @@ -199,7 +227,11 @@ fn display_update_summary( } /// Ensure all parent folders exist for a given path, creating them if needed -fn ensure_folder_path(storage: &mut StorageV5, path: &str) -> Result<()> { +fn ensure_folder_path( + storage: &mut StorageV5, + path: &str, + domain_manager: &EncryptionDomainManager, +) -> Result<()> { if path == "/" || path.is_empty() { return Ok(()); } @@ -213,7 +245,7 @@ fn ensure_folder_path(storage: &mut StorageV5, path: &str) -> Result<()> { if storage.get_folder(&folder_path).is_none() { // Create the folder - storage.mkdir(¤t_path, part)?; + storage.mkdir(¤t_path, part, domain_manager)?; } current_path = folder_path; @@ -226,27 +258,29 @@ fn ensure_folder_path(storage: &mut StorageV5, path: &str) -> Result<()> { fn apply_all_updates( updates: Vec, main_storage: &mut StorageV5, - main_cypher: &Cypher, - update_cypher: &Cypher, + main_domain_manager: &EncryptionDomainManager, + update_domain_manager: &EncryptionDomainManager, filename: &Path, ) -> Result<()> { + let update_cypher = update_domain_manager.get_master_cypher(); + for update in updates { - let mut decrypted = update.new_value.decrypt(update_cypher)?; - let re_encrypted = EncryptedValue::encrypt(main_cypher, &decrypted)?; - decrypted.zeroize(); + let decrypted = update.new_value.decrypt(update_cypher)?; // Ensure folder path exists before putting the key - ensure_folder_path(main_storage, &update.folder_path)?; + ensure_folder_path(main_storage, &update.folder_path, main_domain_manager)?; main_storage.put_at_path( &update.folder_path, update.key, - re_encrypted, + &decrypted, update.new_timestamp, - ); + crate::MASTER_DOMAIN_ID, + main_domain_manager, + )?; } - save_storage_v5(main_cypher, main_storage, filename)?; + save_storage_v5(main_domain_manager, main_storage, filename)?; println!("✓ All updates applied successfully."); Ok(()) } @@ -255,11 +289,14 @@ fn apply_all_updates( fn apply_updates_interactive( updates: Vec, main_storage: &mut StorageV5, - main_cypher: &Cypher, - update_cypher: &Cypher, + main_domain_manager: &EncryptionDomainManager, + update_domain_manager: &EncryptionDomainManager, filename: &Path, insecure_stdout: bool, ) -> Result<()> { + let main_cypher = main_domain_manager.get_master_cypher(); + let update_cypher = update_domain_manager.get_master_cypher(); + let mut applied = 0; let mut skipped = 0; @@ -274,19 +311,19 @@ fn apply_updates_interactive( match response.trim().to_lowercase().as_str() { "y" | "yes" => { - let mut decrypted = update.new_value.decrypt(update_cypher)?; - let re_encrypted = EncryptedValue::encrypt(main_cypher, &decrypted)?; - decrypted.zeroize(); - // Ensure folder path exists before putting the key - ensure_folder_path(main_storage, &update.folder_path)?; + ensure_folder_path(main_storage, &update.folder_path, main_domain_manager)?; + + let decrypted = update.new_value.decrypt(update_cypher)?; main_storage.put_at_path( &update.folder_path, update.key, - re_encrypted, + &decrypted, update.new_timestamp, - ); + crate::MASTER_DOMAIN_ID, + main_domain_manager, + )?; applied += 1; println!("✓ Applied"); } @@ -302,7 +339,7 @@ fn apply_updates_interactive( } if applied > 0 { - save_storage_v5(main_cypher, main_storage, filename)?; + save_storage_v5(main_domain_manager, main_storage, filename)?; println!( "\n✓ Applied {} update{}, skipped {}.", applied, @@ -340,8 +377,26 @@ pub fn run_update_with( let update_cypher = Cypher::new(update_key); let update_storage = load_storage_v5(&update_cypher, update_file)?; - // Find what needs updating - let updates = find_updates(&main_storage, &update_storage, &main_cypher, &update_cypher); + // Create domain managers with only master domain unlocked + // We only sync secrets in the default domain (0) for safety and simplicity + let main_domain_manager = EncryptionDomainManager::new(main_cypher); + let update_domain_manager = EncryptionDomainManager::new(update_cypher); + + // Find what needs updating (only master domain items) + let (updates, skipped_count) = find_updates( + &main_storage, + &update_storage, + &main_domain_manager, + &update_domain_manager, + ); + + if skipped_count > 0 { + println!("⚠️ Skipped {skipped_count} items in non-default encryption domains."); + println!( + " These items are likely more sensitive and should be synced manually if needed." + ); + println!(); + } if updates.is_empty() { println!("No updates found. Storage files are in sync."); @@ -349,7 +404,12 @@ pub fn run_update_with( } // Display summary - display_update_summary(&updates, &main_cypher, &update_cypher, insecure_stdout)?; + display_update_summary( + &updates, + &main_domain_manager, + &update_domain_manager, + insecure_stdout, + )?; // Prompt for action let choice = prompt_merge_mode()?; @@ -360,8 +420,8 @@ pub fn run_update_with( apply_all_updates( updates, &mut main_storage, - &main_cypher, - &update_cypher, + &main_domain_manager, + &update_domain_manager, filename, )?; } @@ -369,8 +429,8 @@ pub fn run_update_with( apply_updates_interactive( updates, &mut main_storage, - &main_cypher, - &update_cypher, + &main_domain_manager, + &update_domain_manager, filename, insecure_stdout, )?; @@ -429,112 +489,176 @@ mod tests { #[test] fn test_find_updates_new_keys() { let cypher = create_test_cypher(); + let domain_manager = EncryptionDomainManager::new(cypher); let mut main_storage = StorageV5::new(); let mut update_storage = StorageV5::new(); // Main has key1, update has key1 and key2 - main_storage.put_at_path( - "/", - "key1".to_string(), - EncryptedValue::encrypt(&cypher, "value1").unwrap(), - 0, - ); - update_storage.put_at_path( - "/", - "key1".to_string(), - EncryptedValue::encrypt(&cypher, "value1").unwrap(), - 0, + main_storage + .put_at_path( + "/", + "key1".to_string(), + "value1", + 0, + crate::MASTER_DOMAIN_ID, + &domain_manager, + ) + .unwrap(); + update_storage + .put_at_path( + "/", + "key1".to_string(), + "value1", + 0, + crate::MASTER_DOMAIN_ID, + &domain_manager, + ) + .unwrap(); + update_storage + .put_at_path( + "/", + "key2".to_string(), + "value2", + 0, + crate::MASTER_DOMAIN_ID, + &domain_manager, + ) + .unwrap(); + + let (updates, skipped_count) = find_updates( + &main_storage, + &update_storage, + &domain_manager, + &domain_manager, ); - update_storage.put_at_path( - "/", - "key2".to_string(), - EncryptedValue::encrypt(&cypher, "value2").unwrap(), - 0, - ); - - let updates = find_updates(&main_storage, &update_storage, &cypher, &cypher); assert_eq!(updates.len(), 1); assert_eq!(updates[0].key, "key2"); assert!(updates[0].is_new_key()); + assert_eq!(skipped_count, 0); } #[test] fn test_find_updates_conflicts() { let cypher = create_test_cypher(); + let domain_manager = EncryptionDomainManager::new(cypher); let mut main_storage = StorageV5::new(); let mut update_storage = StorageV5::new(); // Both have key1 but with different values - main_storage.put_at_path( - "/", - "key1".to_string(), - EncryptedValue::encrypt(&cypher, "old_value").unwrap(), - 100, - ); - update_storage.put_at_path( - "/", - "key1".to_string(), - EncryptedValue::encrypt(&cypher, "new_value").unwrap(), - 200, + main_storage + .put_at_path( + "/", + "key1".to_string(), + "old_value", + 100, + crate::MASTER_DOMAIN_ID, + &domain_manager, + ) + .unwrap(); + update_storage + .put_at_path( + "/", + "key1".to_string(), + "new_value", + 200, + crate::MASTER_DOMAIN_ID, + &domain_manager, + ) + .unwrap(); + + let (updates, skipped_count) = find_updates( + &main_storage, + &update_storage, + &domain_manager, + &domain_manager, ); - let updates = find_updates(&main_storage, &update_storage, &cypher, &cypher); - assert_eq!(updates.len(), 1); assert_eq!(updates[0].key, "key1"); assert!(!updates[0].is_new_key()); + assert_eq!(skipped_count, 0); } #[test] fn test_find_updates_no_changes() { let cypher = create_test_cypher(); + let domain_manager = EncryptionDomainManager::new(cypher); let mut main_storage = StorageV5::new(); let mut update_storage = StorageV5::new(); // Both have same key with same value - main_storage.put_at_path( - "/", - "key1".to_string(), - EncryptedValue::encrypt(&cypher, "value1").unwrap(), - 0, - ); - update_storage.put_at_path( - "/", - "key1".to_string(), - EncryptedValue::encrypt(&cypher, "value1").unwrap(), - 0, + main_storage + .put_at_path( + "/", + "key1".to_string(), + "value1", + 0, + crate::MASTER_DOMAIN_ID, + &domain_manager, + ) + .unwrap(); + update_storage + .put_at_path( + "/", + "key1".to_string(), + "value1", + 0, + crate::MASTER_DOMAIN_ID, + &domain_manager, + ) + .unwrap(); + + let (updates, skipped_count) = find_updates( + &main_storage, + &update_storage, + &domain_manager, + &domain_manager, ); - let updates = find_updates(&main_storage, &update_storage, &cypher, &cypher); - assert_eq!(updates.len(), 0); + assert_eq!(skipped_count, 0); } #[test] fn test_find_updates_ignores_older_timestamp() { let cypher = create_test_cypher(); + let domain_manager = EncryptionDomainManager::new(cypher); let mut main_storage = StorageV5::new(); let mut update_storage = StorageV5::new(); // Update has older value - update_storage.put_at_path( - "/", - "key1".to_string(), - EncryptedValue::encrypt(&cypher, "old_value").unwrap(), - 100, + update_storage + .put_at_path( + "/", + "key1".to_string(), + "old_value", + 100, + crate::MASTER_DOMAIN_ID, + &domain_manager, + ) + .unwrap(); + + main_storage + .put_at_path( + "/", + "key1".to_string(), + "new_value", + 200, + crate::MASTER_DOMAIN_ID, + &domain_manager, + ) + .unwrap(); + + let (updates, skipped_count) = find_updates( + &main_storage, + &update_storage, + &domain_manager, + &domain_manager, ); - main_storage.put_at_path( - "/", - "key1".to_string(), - EncryptedValue::encrypt(&cypher, "new_value").unwrap(), - 200, - ); - - let updates = find_updates(&main_storage, &update_storage, &cypher, &cypher); - // Should not include update since main is newer assert_eq!(updates.len(), 0); + assert_eq!(skipped_count, 0); } } diff --git a/src/crypto/domain_keys.rs b/src/crypto/domain_keys.rs index 73f9bc8..b316fc2 100644 --- a/src/crypto/domain_keys.rs +++ b/src/crypto/domain_keys.rs @@ -45,6 +45,11 @@ impl EncryptionDomainManager { Self { domains } } + pub fn get_master_cypher(&self) -> &Cypher { + self.get_cypher(MASTER_DOMAIN_ID) + .expect("Always have master cypher") + } + /// Unlocks an encryption domain by deriving a key from the provided password /// /// # Arguments diff --git a/src/main.rs b/src/main.rs index f740eac..1a9bcb1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -23,8 +23,8 @@ use clap::{ArgGroup, Parser}; use nix::fcntl::{Flock, FlockArg}; use rcypher::cli::utils::get_password; use rcypher::{ - Argon2Params, Cypher, CypherVersion, EncryptedValue, EncryptionKey, Spinner, StorageV5, - ThreadStopGuard, load_storage_v5, save_storage_v5, + Argon2Params, Cypher, CypherVersion, EncryptionDomainManager, EncryptionKey, MASTER_DOMAIN_ID, + Spinner, StorageV5, ThreadStopGuard, load_storage_v5, save_storage_v5, }; // Import from lib use rcypher::{cli, disable_core_dumps, enable_ptrace_protection, is_debugger_attached}; use std::fs::OpenOptions; @@ -157,18 +157,25 @@ fn run_upgrade_storage( let mut new_storage = StorageV5::new(); let new_cypher = Cypher::new(new_key); + let new_domain_manager = EncryptionDomainManager::new(new_cypher); + for (key, item) in old_storage.root.secrets() { if let Some(entries) = item.get_entries() { for entry in entries { - let mut secret = entry.encrypted_value().decrypt(&old_cypher)?; - let new_value = EncryptedValue::encrypt(&new_cypher, &secret)?; - new_storage.put_at_path("/", key.clone(), new_value, entry.timestamp); - secret.zeroize(); + let secret = entry.encrypted_value().decrypt(&old_cypher)?; + new_storage.put_at_path( + "/", + key.clone(), + &secret, + entry.timestamp, + MASTER_DOMAIN_ID, + &new_domain_manager, + )?; } } } - save_storage_v5(&new_cypher, &new_storage, ¶ms.filename)?; + save_storage_v5(&new_domain_manager, &mut new_storage, ¶ms.filename)?; spinner.finish_and_clear(); Ok(()) @@ -176,11 +183,12 @@ fn run_upgrade_storage( fn run_interactive(params: &CliParams, key: EncryptionKey) -> Result<()> { let cypher = Cypher::new(key); + let domain_manager = EncryptionDomainManager::new(cypher); let mut interactive_cli = cli::InteractiveCli::new( params.prompt.clone(), params.insecure_stdout, - cypher, + domain_manager, params.filename.clone(), ); interactive_cli.run()?; diff --git a/src/storage/serialization.rs b/src/storage/serialization.rs index 6417ac7..a34b3b7 100644 --- a/src/storage/serialization.rs +++ b/src/storage/serialization.rs @@ -4,7 +4,9 @@ use std::path::Path; use anyhow::{Result, bail}; use tempfile::NamedTempFile; +use zeroize::Zeroize; +use crate::EncryptionDomainManager; use crate::crypto::Cypher; use crate::version::StoreVersion; @@ -169,12 +171,23 @@ pub fn load_storage_v5(cypher: &Cypher, path: &Path) -> Result { } /// Save V5 storage to file -pub fn save_storage_v5(cypher: &Cypher, storage: &StorageV5, path: &Path) -> Result<()> { +pub fn save_storage_v5( + domain_manager: &EncryptionDomainManager, + storage: &mut StorageV5, + path: &Path, +) -> Result<()> { + // Re-encrypt all unlocked encrypted folders before saving + storage.prepare_for_save(domain_manager)?; + let dir = path.parent().expect("Can't get parent dir of a file"); let mut temp = NamedTempFile::new_in(dir)?; - let serialized = v5::serialize_storage_v5_to_vec(storage)?; - let encrypted = cypher.encrypt(&serialized)?; + let mut serialized = v5::serialize_storage_v5_to_vec(storage)?; + + // Encrypt the file with the master cypher + let encrypted = domain_manager.get_master_cypher().encrypt(&serialized)?; + + serialized.zeroize(); temp.write_all(&encrypted)?; temp.persist(path)?; diff --git a/src/storage/v5.rs b/src/storage/v5.rs index f6a6326..968c02f 100644 --- a/src/storage/v5.rs +++ b/src/storage/v5.rs @@ -1,13 +1,17 @@ +use anyhow::anyhow; use std::collections::{BTreeMap, HashMap}; use std::io::{Read, Write}; +use zeroize::Zeroizing; use anyhow::{Result, bail}; use bincode::{Decode, Encode, config}; use regex::Regex; use super::value::EncryptedValue; +use crate::EncryptionDomainManager; use crate::path_utils::{format_full_path, relative_path_from}; use crate::version::StoreVersion; +use crate::{MASTER_DOMAIN_ID, MASTER_DOMAIN_NAME}; // ============================================================================ // Storage V5 - Hierarchical folders with encryption domains @@ -26,7 +30,6 @@ pub struct StorageV5 { impl StorageV5 { /// Create a new empty V5 storage pub fn new() -> Self { - use crate::{MASTER_DOMAIN_ID, MASTER_DOMAIN_NAME}; let mut encryption_domains = std::collections::HashMap::new(); encryption_domains.insert(MASTER_DOMAIN_ID, MASTER_DOMAIN_NAME.to_string()); @@ -36,7 +39,10 @@ impl StorageV5 { } } - /// Get a folder by path (e.g., "/" or "/work/personal") + /// Get a folder by path (read-only, no decryption) + /// + /// Returns None if path doesn't exist or contains locked encrypted folders + /// Use `get_folder_mut` if you need to decrypt folders during traversal pub fn get_folder(&self, path: &str) -> Option<&Folder> { if path == "/" || path.is_empty() { return Some(&self.root); @@ -46,39 +52,140 @@ impl StorageV5 { let mut current = &self.root; for part in parts { - current = current.get_subfolder(part)?; + let item = current.items.get(part)?; + current = item.get_folder()?; // Returns None if locked } Some(current) } - /// Get a mutable folder by path - pub fn get_folder_mut(&mut self, path: &str) -> Option<&mut Folder> { + /// Get a mutable folder by path with transparent decryption + /// + /// Automatically decrypts `EncryptedFolders` during traversal if domain is unlocked + /// + /// # Errors + /// * If path doesn't exist + /// * If an encrypted folder's domain is locked + /// * If decryption fails + pub fn get_folder_mut( + &mut self, + path: &str, + domain_manager: &EncryptionDomainManager, + ) -> Result<&mut Folder> { if path == "/" || path.is_empty() { - return Some(&mut self.root); + return Ok(&mut self.root); } let parts: Vec<&str> = path.trim_matches('/').split('/').collect(); let mut current = &mut self.root; for part in parts { - current = current.get_subfolder_mut(part)?; + // Get the item + let item = current + .items + .get_mut(part) + .ok_or_else(|| anyhow::anyhow!("Folder '{part}' not found in path '{path}'"))?; + + // Decrypt if it's an encrypted folder (transparent, idempotent) + if item.is_encrypted_folder() { + item.decrypt_folder(domain_manager)?; + } + + // Navigate into the folder + current = item + .get_folder_mut() + .ok_or_else(|| anyhow::anyhow!("'{part}' is not a folder"))?; } - Some(current) + Ok(current) + } + + /// Get the encryption domain for a given path by traversing from root + /// Returns the encryption domain of the deepest encrypted folder in the path, + /// or `MASTER_DOMAIN_ID` if no encrypted folders are found + pub fn get_encryption_domain_for_path(&self, path: &str) -> u32 { + if path == "/" || path.is_empty() { + return crate::MASTER_DOMAIN_ID; + } + + let parts: Vec<&str> = path.trim_matches('/').split('/').collect(); + let mut current = &self.root; + let mut domain = crate::MASTER_DOMAIN_ID; + + for part in parts { + if let Some(item) = current.items.get(part) { + // Update domain if this folder has an encryption domain + if let Some(item_domain) = item.encryption_domain() { + domain = item_domain; + } + + // Try to get the folder for next iteration + if let Some(folder) = item.get_folder() { + current = folder; + } else { + // Can't traverse further, return current domain + break; + } + } else { + // Path doesn't exist, return current domain + break; + } + } + + domain + } + + /// Recursively re-encrypt all unlocked encrypted folders before saving + /// This ensures that any modifications made to encrypted folders are persisted + pub fn prepare_for_save(&mut self, domain_manager: &EncryptionDomainManager) -> Result<()> { + Self::reencrypt_folders_recursive(&mut self.root, domain_manager) + } + + /// Helper function to recursively re-encrypt encrypted folders + fn reencrypt_folders_recursive( + folder: &mut Folder, + domain_manager: &EncryptionDomainManager, + ) -> Result<()> { + for item in folder.items.values_mut() { + match item { + FolderItem::EncryptedFolder { + decrypted_folder, .. + } => { + // If the folder is unlocked, recursively process its contents first + if let Some(decrypted) = decrypted_folder { + Self::reencrypt_folders_recursive(decrypted, domain_manager)?; + } + // Then re-encrypt this folder + item.reencrypt_folder(domain_manager)?; + } + FolderItem::Folder { + folder: subfolder, .. + } => { + // Recursively process regular subfolders + Self::reencrypt_folders_recursive(subfolder, domain_manager)?; + } + FolderItem::Secret { .. } => { + // Secrets don't need special handling + } + } + } + Ok(()) } /// Create a new folder at the given path - pub fn mkdir(&mut self, path: &str, folder_name: &str) -> Result<()> { - let parent = self - .get_folder_mut(path) - .ok_or_else(|| anyhow::anyhow!("Parent folder '{path}' not found"))?; + pub fn mkdir( + &mut self, + path: &str, + folder_name: &str, + domain_manager: &EncryptionDomainManager, + ) -> Result<()> { + let parent = self.get_folder_mut(path, domain_manager)?; if parent.items.contains_key(folder_name) { bail!("Item '{folder_name}' already exists"); } - let new_folder = Folder::new(folder_name.to_string(), parent.encryption_domain); + let new_folder = Folder::new(folder_name.to_string()); parent.items.insert( folder_name.to_string(), FolderItem::new_folder(folder_name.to_string(), new_folder), @@ -88,49 +195,54 @@ impl StorageV5 { } /// Store a secret value at a specific path - pub fn put_at_path(&mut self, path: &str, key: String, value: EncryptedValue, timestamp: u64) { - if let Some(folder) = self.get_folder_mut(path) { - let entry = SecretEntry::new(value, timestamp); + pub fn put_at_path( + &mut self, + path: &str, + key: String, + value: &str, + timestamp: u64, + encryption_domain: u32, + domain_manager: &EncryptionDomainManager, + ) -> Result<()> { + let folder = self.get_folder_mut(path, domain_manager)?; + let encrypted_value = EncryptedValue::from_ciphertext( + domain_manager + .get_cypher(encryption_domain) + .ok_or_else(|| anyhow!("target domain is locked"))? + .encrypt(value.as_bytes())?, + ); + let entry = SecretEntry::new(encrypted_value, timestamp); - folder - .items - .entry(key.clone()) - .and_modify(|item| { - if let Some(entries) = item.get_entries_mut() { - entries.push(entry.clone()); - } - }) - .or_insert_with(|| FolderItem::new_secret(key, vec![entry], 0)); - } - } + folder + .items + .entry(key.clone()) + .and_modify(|item| { + if let Some(entries) = item.get_entries_mut() { + entries.push(entry.clone()); + } + }) + .or_insert_with(|| FolderItem::new_secret(key, vec![entry], encryption_domain)); - /// Returns an iterator over key-value pairs matching the given regex pattern in root folder. - /// - /// # Sorting - /// - Keys are returned in sorted order (guaranteed by `BTreeMap`) - /// - Returns the latest value for each key (entries sorted by timestamp) - /// - /// Returns (`full_path`, key, value) tuples where `full_path` is like "/work/personal" - pub fn get( - &self, - pattern: &str, - ) -> Result + '_> { - self.get_at_path("/", pattern, false) + Ok(()) } /// Returns an iterator over key-value pairs matching pattern at a specific path. /// If recursive is true, searches through all subfolders. /// Returns (`full_path`, key, value) tuples where `full_path` is like "/work/personal" - pub fn get_at_path( - &self, + pub fn get_at_path<'a>( + &'a mut self, path: &str, pattern: &str, recursive: bool, - ) -> Result + '_> { + domain_manager: &'a EncryptionDomainManager, + ) -> Result + 'a> { let re = Regex::new(&format!("^{pattern}$"))?; - let folder = self - .get_folder(path) - .ok_or_else(|| anyhow::anyhow!("Folder '{path}' not found"))?; + let folder = self.get_folder_mut(path, domain_manager)?; + + // Decrypt the entire tree upfront if recursive (avoids borrow checker issues during iteration) + if recursive { + Self::decrypt_tree_recursive(folder, domain_manager); + } let normalized_path = if path == "/" || path.is_empty() { String::from("/") @@ -146,59 +258,58 @@ impl StorageV5 { )) } - /// Returns an iterator over all historical values for a given key in root folder. - /// - /// # Sorting - /// Entries are returned in chronological order (oldest to newest). - pub fn history(&self, key: &str) -> Option + '_> { - self.history_at_path("/", key) - } - /// Returns an iterator over all historical values for a given key at a specific path. /// Returns None for folders (regular or encrypted). pub fn history_at_path( - &self, + &mut self, path: &str, key: &str, + domain_manager: &EncryptionDomainManager, ) -> Option + '_> { - self.get_folder(path) + self.get_folder_mut(path, domain_manager) + .ok() .and_then(|folder| folder.get_item(key)) .and_then(|item| item.get_entries()) .map(|entries| entries.iter()) } - /// Delete a key and all its history from root folder - pub fn delete(&mut self, key: &str) -> bool { - self.delete_at_path("/", key) - } - /// Delete an item (secret, folder, or encrypted folder) from a specific path - pub fn delete_at_path(&mut self, path: &str, key: &str) -> bool { - self.get_folder_mut(path) + pub fn delete_at_path( + &mut self, + path: &str, + key: &str, + domain_manager: &EncryptionDomainManager, + ) -> bool { + self.get_folder_mut(path, domain_manager) + .ok() .and_then(|folder| folder.items.remove(key)) .is_some() } /// Move an item (secret, folder, or encrypted folder) from one location to another /// Works like shell `mv` - handles both files and directories uniformly - /// `source_folder`: folder containing the item - /// `item_name`: name of the item to move - /// `dest_folder`: destination folder - /// `dest_name`: optional new name (if None, keeps same name) + /// + /// # Arguments + /// * `source_folder` - Path to folder containing the item + /// * `item_name` - Name of the item to move + /// * `dest_folder` - Destination folder path + /// * `dest_name` - Optional new name (if None, keeps same name) + /// * `target_domain_id` - If Some, re-encrypt item to this domain + /// * `domain_manager` - Domain manager for transparent decryption and re-encryption pub fn move_item( &mut self, source_folder: &str, item_name: &str, dest_folder: &str, dest_name: Option<&str>, + target_domain_id: Option, + domain_manager: &EncryptionDomainManager, ) -> Result<()> { let final_name = dest_name.unwrap_or(item_name); // Check destination folder exists and no collision (must do before removing from source) { - let dest = self - .get_folder(dest_folder) - .ok_or_else(|| anyhow::anyhow!("Destination folder '{dest_folder}' not found"))?; + let dest = self.get_folder_mut(dest_folder, domain_manager)?; if dest.items.contains_key(final_name) { bail!("Item '{final_name}' already exists at destination '{dest_folder}'"); @@ -206,12 +317,12 @@ impl StorageV5 { } // Remove from source - let mut item = self - .get_folder_mut(source_folder) - .ok_or_else(|| anyhow::anyhow!("Source folder '{source_folder}' not found"))? - .items - .remove(item_name) - .ok_or_else(|| anyhow::anyhow!("Item '{item_name}' not found in '{source_folder}'"))?; + let mut item = { + let source = self.get_folder_mut(source_folder, domain_manager)?; + source.items.remove(item_name).ok_or_else(|| { + anyhow::anyhow!("Item '{item_name}' not found in '{source_folder}'") + })? + }; // Update the item's name if renaming if final_name != item_name { @@ -224,37 +335,128 @@ impl StorageV5 { } } + // Re-encrypt to target domain if requested + if let Some(domain_id) = target_domain_id { + Self::reencrypt_item(&mut item, domain_id, domain_manager)?; + } + // Insert into dest - self.get_folder_mut(dest_folder) - .expect("dest folder exists") - .items - .insert(final_name.to_string(), item); + let dest = self.get_folder_mut(dest_folder, domain_manager)?; + dest.items.insert(final_name.to_string(), item); Ok(()) } - /// Returns an iterator over all keys matching the given regex pattern in root folder. + /// Re-encrypt an item from its current domain to a target domain /// - /// # Sorting - /// Keys are returned in sorted order (guaranteed by `BTreeMap`). - /// Returns (`folder_path`, key) tuples where `folder_path` is like "/work/personal" - pub fn search(&self, pattern: &str) -> Result + '_> { - self.search_at_path("/", pattern, false) + /// # Arguments + /// * `item` - The item to re-encrypt (Secret, Folder, or `EncryptedFolder`) + /// * `target_domain_id` - The domain ID to re-encrypt to + /// * `domain_manager` - Manager containing unlocked domain cyphers + /// + /// # Errors + /// * If the source domain (current item's domain) is not unlocked + /// * If the target domain is not unlocked + /// * If encryption/decryption fails + fn reencrypt_item( + item: &mut FolderItem, + target_domain_id: u32, + domain_manager: &EncryptionDomainManager, + ) -> Result<()> { + use zeroize::Zeroize; + + match item { + FolderItem::Secret { + entries, + encryption_domain: source_domain_id, + .. + } => { + // Verify target domain is unlocked + let target_cypher = + domain_manager.get_cypher(target_domain_id).ok_or_else(|| { + anyhow::anyhow!("Target domain {target_domain_id} is not unlocked") + })?; + + // Get source cypher for decryption + let source_cypher = + domain_manager + .get_cypher(*source_domain_id) + .ok_or_else(|| { + anyhow::anyhow!("Source domain {source_domain_id} is not unlocked") + })?; + + // Re-encrypt all entries + for entry in entries { + let mut plaintext = source_cypher.decrypt(entry.value.as_bytes())?; + let ciphertext = target_cypher.encrypt(&plaintext)?; + plaintext.zeroize(); // Clear decrypted content from memory + entry.value = EncryptedValue::from_ciphertext(ciphertext); + } + + // Update domain + *source_domain_id = target_domain_id; + Ok(()) + } + FolderItem::Folder { .. } | FolderItem::EncryptedFolder { .. } => { + // Delegate to encrypt_folder for both folder types + item.encrypt_folder(target_domain_id, domain_manager) + } + } + } + + /// Lock an item to a specific encryption domain (re-encrypts the item) + /// + /// # Arguments + /// * `item_path` - Full path to the item (e.g., "/`work/api_key`" or "/personal/passwords") + /// * `target_domain_id` - The domain ID to lock the item to + /// * `domain_manager` - Manager containing unlocked domain cyphers + /// + /// # Errors + /// * If the item doesn't exist + /// * If the target domain is not unlocked + /// * If the source domain (for re-encryption) is not unlocked + /// * If encryption/decryption fails + pub fn lock_item( + &mut self, + item_path: &str, + target_domain_id: u32, + domain_manager: &EncryptionDomainManager, + ) -> Result<()> { + // Parse the path using path_utils (from root) + let (folder_path, item_name) = crate::parse_key_path("/", item_path); + + // Get the folder containing the item (with transparent decryption) + let folder = self.get_folder_mut(&folder_path, domain_manager)?; + + // Get the item + let item = folder + .items + .get_mut(item_name) + .ok_or_else(|| anyhow::anyhow!("Item '{item_name}' not found in '{folder_path}'"))?; + + // Re-encrypt the item + Self::reencrypt_item(item, target_domain_id, domain_manager)?; + + Ok(()) } /// Returns an iterator over all keys matching pattern at a specific path. /// If recursive is true, searches through all subfolders. /// Returns (`folder_path`, key) tuples where `folder_path` is like "/work/personal" - pub fn search_at_path( - &self, + pub fn search_at_path<'a>( + &'a mut self, path: &str, pattern: &str, recursive: bool, - ) -> Result + '_> { + domain_manager: &'a EncryptionDomainManager, + ) -> Result + 'a> { let re = Regex::new(pattern)?; - let folder = self - .get_folder(path) - .ok_or_else(|| anyhow::anyhow!("Folder '{path}' not found"))?; + let folder = self.get_folder_mut(path, domain_manager)?; + + // Decrypt the entire tree upfront if recursive (avoids borrow checker issues during iteration) + if recursive { + Self::decrypt_tree_recursive(folder, domain_manager); + } let normalized_path = if path == "/" || path.is_empty() { String::from("/") @@ -270,6 +472,35 @@ impl StorageV5 { )) } + // ======================================================================== + // Internal Helpers + // ======================================================================== + + /// Recursively decrypt all unlocked encrypted folders in the tree + /// This is called before creating iterators to avoid borrow checker issues + fn decrypt_tree_recursive(folder: &mut Folder, domain_manager: &EncryptionDomainManager) { + for item in folder.items.values_mut() { + match item { + FolderItem::EncryptedFolder { .. } => { + // Try to decrypt (idempotent, fails silently if domain locked) + let _ = item.decrypt_folder(domain_manager); + + // If successfully decrypted, recurse into it + if let Some(subfolder) = item.get_folder_mut() { + Self::decrypt_tree_recursive(subfolder, domain_manager); + } + } + FolderItem::Folder { folder, .. } => { + // Regular folder - just recurse + Self::decrypt_tree_recursive(folder, domain_manager); + } + FolderItem::Secret { .. } => { + // Secrets don't contain subfolders + } + } + } + } + // ======================================================================== // Encryption Domain Management // ======================================================================== @@ -322,12 +553,13 @@ impl StorageV5 { } // ============================================================================ -// Zero-copy iterators through Folder structure +// Iterators through Folder structure (tree is pre-decrypted before iteration) // ============================================================================ type ItemsIterator<'a> = std::collections::btree_map::Iter<'a, String, FolderItem>; /// Iterator over secrets in a folder and optionally its subfolders +/// Note: Encrypted folders must be decrypted before iteration (done automatically in `get_at_path`) pub struct RecursiveSecretIterator<'a> { // Single stack of (current_path, items_iter) for depth-first traversal stack: Vec<(String, ItemsIterator<'a>)>, @@ -374,6 +606,7 @@ impl<'a> Iterator for RecursiveSecretIterator<'a> { } // If recursive and this is a navigable folder, descend into it + // (Tree is already decrypted, so we only traverse navigable folders) if self.recursive && let Some(subfolder) = item.get_folder() { @@ -414,6 +647,7 @@ impl<'a> Iterator for RecursiveSecretIterator<'a> { } /// Iterator over keys in a folder and optionally its subfolders +/// Note: Encrypted folders must be decrypted before iteration (done automatically in `search_at_path`) pub struct RecursiveKeyIterator<'a> { // Single stack of (current_path, items_iter) for depth-first traversal stack: Vec<(String, ItemsIterator<'a>)>, @@ -458,6 +692,7 @@ impl<'a> Iterator for RecursiveKeyIterator<'a> { } // If recursive and this is a navigable folder, descend into it + // (Tree is already decrypted, so we only traverse navigable folders) if self.recursive && let Some(subfolder) = item.get_folder() { @@ -579,16 +814,16 @@ impl FolderItem { } /// Get the encryption domain for this item - /// Returns folder's default domain for regular folders - pub fn encryption_domain(&self) -> u32 { + /// Returns None for regular (unencrypted) folders + pub const fn encryption_domain(&self) -> Option { match self { Self::Secret { encryption_domain, .. } | Self::EncryptedFolder { encryption_domain, .. - } => *encryption_domain, - Self::Folder { folder, .. } => folder.encryption_domain, + } => Some(*encryption_domain), + Self::Folder { .. } => None, } } @@ -672,18 +907,6 @@ impl FolderItem { } } - /// Get the underlying Folder box (for moving folders around) - pub fn take_folder(self) -> Option> { - match self { - Self::Folder { folder, .. } - | Self::EncryptedFolder { - decrypted_folder: Some(folder), - .. - } => Some(folder), - _ => None, - } - } - // ========== Accessing Secret Data ========== /// Get secret entries (only for secrets, not folders) @@ -714,32 +937,136 @@ impl FolderItem { // ========== Encrypted Folder Operations ========== - /// Unlock an encrypted folder with the decrypted data - /// Returns error if not an encrypted folder or already unlocked - pub fn unlock(&mut self, decrypted_folder: Folder) -> Result<()> { + /// Encrypt a folder to a specific domain (converts Folder → `EncryptedFolder` or syncs changes) + /// + /// # Use cases + /// - CLI `lock ` command - encrypts folder to target domain + /// - Before save - syncs in-memory changes back to `encrypted_data` + /// + /// # Behavior + /// - Regular Folder: serialize, encrypt to target domain, convert to `EncryptedFolder` + /// - `EncryptedFolder` with `decrypted_folder` (unlocked): serialize from memory, re-encrypt, **keeps folder unlocked** + /// - `EncryptedFolder` without `decrypted_folder` (locked): decrypt from current domain, re-encrypt to target domain + /// + /// # Errors + /// * If source or target domain is not unlocked + /// * If serialization or encryption fails + pub fn encrypt_folder( + &mut self, + target_domain_id: u32, + domain_manager: &EncryptionDomainManager, + ) -> Result<()> { + + + match self { + Self::Folder { folder, name } => { + // Convert regular folder to encrypted folder + let cypher = domain_manager + .get_cypher(target_domain_id) + .ok_or_else(|| anyhow::anyhow!("Domain {target_domain_id} is not unlocked"))?; + + let folder_bytes = serialize_folder_to_vec(folder)?; + let encrypted_data = cypher.encrypt(&folder_bytes)?; + + *self = Self::new_encrypted_folder(name.clone(), encrypted_data, target_domain_id); + Ok(()) + } + Self::EncryptedFolder { + encrypted_data, + encryption_domain: source_domain_id, + decrypted_folder, + .. + } => { + let target_cypher = + domain_manager.get_cypher(target_domain_id).ok_or_else(|| { + anyhow::anyhow!("Target domain {target_domain_id} is not unlocked") + })?; + + let folder_bytes = if let Some(folder) = decrypted_folder.as_ref() { + // Use in-memory version (has latest changes) + serialize_folder_to_vec(folder)? + } else { + // Decrypt from current domain first + let source_cypher = + domain_manager + .get_cypher(*source_domain_id) + .ok_or_else(|| { + anyhow::anyhow!("Source domain {source_domain_id} is not unlocked") + })?; + source_cypher.decrypt(encrypted_data)? + }; + + // Re-encrypt to target domain + let new_encrypted_data = target_cypher.encrypt(&folder_bytes)?; + + *encrypted_data = new_encrypted_data; + *source_domain_id = target_domain_id; + Ok(()) + } + Self::Secret { .. } => { + bail!("Bug! should only be called for folders") + } + } + } + + /// Re-encrypt an encrypted folder with its own domain's cypher + /// This is used before saving to persist any changes made to unlocked folders + /// Only re-encrypts if the folder is unlocked (has `decrypted_folder` populated) + pub fn reencrypt_folder(&mut self, domain_manager: &EncryptionDomainManager) -> Result<()> { match self { Self::EncryptedFolder { - decrypted_folder: df, + encryption_domain, + decrypted_folder, .. } => { - if df.is_some() { - bail!("Folder already unlocked"); + // Only re-encrypt if the folder is unlocked (has been decrypted/modified) + if decrypted_folder.is_some() { + let domain_id = *encryption_domain; + self.encrypt_folder(domain_id, domain_manager) + } else { + // Folder is locked - no changes to persist, skip re-encryption + Ok(()) } - *df = Some(Box::new(decrypted_folder)); + } + Self::Folder { .. } | Self::Secret { .. } => { + // Regular folders and secrets don't need re-encryption Ok(()) } - _ => bail!("Not an encrypted folder"), } } - /// Lock an encrypted folder (clear the decrypted data) - pub fn lock(&mut self) -> Result<()> { + /// Decrypt an encrypted folder for access (lazy, idempotent) + /// Populates `decrypted_folder` if not already populated + /// + /// # Errors + /// * If not an encrypted folder + /// * If the domain is not unlocked + /// * If decryption or deserialization fails + pub fn decrypt_folder(&mut self, domain_manager: &EncryptionDomainManager) -> Result<()> { match self { Self::EncryptedFolder { - decrypted_folder: df, + encrypted_data, + encryption_domain, + decrypted_folder, .. } => { - *df = None; + // Idempotent - if already decrypted, do nothing + if decrypted_folder.is_some() { + return Ok(()); + } + + // Get the domain's cypher + let cypher = domain_manager + .get_cypher(*encryption_domain) + .ok_or_else(|| anyhow::anyhow!("Domain {encryption_domain} is not unlocked"))?; + + // Decrypt the folder data + let folder_bytes = cypher.decrypt(encrypted_data)?; + let folder = deserialize_folder_from_slice(&folder_bytes)?; + + // Populate the decrypted_folder field + *decrypted_folder = Some(Box::new(folder)); + Ok(()) } _ => bail!("Not an encrypted folder"), @@ -892,30 +1219,23 @@ pub struct Folder { /// Folder name (empty string for root) pub name: String, - /// Default encryption domain for new items created in this folder - /// - 0 = default domain (master key) - /// - N > 0 = custom domain (requires separate password) - pub encryption_domain: u32, - /// All items in this folder (secrets AND subfolders) pub items: BTreeMap, } impl Folder { - /// Create a new root folder (domain 0) + /// Create a new root folder pub const fn new_root() -> Self { Self { name: String::new(), - encryption_domain: 0, items: BTreeMap::new(), } } /// Create a new named folder - pub const fn new(name: String, encryption_domain: u32) -> Self { + pub const fn new(name: String) -> Self { Self { name, - encryption_domain, items: BTreeMap::new(), } } @@ -1053,10 +1373,10 @@ pub fn deserialize_storage_v5(reader: &mut R) -> Result { // Convenience functions for Vec /// Serialize V5 storage to bytes (convenience wrapper) -pub fn serialize_storage_v5_to_vec(storage: &StorageV5) -> Result> { +pub fn serialize_storage_v5_to_vec(storage: &StorageV5) -> Result>> { let mut buffer = Vec::new(); serialize_storage_v5(&mut buffer, storage)?; - Ok(buffer) + Ok(Zeroizing::new(buffer)) } /// Deserialize V5 storage from bytes (convenience wrapper) @@ -1065,6 +1385,30 @@ pub fn deserialize_storage_v5_from_slice(data: &[u8]) -> Result { deserialize_storage_v5(&mut cursor) } +// ============================================================================ +// Folder Serialization Helpers +// ============================================================================ + +/// Serialize a Folder to bytes using bincode +/// Used for encrypting folders into `EncryptedFolder` variant +pub fn serialize_folder_to_vec(folder: &Folder) -> Result>> { + let config = config::standard(); + let bytes = bincode::encode_to_vec(folder, config)?; + Ok(Zeroizing::new(bytes)) +} + +/// Deserialize a Folder from bytes using bincode +/// Used for decrypting `EncryptedFolder` variant +pub fn deserialize_folder_from_slice(data: &[u8]) -> Result { + let config = config::standard(); + let (folder, _len) = bincode::decode_from_slice(data, config)?; + Ok(folder) +} + +// ============================================================================ +// Internal Helpers +// ============================================================================ + /// Sort all secret entries by timestamp (recursive) fn sort_folder_entries(folder: &mut Folder) { for item in folder.items.values_mut() { @@ -1096,16 +1440,16 @@ use super::value::ValueEntry; /// Migrate V4 storage to V5 format /// All secrets go into root folder with default encryption domain (0) pub fn migrate_v4_to_v5(v4: StorageV4) -> StorageV5 { - use crate::{MASTER_DOMAIN_ID, MASTER_DOMAIN_NAME}; - let mut root = Folder::new_root(); // Migrate flat structure to root folder for (key, entries) in v4.data { let secrets: Vec = entries.into_iter().map(value_entry_to_secret).collect(); - root.items - .insert(key.clone(), FolderItem::new_secret(key, secrets, 0)); + root.items.insert( + key.clone(), + FolderItem::new_secret(key, secrets, MASTER_DOMAIN_ID), + ); } // Initialize encryption domains with master domain @@ -1130,6 +1474,20 @@ fn value_entry_to_secret(entry: ValueEntry) -> SecretEntry { #[cfg(test)] mod tests { use super::*; + use crate::{Argon2Params, Cypher, CypherVersion, EncryptionKey}; + + /// Test helper: Create a domain manager with master key + fn test_domain_manager() -> EncryptionDomainManager { + // Use insecure params for testing (faster) + let key = EncryptionKey::from_password_with_params( + CypherVersion::default(), + "test_password", + &Argon2Params::insecure(), + ) + .expect("Failed to create key"); + let master_cypher = Cypher::new(key); + EncryptionDomainManager::new(master_cypher) + } #[test] fn test_empty_storage_v5() { @@ -1178,7 +1536,7 @@ mod tests { let mut storage = StorageV5::new(); // Add a subfolder - let mut subfolder = Folder::new("work".to_string(), 0); + let mut subfolder = Folder::new("work".to_string()); let api_key_value = EncryptedValue::from_ciphertext(b"secret123".to_vec()); subfolder.items.insert( "api_key".to_string(), @@ -1228,30 +1586,43 @@ mod tests { let item = &deserialized.root.items["secret_folder"]; assert!(item.is_encrypted_folder()); assert!(item.is_locked()); - assert_eq!(item.encryption_domain(), 1); + assert_eq!(item.encryption_domain(), Some(1)); } #[test] fn test_v4_migration() { let mut v4 = StorageV4::new(); - v4.put("key1".to_string(), "value1".into()); - v4.put("key2".to_string(), "value2".into()); + v4.put( + "key1".to_string(), + EncryptedValue::from_ciphertext("value1".into()), + ); + v4.put( + "key2".to_string(), + EncryptedValue::from_ciphertext("value2".into()), + ); let v5 = migrate_v4_to_v5(v4); assert_eq!(v5.root.items.len(), 2); assert!(v5.root.items.contains_key("key1")); assert!(v5.root.items.contains_key("key2")); - assert_eq!(v5.root.encryption_domain, 0); + // All migrated secrets should use master domain (0) + assert_eq!(v5.root.items["key1"].encryption_domain(), Some(0)); + assert_eq!(v5.root.items["key2"].encryption_domain(), Some(0)); } #[test] fn test_put_and_get() { let mut storage = StorageV5::new(); - let test_value = EncryptedValue::from_ciphertext(b"test_value".to_vec()); - storage.put_at_path("/", "test_key".to_string(), test_value, 0); - - let results: Vec<_> = storage.get("test_key").unwrap().collect(); + let dm = test_domain_manager(); + storage + .put_at_path("/", "test_key".to_string(), "test_value", 0, 0, &dm) + .unwrap(); + + let results: Vec<_> = storage + .get_at_path("/", "test_key", false, &dm) + .unwrap() + .collect(); assert_eq!(results.len(), 1); assert_eq!(results[0].0, "/"); // full_path assert_eq!(results[0].1, "test_key"); // key name @@ -1261,26 +1632,21 @@ mod tests { #[test] fn test_get_with_pattern() { let mut storage = StorageV5::new(); - storage.put_at_path( - "/", - "key1".to_string(), - EncryptedValue::from_ciphertext(b"value1".to_vec()), - 0, - ); - storage.put_at_path( - "/", - "key2".to_string(), - EncryptedValue::from_ciphertext(b"value2".to_vec()), - 0, - ); - storage.put_at_path( - "/", - "other".to_string(), - EncryptedValue::from_ciphertext(b"value3".to_vec()), - 0, - ); - - let results: Vec<_> = storage.get("key.*").unwrap().collect(); + let dm = test_domain_manager(); + storage + .put_at_path("/", "key1".to_string(), "value1", 0, 0, &dm) + .unwrap(); + storage + .put_at_path("/", "key2".to_string(), "value2", 0, 0, &dm) + .unwrap(); + storage + .put_at_path("/", "other".to_string(), "value3", 0, 0, &dm) + .unwrap(); + + let results: Vec<_> = storage + .get_at_path("/", "key.*", false, &dm) + .unwrap() + .collect(); assert_eq!(results.len(), 2); assert!( results @@ -1297,26 +1663,21 @@ mod tests { #[test] fn test_search() { let mut storage = StorageV5::new(); - storage.put_at_path( - "/", - "alpha".to_string(), - EncryptedValue::from_ciphertext(b"value1".to_vec()), - 0, - ); - storage.put_at_path( - "/", - "beta".to_string(), - EncryptedValue::from_ciphertext(b"value2".to_vec()), - 0, - ); - storage.put_at_path( - "/", - "gamma".to_string(), - EncryptedValue::from_ciphertext(b"value3".to_vec()), - 0, - ); - - let results: Vec<_> = storage.search(".*a.*").unwrap().collect(); + let dm = test_domain_manager(); + storage + .put_at_path("/", "alpha".to_string(), "value1", 0, 0, &dm) + .unwrap(); + storage + .put_at_path("/", "beta".to_string(), "value2", 0, 0, &dm) + .unwrap(); + storage + .put_at_path("/", "gamma".to_string(), "value3", 0, 0, &dm) + .unwrap(); + + let results: Vec<_> = storage + .search_at_path("/", ".*a.*", true, &dm) + .unwrap() + .collect(); assert_eq!(results.len(), 2); // alpha, gamma assert!(results.iter().any(|(path, k)| path == "/" && *k == "alpha")); assert!(results.iter().any(|(path, k)| path == "/" && *k == "gamma")); @@ -1325,26 +1686,18 @@ mod tests { #[test] fn test_history() { let mut storage = StorageV5::new(); - storage.put_at_path( - "/", - "key".to_string(), - EncryptedValue::from_ciphertext(b"value1".to_vec()), - 100, - ); - storage.put_at_path( - "/", - "key".to_string(), - EncryptedValue::from_ciphertext(b"value2".to_vec()), - 200, - ); - storage.put_at_path( - "/", - "key".to_string(), - EncryptedValue::from_ciphertext(b"value3".to_vec()), - 300, - ); - - let history: Vec<_> = storage.history("key").unwrap().collect(); + let dm = test_domain_manager(); + storage + .put_at_path("/", "key".to_string(), "value1", 100, 0, &dm) + .unwrap(); + storage + .put_at_path("/", "key".to_string(), "value2", 200, 0, &dm) + .unwrap(); + storage + .put_at_path("/", "key".to_string(), "value3", 300, 0, &dm) + .unwrap(); + + let history: Vec<_> = storage.history_at_path("/", "key", &dm).unwrap().collect(); assert_eq!(history.len(), 3); assert_eq!(history[0].timestamp, 100); assert_eq!(history[1].timestamp, 200); @@ -1354,24 +1707,19 @@ mod tests { #[test] fn test_delete() { let mut storage = StorageV5::new(); - storage.put_at_path( - "/", - "key1".to_string(), - EncryptedValue::from_ciphertext(b"value1".to_vec()), - 0, - ); - storage.put_at_path( - "/", - "key2".to_string(), - EncryptedValue::from_ciphertext(b"value2".to_vec()), - 0, - ); - - assert!(storage.delete("key1")); - assert!(!storage.delete("key1")); // Already deleted - assert!(storage.delete("key2")); - - let results: Vec<_> = storage.get(".*").unwrap().collect(); + let dm = test_domain_manager(); + storage + .put_at_path("/", "key1".to_string(), "value1", 0, 0, &dm) + .unwrap(); + storage + .put_at_path("/", "key2".to_string(), "value2", 0, 0, &dm) + .unwrap(); + + assert!(storage.delete_at_path("/", "key1", &dm)); + assert!(!storage.delete_at_path("/", "key1", &dm)); // Already deleted + assert!(storage.delete_at_path("/", "key2", &dm)); + + let results: Vec<_> = storage.get_at_path("/", ".*", true, &dm).unwrap().collect(); assert_eq!(results.len(), 0); } } diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index da49526..8b981ca 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -1,5 +1,4 @@ -use rcypher::save_storage_v5; -use rcypher::{Cypher, CypherVersion, EncryptedValue, EncryptionKey, StorageV5}; +use rcypher::{Cypher, CypherVersion, EncryptionKey, StorageV4, save_storage_v4}; use std::fs; use std::path::Path; use std::path::PathBuf; @@ -369,21 +368,11 @@ fn test_upgrade_storage() { EncryptionKey::from_password(CypherVersion::LegacyWithoutKdf, "test_password").unwrap(); let legacy_cypher = Cypher::new(legacy_key); - let mut storage = StorageV5::new(); - storage.put_at_path( - "/", - "key1".to_string(), - EncryptedValue::encrypt(&legacy_cypher, "value1").unwrap(), - 0, - ); - storage.put_at_path( - "/", - "key2".to_string(), - EncryptedValue::encrypt(&legacy_cypher, "value2").unwrap(), - 0, - ); + let mut storage = StorageV4::new(); + storage.put("key1".to_string(), "value1".into()); + storage.put("key2".to_string(), "value2".into()); - save_storage_v5(&legacy_cypher, &storage, &storage_path).unwrap(); + save_storage_v4(&legacy_cypher, &storage, &storage_path).unwrap(); // Run upgrade command let mut cmd = Command::new(cargo::cargo_bin!("rcypher")); @@ -397,6 +386,7 @@ fn test_upgrade_storage() { .output() .unwrap(); + println!("{:?}", String::from_utf8(output.stderr)); assert!(output.status.success()); // Verify the file was upgraded by trying to read it with new format diff --git a/tests/storage_v5_tests.rs b/tests/storage_v5_tests.rs index 48e01eb..c1f9af2 100644 --- a/tests/storage_v5_tests.rs +++ b/tests/storage_v5_tests.rs @@ -10,6 +10,18 @@ fn temp_test_file() -> (TempDir, PathBuf) { (dir, path) } +// Helper to create a test domain manager with master key +fn test_domain_manager() -> EncryptionDomainManager { + let key = EncryptionKey::from_password_with_params( + CypherVersion::default(), + "test_password", + &Argon2Params::insecure(), + ) + .expect("Failed to create key"); + let cypher = Cypher::new(key); + EncryptionDomainManager::new(cypher) +} + #[test] fn test_storage_new() { let storage = StorageV5::new(); @@ -19,51 +31,117 @@ fn test_storage_new() { #[test] fn test_storage_put_get() { let mut storage = StorageV5::new(); - storage.put_at_path("/", "key1".to_string(), "value1".into(), 0); - storage.put_at_path("/", "key2".to_string(), "value2".into(), 0); + let dm = test_domain_manager(); + storage + .put_at_path("/", "key1".to_string(), "value1", 0, MASTER_DOMAIN_ID, &dm) + .unwrap(); + storage + .put_at_path("/", "key2".to_string(), "value2", 0, MASTER_DOMAIN_ID, &dm) + .unwrap(); - let results: Vec<_> = storage.get("key1").unwrap().collect(); + let results: Vec<_> = storage + .get_at_path("/", "key1", false, &dm) + .unwrap() + .collect(); assert_eq!(results.len(), 1); assert_eq!(results[0].0, "/"); assert_eq!(results[0].1, "key1"); - assert_eq!(results[0].2.as_bytes(), "value1".as_bytes()); + assert_eq!( + results[0] + .2 + .decrypt(dm.get_master_cypher()) + .unwrap() + .as_str(), + "value1" + ); } #[test] fn test_storage_put_multiple_values() { let mut storage = StorageV5::new(); - storage.put_at_path("/", "key1".to_string(), "value1".into(), 0); - storage.put_at_path("/", "key1".to_string(), "value2".into(), 0); - storage.put_at_path("/", "key1".to_string(), "value3".into(), 0); + let dm = test_domain_manager(); + storage + .put_at_path("/", "key1".to_string(), "value1", 0, MASTER_DOMAIN_ID, &dm) + .unwrap(); + storage + .put_at_path("/", "key1".to_string(), "value2", 0, MASTER_DOMAIN_ID, &dm) + .unwrap(); + storage + .put_at_path("/", "key1".to_string(), "value3", 0, MASTER_DOMAIN_ID, &dm) + .unwrap(); // get should return the latest value - let results: Vec<_> = storage.get("key1").unwrap().collect(); + let results: Vec<_> = storage + .get_at_path("/", "key1", false, &dm) + .unwrap() + .collect(); assert_eq!(results.len(), 1); assert_eq!(results[0].0, "/"); assert_eq!(results[0].1, "key1"); - assert_eq!(results[0].2.as_bytes(), "value3".as_bytes()); + assert_eq!( + results[0] + .2 + .decrypt(dm.get_master_cypher()) + .unwrap() + .as_str(), + "value3" + ); // history should return all values - let history: Vec<_> = storage.history("key1").unwrap().collect(); + let history: Vec<_> = storage.history_at_path("/", "key1", &dm).unwrap().collect(); assert_eq!(history.len(), 3); - assert_eq!(history[0].encrypted_value().as_bytes(), "value1".as_bytes()); - assert_eq!(history[1].encrypted_value().as_bytes(), "value2".as_bytes()); - assert_eq!(history[2].encrypted_value().as_bytes(), "value3".as_bytes()); + assert_eq!( + history[0] + .encrypted_value() + .decrypt(dm.get_master_cypher()) + .unwrap() + .as_str(), + "value1" + ); + assert_eq!( + history[1] + .encrypted_value() + .decrypt(dm.get_master_cypher()) + .unwrap() + .as_str(), + "value2" + ); + assert_eq!( + history[2] + .encrypted_value() + .decrypt(dm.get_master_cypher()) + .unwrap() + .as_str(), + "value3" + ); } #[test] fn test_storage_get_with_regex() { let mut storage = StorageV5::new(); - storage.put_at_path("/", "test1".to_string(), "value1".into(), 0); - storage.put_at_path("/", "test2".to_string(), "value2".into(), 0); - storage.put_at_path("/", "prod1".to_string(), "value3".into(), 0); + let dm = test_domain_manager(); + storage + .put_at_path("/", "test1".to_string(), "value1", 0, MASTER_DOMAIN_ID, &dm) + .unwrap(); + storage + .put_at_path("/", "test2".to_string(), "value2", 0, MASTER_DOMAIN_ID, &dm) + .unwrap(); + storage + .put_at_path("/", "prod1".to_string(), "value3", 0, MASTER_DOMAIN_ID, &dm) + .unwrap(); // Match all test keys - let results: Vec<_> = storage.get("test.*").unwrap().collect(); + let results: Vec<_> = storage + .get_at_path("/", "test.*", false, &dm) + .unwrap() + .collect(); assert_eq!(results.len(), 2); // Match specific key - let results: Vec<_> = storage.get("test1").unwrap().collect(); + let results: Vec<_> = storage + .get_at_path("/", "test1", false, &dm) + .unwrap() + .collect(); assert_eq!(results.len(), 1); assert_eq!(results[0].0, "/"); assert_eq!(results[0].1, "test1"); @@ -72,20 +150,57 @@ fn test_storage_get_with_regex() { #[test] fn test_storage_get_no_match() { let mut storage = StorageV5::new(); - storage.put_at_path("/", "key1".to_string(), "value1".into(), 0); + let dm = test_domain_manager(); + storage + .put_at_path("/", "key1".to_string(), "value1", 0, MASTER_DOMAIN_ID, &dm) + .unwrap(); - let results: Vec<_> = storage.get("nonexistent").unwrap().collect(); + let results: Vec<_> = storage + .get_at_path("/", "nonexistent", true, &dm) + .unwrap() + .collect(); assert_eq!(results.len(), 0); } #[test] fn test_storage_search() { let mut storage = StorageV5::new(); - storage.put_at_path("/", "user_alice".to_string(), "value1".into(), 0); - storage.put_at_path("/", "user_bob".to_string(), "value2".into(), 0); - storage.put_at_path("/", "admin_charlie".to_string(), "value3".into(), 0); + let dm = test_domain_manager(); + storage + .put_at_path( + "/", + "user_alice".to_string(), + "value1", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); + storage + .put_at_path( + "/", + "user_bob".to_string(), + "value2", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); + storage + .put_at_path( + "/", + "admin_charlie".to_string(), + "value3", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); - let keys: Vec<_> = storage.search("user_").unwrap().collect(); + let keys: Vec<_> = storage + .search_at_path("/", "user_", true, &dm) + .unwrap() + .collect(); assert_eq!(keys.len(), 2); assert!( keys.iter() @@ -93,36 +208,72 @@ fn test_storage_search() { ); assert!(keys.iter().any(|(path, k)| path == "/" && *k == "user_bob")); - let all_keys: Vec<_> = storage.search("").unwrap().collect(); + let all_keys: Vec<_> = storage + .search_at_path("/", "", true, &dm) + .unwrap() + .collect(); assert_eq!(all_keys.len(), 3); } #[test] fn test_storage_delete() { let mut storage = StorageV5::new(); - storage.put_at_path("/", "key1".to_string(), "value1".into(), 0); - storage.put_at_path("/", "key2".to_string(), "value2".into(), 0); + let dm = test_domain_manager(); + storage + .put_at_path("/", "key1".to_string(), "value1", 0, MASTER_DOMAIN_ID, &dm) + .unwrap(); + storage + .put_at_path("/", "key2".to_string(), "value2", 0, MASTER_DOMAIN_ID, &dm) + .unwrap(); - assert!(storage.delete("key1")); + assert!(storage.delete_at_path("/", "key1", &dm)); assert_eq!(storage.root.items.len(), 1); - assert!(!storage.delete("key1")); // Already deleted - assert!(!storage.delete("nonexistent")); + assert!(!storage.delete_at_path("/", "key1", &dm)); // Already deleted + assert!(!storage.delete_at_path("/", "nonexistent", &dm)); } #[test] fn test_storage_history() { use std::time::{SystemTime, UNIX_EPOCH}; let mut storage = StorageV5::new(); + let dm = test_domain_manager(); let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs(); - storage.put_at_path("/", "key1".to_string(), "v1".into(), timestamp); - storage.put_at_path("/", "key1".to_string(), "v2".into(), timestamp.add(1)); - storage.put_at_path("/", "key1".to_string(), "v3".into(), timestamp.add(2)); + storage + .put_at_path( + "/", + "key1".to_string(), + "v1", + timestamp, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); + storage + .put_at_path( + "/", + "key1".to_string(), + "v2", + timestamp.add(1), + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); + storage + .put_at_path( + "/", + "key1".to_string(), + "v3", + timestamp.add(2), + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); - let history: Vec<_> = storage.history("key1").unwrap().collect(); + let history: Vec<_> = storage.history_at_path("/", "key1", &dm).unwrap().collect(); assert_eq!(history.len(), 3); // Timestamps should be in ascending order @@ -130,9 +281,30 @@ fn test_storage_history() { assert!(history[1].timestamp + 1 == history[2].timestamp); // Values should be in order - assert_eq!(history[0].encrypted_value().as_bytes(), "v1".as_bytes()); - assert_eq!(history[1].encrypted_value().as_bytes(), "v2".as_bytes()); - assert_eq!(history[2].encrypted_value().as_bytes(), "v3".as_bytes()); + assert_eq!( + history[0] + .encrypted_value() + .decrypt(dm.get_master_cypher()) + .unwrap() + .as_str(), + "v1" + ); + assert_eq!( + history[1] + .encrypted_value() + .decrypt(dm.get_master_cypher()) + .unwrap() + .as_str(), + "v2" + ); + assert_eq!( + history[2] + .encrypted_value() + .decrypt(dm.get_master_cypher()) + .unwrap() + .as_str(), + "v3" + ); } #[test] @@ -147,22 +319,46 @@ fn test_serialize_deserialize_empty() { #[test] fn test_serialize_deserialize_single_entry() { let mut storage = StorageV5::new(); - storage.put_at_path("/", "key1".to_string(), "value1".into(), 0); + let dm = test_domain_manager(); + storage + .put_at_path("/", "key1".to_string(), "value1", 0, MASTER_DOMAIN_ID, &dm) + .unwrap(); let serialized = serialize_storage_v5_to_vec(&storage).unwrap(); let deserialized = deserialize_storage_v5_from_slice(&serialized).unwrap(); assert_eq!(deserialized.root.items.len(), 1); let entry = &deserialized.root.items["key1"].get_entries().unwrap()[0]; - assert_eq!(entry.encrypted_value().as_bytes(), "value1".as_bytes()); + assert_eq!( + entry + .encrypted_value() + .decrypt(dm.get_master_cypher()) + .unwrap() + .as_str(), + "value1" + ); } #[test] fn test_serialize_deserialize_multiple_entries() { let mut storage = StorageV5::new(); - storage.put_at_path("/", "key1".to_string(), "value1".into(), 0); - storage.put_at_path("/", "key2".to_string(), "value2".into(), 0); - storage.put_at_path("/", "key1".to_string(), "value1_updated".into(), 0); + let dm = test_domain_manager(); + storage + .put_at_path("/", "key1".to_string(), "value1", 0, MASTER_DOMAIN_ID, &dm) + .unwrap(); + storage + .put_at_path("/", "key2".to_string(), "value2", 0, MASTER_DOMAIN_ID, &dm) + .unwrap(); + storage + .put_at_path( + "/", + "key1".to_string(), + "value1_updated", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); let serialized = serialize_storage_v5_to_vec(&storage).unwrap(); let deserialized = deserialize_storage_v5_from_slice(&serialized).unwrap(); @@ -181,8 +377,20 @@ fn test_serialize_deserialize_multiple_entries() { #[test] fn test_serialize_deserialize_unicode() { let mut storage = StorageV5::new(); - storage.put_at_path("/", "ключ".to_string(), "значение".into(), 0); - storage.put_at_path("/", "🔑".to_string(), "🎁".into(), 0); + let dm = test_domain_manager(); + storage + .put_at_path( + "/", + "ключ".to_string(), + "значение", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); + storage + .put_at_path("/", "🔑".to_string(), "🎁", 0, MASTER_DOMAIN_ID, &dm) + .unwrap(); let serialized = serialize_storage_v5_to_vec(&storage).unwrap(); let deserialized = deserialize_storage_v5_from_slice(&serialized).unwrap(); @@ -191,14 +399,18 @@ fn test_serialize_deserialize_unicode() { assert_eq!( deserialized.root.items["ключ"].get_entries().unwrap()[0] .encrypted_value() - .as_bytes(), - "значение".as_bytes() + .decrypt(dm.get_master_cypher()) + .unwrap() + .as_str(), + "значение" ); assert_eq!( deserialized.root.items["🔑"].get_entries().unwrap()[0] .encrypted_value() - .as_bytes(), - "🎁".as_bytes() + .decrypt(dm.get_master_cypher()) + .unwrap() + .as_str(), + "🎁" ); } @@ -225,25 +437,34 @@ fn test_load_save_storage_v5() { ); let mut storage = StorageV5::new(); - storage.put_at_path("/", "key1".to_string(), "value1".into(), 0); - storage.put_at_path("/", "key2".to_string(), "value2".into(), 0); + let dm = EncryptionDomainManager::new(cypher); + storage + .put_at_path("/", "key1".to_string(), "value1", 0, MASTER_DOMAIN_ID, &dm) + .unwrap(); + storage + .put_at_path("/", "key2".to_string(), "value2", 0, MASTER_DOMAIN_ID, &dm) + .unwrap(); - save_storage_v5(&cypher, &storage, &path).unwrap(); + save_storage_v5(&dm, &mut storage, &path).unwrap(); assert!(path.exists()); - let loaded = load_storage_v5(&cypher, &path).unwrap(); + let loaded = load_storage_v5(dm.get_master_cypher(), &path).unwrap(); assert_eq!(loaded.root.items.len(), 2); assert_eq!( loaded.root.items["key1"].get_entries().unwrap()[0] .encrypted_value() - .as_bytes(), - "value1".as_bytes() + .decrypt(dm.get_master_cypher()) + .unwrap() + .as_str(), + "value1" ); assert_eq!( loaded.root.items["key2"].get_entries().unwrap()[0] .encrypted_value() - .as_bytes(), - "value2".as_bytes() + .decrypt(dm.get_master_cypher()) + .unwrap() + .as_str(), + "value2" ); } @@ -267,32 +488,46 @@ fn test_load_with_wrong_password() { EncryptionKey::for_file_with_params("test_password", &path, &Argon2Params::insecure()) .unwrap(), ); + let dm1 = EncryptionDomainManager::new(cypher1); let cypher2 = Cypher::new( EncryptionKey::for_file_with_params("test_password2", &path, &Argon2Params::insecure()) .unwrap(), ); + let dm2 = EncryptionDomainManager::new(cypher2); let mut storage = StorageV5::new(); - storage.put_at_path("/", "key1".to_string(), "value1".into(), 0); + storage + .put_at_path("/", "key1".to_string(), "value1", 0, MASTER_DOMAIN_ID, &dm1) + .unwrap(); - save_storage_v5(&cypher1, &storage, &path).unwrap(); + save_storage_v5(&dm1, &mut storage, &path).unwrap(); // Should fail or return garbage - let result = load_storage_v5(&cypher2, &path); + let result = load_storage_v5(dm2.get_master_cypher(), &path); assert!(result.is_err() || result.unwrap().root.items.is_empty()); } #[test] fn test_storage_ordering() { let mut storage = StorageV5::new(); + let dm = test_domain_manager(); // Add keys in random order - storage.put_at_path("/", "zebra".to_string(), "z".into(), 0); - storage.put_at_path("/", "alpha".to_string(), "a".into(), 0); - storage.put_at_path("/", "beta".to_string(), "b".into(), 0); + storage + .put_at_path("/", "zebra".to_string(), "z", 0, MASTER_DOMAIN_ID, &dm) + .unwrap(); + storage + .put_at_path("/", "alpha".to_string(), "a", 0, MASTER_DOMAIN_ID, &dm) + .unwrap(); + storage + .put_at_path("/", "beta".to_string(), "b", 0, MASTER_DOMAIN_ID, &dm) + .unwrap(); // Search should return sorted - let keys: Vec<_> = storage.search("").unwrap().collect(); + let keys: Vec<_> = storage + .search_at_path("/", "", true, &dm) + .unwrap() + .collect(); assert_eq!(keys[0], ("/".to_string(), "alpha")); assert_eq!(keys[1], ("/".to_string(), "beta")); assert_eq!(keys[2], ("/".to_string(), "zebra")); @@ -301,24 +536,104 @@ fn test_storage_ordering() { #[test] fn test_special_characters_in_keys() { let mut storage = StorageV5::new(); - storage.put_at_path("/", "key-with-dash".to_string(), "value1".into(), 0); - storage.put_at_path("/", "key_with_underscore".to_string(), "value2".into(), 0); - storage.put_at_path("/", "key.with.dots".to_string(), "value3".into(), 0); - storage.put_at_path("/", "key@with@at".to_string(), "value4".into(), 0); + let dm = test_domain_manager(); + storage + .put_at_path( + "/", + "key-with-dash".to_string(), + "value1", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); + storage + .put_at_path( + "/", + "key_with_underscore".to_string(), + "value2", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); + storage + .put_at_path( + "/", + "key.with.dots".to_string(), + "value3", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); + storage + .put_at_path( + "/", + "key@with@at".to_string(), + "value4", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); assert_eq!(storage.root.items.len(), 4); - let result: Vec<_> = storage.get("key-with-dash").unwrap().collect(); - assert_eq!(result[0].2.as_bytes(), "value1".as_bytes()); - let result: Vec<_> = storage.get("key_with_underscore").unwrap().collect(); - assert_eq!(result[0].2.as_bytes(), "value2".as_bytes()); - let result: Vec<_> = storage.get("key.with.dots").unwrap().collect(); - assert_eq!(result[0].2.as_bytes(), "value3".as_bytes()); - let result: Vec<_> = storage.get("key@with@at").unwrap().collect(); - assert_eq!(result[0].2.as_bytes(), "value4".as_bytes()); + let result: Vec<_> = storage + .get_at_path("/", "key-with-dash", true, &dm) + .unwrap() + .collect(); + assert_eq!( + result[0] + .2 + .decrypt(dm.get_master_cypher()) + .unwrap() + .as_str(), + "value1" + ); + let result: Vec<_> = storage + .get_at_path("/", "key_with_underscore", true, &dm) + .unwrap() + .collect(); + assert_eq!( + result[0] + .2 + .decrypt(dm.get_master_cypher()) + .unwrap() + .as_str(), + "value2" + ); + let result: Vec<_> = storage + .get_at_path("/", "key.with.dots", true, &dm) + .unwrap() + .collect(); + assert_eq!( + result[0] + .2 + .decrypt(dm.get_master_cypher()) + .unwrap() + .as_str(), + "value3" + ); + let result: Vec<_> = storage + .get_at_path("/", "key@with@at", true, &dm) + .unwrap() + .collect(); + assert_eq!( + result[0] + .2 + .decrypt(dm.get_master_cypher()) + .unwrap() + .as_str(), + "value4" + ); // Dot in regex matches any character - let results: Vec<_> = storage.get("key.*").unwrap().collect(); + let results: Vec<_> = storage + .get_at_path("/", "key.*", true, &dm) + .unwrap() + .collect(); assert_eq!(results.len(), 4); } @@ -328,13 +643,23 @@ fn test_concurrent_operations() { use std::thread; let storage = Arc::new(Mutex::new(StorageV5::new())); + let dm = Arc::new(test_domain_manager()); let mut handles = vec![]; for i in 0..10 { let storage_clone = Arc::clone(&storage); + let dm_clone = Arc::clone(&dm); let handle = thread::spawn(move || { let mut s = storage_clone.lock().unwrap(); - s.put_at_path("/", format!("key{}", i), format!("value{}", i).into(), 0); + s.put_at_path( + "/", + format!("key{}", i), + &format!("value{}", i), + 0, + MASTER_DOMAIN_ID, + &dm_clone, + ) + .unwrap(); }); handles.push(handle); } @@ -349,30 +674,57 @@ fn test_concurrent_operations() { #[test] fn test_storage_persistence_across_sessions() { + use rcypher::*; + let (_dir, path) = temp_test_file(); - let cypher = Cypher::new( - EncryptionKey::for_file_with_params("test_password", &path, &Argon2Params::insecure()) - .unwrap(), - ); + + // Helper to create domain manager with the same key for existing file + let make_dm = || { + let key = + EncryptionKey::for_file_with_params("test_password", &path, &Argon2Params::insecure()) + .unwrap(); + EncryptionDomainManager::new(Cypher::new(key)) + }; // Session 1: Create and save { let mut storage = StorageV5::new(); - storage.put_at_path("/", "session1_key".to_string(), "session1_value".into(), 0); - save_storage_v5(&cypher, &storage, &path).unwrap(); + let dm = make_dm(); + storage + .put_at_path( + "/", + "session1_key".to_string(), + "session1_value", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); + save_storage_v5(&dm, &mut storage, &path).unwrap(); } // Session 2: Load and add { - let mut storage = load_storage_v5(&cypher, &path).unwrap(); + let dm = make_dm(); + let mut storage = load_storage_v5(dm.get_master_cypher(), &path).unwrap(); assert_eq!(storage.root.items.len(), 1); - storage.put_at_path("/", "session2_key".to_string(), "session2_value".into(), 0); - save_storage_v5(&cypher, &storage, &path).unwrap(); + storage + .put_at_path( + "/", + "session2_key".to_string(), + "session2_value", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); + save_storage_v5(&dm, &mut storage, &path).unwrap(); } // Session 3: Verify both keys exist { - let storage = load_storage_v5(&cypher, &path).unwrap(); + let dm = make_dm(); + let storage = load_storage_v5(dm.get_master_cypher(), &path).unwrap(); assert_eq!(storage.root.items.len(), 2); assert!(storage.root.items.contains_key("session1_key")); assert!(storage.root.items.contains_key("session2_key")); @@ -382,8 +734,13 @@ fn test_storage_persistence_across_sessions() { #[test] fn test_empty_key_value() { let mut storage = StorageV5::new(); - storage.put_at_path("/", "".to_string(), "value".into(), 0); - storage.put_at_path("/", "key".to_string(), "".into(), 0); + let dm = test_domain_manager(); + storage + .put_at_path("/", "".to_string(), "value", 0, MASTER_DOMAIN_ID, &dm) + .unwrap(); + storage + .put_at_path("/", "key".to_string(), "", 0, MASTER_DOMAIN_ID, &dm) + .unwrap(); assert_eq!(storage.root.items.len(), 2); @@ -394,24 +751,31 @@ fn test_empty_key_value() { assert_eq!( deserialized.root.items[""].get_entries().unwrap()[0] .encrypted_value() - .as_bytes(), - "value".as_bytes() + .decrypt(dm.get_master_cypher()) + .unwrap() + .as_str(), + "value" ); assert_eq!( deserialized.root.items["key"].get_entries().unwrap()[0] .encrypted_value() - .as_bytes(), - "".as_bytes() + .decrypt(dm.get_master_cypher()) + .unwrap() + .as_str(), + "" ); } #[test] fn test_very_long_key_value() { let mut storage = StorageV5::new(); + let dm = test_domain_manager(); let long_key = "k".repeat(10000); let long_value = "v".repeat(50000); - storage.put_at_path("/", long_key.clone(), long_value.clone().into(), 0); + storage + .put_at_path("/", long_key.clone(), &long_value, 0, MASTER_DOMAIN_ID, &dm) + .unwrap(); let serialized = serialize_storage_v5_to_vec(&storage).unwrap(); let deserialized = deserialize_storage_v5_from_slice(&serialized).unwrap(); @@ -419,27 +783,69 @@ fn test_very_long_key_value() { assert_eq!( deserialized.root.items[&long_key].get_entries().unwrap()[0] .encrypted_value() - .as_bytes(), - long_value.as_bytes() + .decrypt(dm.get_master_cypher()) + .unwrap() + .as_str(), + long_value.as_str() ); } #[test] fn test_regex_matches_full_path() { let mut storage = StorageV5::new(); + let dm = test_domain_manager(); // Create folder structure and add keys - storage.mkdir("/", "work").unwrap(); - storage.mkdir("/", "personal").unwrap(); - storage.put_at_path("/", "x_key".to_string(), "root_value".into(), 0); - storage.put_at_path("/work", "api_key".to_string(), "work_api".into(), 0); - storage.put_at_path("/work", "secret".to_string(), "work_secret".into(), 0); - storage.put_at_path("/personal", "password".to_string(), "personal_pw".into(), 0); + storage.mkdir("/", "work", &dm).unwrap(); + storage.mkdir("/", "personal", &dm).unwrap(); + storage + .put_at_path( + "/", + "x_key".to_string(), + "root_value", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); + storage + .put_at_path( + "/work", + "api_key".to_string(), + "work_api", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); + storage + .put_at_path( + "/work", + "secret".to_string(), + "work_secret", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); + storage + .put_at_path( + "/personal", + "password".to_string(), + "personal_pw", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); // Pattern "x.*" should match: // - "x_key" in root (path is "x_key") // But NOT "api_key" in /work (path is "work/api_key", doesn't match "x.*") - let results: Vec<_> = storage.get_at_path("/", "x.*", true).unwrap().collect(); + let results: Vec<_> = storage + .get_at_path("/", "x.*", true, &dm) + .unwrap() + .collect(); assert_eq!(results.len(), 1); assert_eq!(results[0].0, "/"); assert_eq!(results[0].1, "x_key"); @@ -447,7 +853,10 @@ fn test_regex_matches_full_path() { // Pattern "work.*" should match: // - "work/api_key" (matches "work.*") // - "work/secret" (matches "work.*") - let results: Vec<_> = storage.get_at_path("/", "work.*", true).unwrap().collect(); + let results: Vec<_> = storage + .get_at_path("/", "work.*", true, &dm) + .unwrap() + .collect(); assert_eq!(results.len(), 2); assert!( results @@ -462,7 +871,7 @@ fn test_regex_matches_full_path() { // Pattern "work/api.*" should match only "work/api_key" let results: Vec<_> = storage - .get_at_path("/", "work/api.*", true) + .get_at_path("/", "work/api.*", true, &dm) .unwrap() .collect(); assert_eq!(results.len(), 1); @@ -471,7 +880,7 @@ fn test_regex_matches_full_path() { // Search should work the same way let keys: Vec<_> = storage - .search_at_path("/", "work.*", true) + .search_at_path("/", "work.*", true, &dm) .unwrap() .collect(); assert_eq!(keys.len(), 2); @@ -482,42 +891,84 @@ fn test_regex_matches_full_path() { #[test] fn test_deep_nesting_operations() { let mut storage = StorageV5::new(); + let dm = test_domain_manager(); // Create deeply nested folder structure (5 levels) - storage.mkdir("/", "level1").unwrap(); - storage.mkdir("/level1", "level2").unwrap(); - storage.mkdir("/level1/level2", "level3").unwrap(); - storage.mkdir("/level1/level2/level3", "level4").unwrap(); + storage.mkdir("/", "level1", &dm).unwrap(); + storage.mkdir("/level1", "level2", &dm).unwrap(); + storage.mkdir("/level1/level2", "level3", &dm).unwrap(); storage - .mkdir("/level1/level2/level3/level4", "level5") + .mkdir("/level1/level2/level3", "level4", &dm) + .unwrap(); + storage + .mkdir("/level1/level2/level3/level4", "level5", &dm) .unwrap(); // Add keys at different levels - storage.put_at_path("/", "root_key".to_string(), "root_val".into(), 0); - storage.put_at_path("/level1", "l1_key".to_string(), "l1_val".into(), 0); - storage.put_at_path("/level1/level2", "l2_key".to_string(), "l2_val".into(), 0); - storage.put_at_path( - "/level1/level2/level3", - "l3_key".to_string(), - "l3_val".into(), - 0, - ); - storage.put_at_path( - "/level1/level2/level3/level4", - "l4_key".to_string(), - "l4_val".into(), - 0, - ); - storage.put_at_path( - "/level1/level2/level3/level4/level5", - "l5_key".to_string(), - "l5_val".into(), - 0, - ); + storage + .put_at_path( + "/", + "root_key".to_string(), + "root_val", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); + storage + .put_at_path( + "/level1", + "l1_key".to_string(), + "l1_val", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); + storage + .put_at_path( + "/level1/level2", + "l2_key".to_string(), + "l2_val", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); + storage + .put_at_path( + "/level1/level2/level3", + "l3_key".to_string(), + "l3_val", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); + storage + .put_at_path( + "/level1/level2/level3/level4", + "l4_key".to_string(), + "l4_val", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); + storage + .put_at_path( + "/level1/level2/level3/level4/level5", + "l5_key".to_string(), + "l5_val", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); // Test get from different levels let results: Vec<_> = storage - .get_at_path("/", "root_key", false) + .get_at_path("/", "root_key", false, &dm) .unwrap() .collect(); assert_eq!(results.len(), 1); @@ -525,7 +976,7 @@ fn test_deep_nesting_operations() { assert_eq!(results[0].1, "root_key"); let results: Vec<_> = storage - .get_at_path("/level1/level2/level3", "l3_key", false) + .get_at_path("/level1/level2/level3", "l3_key", false, &dm) .unwrap() .collect(); assert_eq!(results.len(), 1); @@ -533,7 +984,7 @@ fn test_deep_nesting_operations() { assert_eq!(results[0].1, "l3_key"); let results: Vec<_> = storage - .get_at_path("/level1/level2/level3/level4/level5", "l5_key", false) + .get_at_path("/level1/level2/level3/level4/level5", "l5_key", false, &dm) .unwrap() .collect(); assert_eq!(results.len(), 1); @@ -541,12 +992,15 @@ fn test_deep_nesting_operations() { assert_eq!(results[0].1, "l5_key"); // Test recursive search from root - let all_keys: Vec<_> = storage.search_at_path("/", ".*", true).unwrap().collect(); + let all_keys: Vec<_> = storage + .search_at_path("/", ".*", true, &dm) + .unwrap() + .collect(); assert_eq!(all_keys.len(), 6); // Test recursive search from nested folder let nested_keys: Vec<_> = storage - .search_at_path("/level1/level2", ".*", true) + .search_at_path("/level1/level2", ".*", true, &dm) .unwrap() .collect(); assert_eq!(nested_keys.len(), 4); // l2_key, l3_key, l4_key, l5_key @@ -555,43 +1009,60 @@ fn test_deep_nesting_operations() { #[test] fn test_nested_folder_regex_matching() { let mut storage = StorageV5::new(); + let dm = test_domain_manager(); // Create nested structure with multiple branches - storage.mkdir("/", "work").unwrap(); - storage.mkdir("/work", "projects").unwrap(); - storage.mkdir("/work", "configs").unwrap(); - storage.mkdir("/work/projects", "client_a").unwrap(); - storage.mkdir("/work/projects", "client_b").unwrap(); + storage.mkdir("/", "work", &dm).unwrap(); + storage.mkdir("/work", "projects", &dm).unwrap(); + storage.mkdir("/work", "configs", &dm).unwrap(); + storage.mkdir("/work/projects", "client_a", &dm).unwrap(); + storage.mkdir("/work/projects", "client_b", &dm).unwrap(); // Add keys with patterns - storage.put_at_path( - "/work/projects/client_a", - "api_key".to_string(), - "key_a".into(), - 0, - ); - storage.put_at_path( - "/work/projects/client_a", - "api_secret".to_string(), - "secret_a".into(), - 0, - ); - storage.put_at_path( - "/work/projects/client_b", - "api_key".to_string(), - "key_b".into(), - 0, - ); - storage.put_at_path( - "/work/configs", - "database".to_string(), - "db_config".into(), - 0, - ); + storage + .put_at_path( + "/work/projects/client_a", + "api_key".to_string(), + "key_a", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); + storage + .put_at_path( + "/work/projects/client_a", + "api_secret".to_string(), + "secret_a", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); + storage + .put_at_path( + "/work/projects/client_b", + "api_key".to_string(), + "key_b", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); + storage + .put_at_path( + "/work/configs", + "database".to_string(), + "db_config", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); // Pattern "work/projects/client_a/.*" should match only client_a keys let results: Vec<_> = storage - .get_at_path("/", "work/projects/client_a/.*", true) + .get_at_path("/", "work/projects/client_a/.*", true, &dm) .unwrap() .collect(); assert_eq!(results.len(), 2); @@ -603,7 +1074,7 @@ fn test_nested_folder_regex_matching() { // Pattern "work/projects/.*/api_key" should match both clients' api_key let results: Vec<_> = storage - .get_at_path("/", "work/projects/.*/api_key", true) + .get_at_path("/", "work/projects/.*/api_key", true, &dm) .unwrap() .collect(); assert_eq!(results.len(), 2); @@ -620,7 +1091,7 @@ fn test_nested_folder_regex_matching() { // Pattern "work/.*/database" should match only database in configs let results: Vec<_> = storage - .get_at_path("/", "work/.*/database", true) + .get_at_path("/", "work/.*/database", true, &dm) .unwrap() .collect(); assert_eq!(results.len(), 1); @@ -632,44 +1103,78 @@ fn test_nested_folder_regex_matching() { fn test_nested_folder_history() { use std::time::{SystemTime, UNIX_EPOCH}; let mut storage = StorageV5::new(); + let dm = test_domain_manager(); let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs(); // Create nested folder - storage.mkdir("/", "work").unwrap(); - storage.mkdir("/work", "projects").unwrap(); + storage.mkdir("/", "work", &dm).unwrap(); + storage.mkdir("/work", "projects", &dm).unwrap(); // Add multiple versions of a nested key - storage.put_at_path( - "/work/projects", - "secret".to_string(), - "v1".into(), - timestamp, - ); - storage.put_at_path( - "/work/projects", - "secret".to_string(), - "v2".into(), - timestamp.add(1), - ); - storage.put_at_path( - "/work/projects", - "secret".to_string(), - "v3".into(), - timestamp.add(2), - ); + storage + .put_at_path( + "/work/projects", + "secret".to_string(), + "v1", + timestamp, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); + storage + .put_at_path( + "/work/projects", + "secret".to_string(), + "v2", + timestamp.add(1), + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); + storage + .put_at_path( + "/work/projects", + "secret".to_string(), + "v3", + timestamp.add(2), + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); // Get history from nested folder let history: Vec<_> = storage - .history_at_path("/work/projects", "secret") + .history_at_path("/work/projects", "secret", &dm) .unwrap() .collect(); assert_eq!(history.len(), 3); - assert_eq!(history[0].encrypted_value().as_bytes(), "v1".as_bytes()); - assert_eq!(history[1].encrypted_value().as_bytes(), "v2".as_bytes()); - assert_eq!(history[2].encrypted_value().as_bytes(), "v3".as_bytes()); + assert_eq!( + history[0] + .encrypted_value() + .decrypt(dm.get_master_cypher()) + .unwrap() + .as_str(), + "v1" + ); + assert_eq!( + history[1] + .encrypted_value() + .decrypt(dm.get_master_cypher()) + .unwrap() + .as_str(), + "v2" + ); + assert_eq!( + history[2] + .encrypted_value() + .decrypt(dm.get_master_cypher()) + .unwrap() + .as_str(), + "v3" + ); // Verify timestamps are in order assert!(history[0].timestamp + 1 == history[1].timestamp); @@ -679,54 +1184,96 @@ fn test_nested_folder_history() { #[test] fn test_nested_folder_delete() { let mut storage = StorageV5::new(); + let dm = test_domain_manager(); // Create nested structure with keys - storage.mkdir("/", "work").unwrap(); - storage.mkdir("/work", "projects").unwrap(); - storage.put_at_path("/work/projects", "secret1".to_string(), "val1".into(), 0); - storage.put_at_path("/work/projects", "secret2".to_string(), "val2".into(), 0); + storage.mkdir("/", "work", &dm).unwrap(); + storage.mkdir("/work", "projects", &dm).unwrap(); + storage + .put_at_path( + "/work/projects", + "secret1".to_string(), + "val1", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); + storage + .put_at_path( + "/work/projects", + "secret2".to_string(), + "val2", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); // Delete key from nested folder - assert!(storage.delete_at_path("/work/projects", "secret1")); + assert!(storage.delete_at_path("/work/projects", "secret1", &dm)); // Verify only secret1 is deleted let results: Vec<_> = storage - .get_at_path("/work/projects", "secret1", false) + .get_at_path("/work/projects", "secret1", false, &dm) .unwrap() .collect(); assert_eq!(results.len(), 0); let results: Vec<_> = storage - .get_at_path("/work/projects", "secret2", false) + .get_at_path("/work/projects", "secret2", false, &dm) .unwrap() .collect(); assert_eq!(results.len(), 1); // Try to delete non-existent key - assert!(!storage.delete_at_path("/work/projects", "secret1")); - assert!(!storage.delete_at_path("/work/projects", "nonexistent")); + assert!(!storage.delete_at_path("/work/projects", "secret1", &dm)); + assert!(!storage.delete_at_path("/work/projects", "nonexistent", &dm)); } #[test] fn test_nested_folder_serialization() { let mut storage = StorageV5::new(); + let dm = test_domain_manager(); // Create complex nested structure - storage.mkdir("/", "org").unwrap(); - storage.mkdir("/org", "dept").unwrap(); - storage.mkdir("/org/dept", "team").unwrap(); - storage.put_at_path("/org", "org_key".to_string(), "org_val".into(), 0); - storage.put_at_path("/org/dept", "dept_key".to_string(), "dept_val".into(), 0); - storage.put_at_path( - "/org/dept/team", - "team_key".to_string(), - "team_val".into(), - 0, - ); + storage.mkdir("/", "org", &dm).unwrap(); + storage.mkdir("/org", "dept", &dm).unwrap(); + storage.mkdir("/org/dept", "team", &dm).unwrap(); + storage + .put_at_path( + "/org", + "org_key".to_string(), + "org_val", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); + storage + .put_at_path( + "/org/dept", + "dept_key".to_string(), + "dept_val", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); + storage + .put_at_path( + "/org/dept/team", + "team_key".to_string(), + "team_val", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); // Serialize and deserialize let serialized = serialize_storage_v5_to_vec(&storage).unwrap(); - let deserialized = deserialize_storage_v5_from_slice(&serialized).unwrap(); + let mut deserialized = deserialize_storage_v5_from_slice(&serialized).unwrap(); // Verify structure is preserved assert!(deserialized.get_folder("/org").is_some()); @@ -735,65 +1282,106 @@ fn test_nested_folder_serialization() { // Verify keys are preserved let results: Vec<_> = deserialized - .get_at_path("/org", "org_key", false) + .get_at_path("/org", "org_key", false, &dm) .unwrap() .collect(); assert_eq!(results.len(), 1); - assert_eq!(results[0].2.as_bytes(), "org_val".as_bytes()); + assert_eq!( + results[0] + .2 + .decrypt(dm.get_master_cypher()) + .unwrap() + .as_str(), + "org_val" + ); let results: Vec<_> = deserialized - .get_at_path("/org/dept/team", "team_key", false) + .get_at_path("/org/dept/team", "team_key", false, &dm) .unwrap() .collect(); assert_eq!(results.len(), 1); - assert_eq!(results[0].2.as_bytes(), "team_val".as_bytes()); + assert_eq!( + results[0] + .2 + .decrypt(dm.get_master_cypher()) + .unwrap() + .as_str(), + "team_val" + ); } #[test] fn test_multiple_nested_branches() { let mut storage = StorageV5::new(); + let dm = test_domain_manager(); // Create multiple independent nested branches - storage.mkdir("/", "work").unwrap(); - storage.mkdir("/work", "project_a").unwrap(); - storage.mkdir("/work", "project_b").unwrap(); - storage.mkdir("/", "personal").unwrap(); - storage.mkdir("/personal", "finance").unwrap(); - storage.mkdir("/personal", "health").unwrap(); + storage.mkdir("/", "work", &dm).unwrap(); + storage.mkdir("/work", "project_a", &dm).unwrap(); + storage.mkdir("/work", "project_b", &dm).unwrap(); + storage.mkdir("/", "personal", &dm).unwrap(); + storage.mkdir("/personal", "finance", &dm).unwrap(); + storage.mkdir("/personal", "health", &dm).unwrap(); // Add keys to different branches - storage.put_at_path("/work/project_a", "api".to_string(), "api_a".into(), 0); - storage.put_at_path("/work/project_b", "api".to_string(), "api_b".into(), 0); - storage.put_at_path( - "/personal/finance", - "account".to_string(), - "acc123".into(), - 0, - ); - storage.put_at_path( - "/personal/health", - "insurance".to_string(), - "ins456".into(), - 0, - ); + storage + .put_at_path( + "/work/project_a", + "api".to_string(), + "api_a", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); + storage + .put_at_path( + "/work/project_b", + "api".to_string(), + "api_b", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); + storage + .put_at_path( + "/personal/finance", + "account".to_string(), + "acc123", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); + storage + .put_at_path( + "/personal/health", + "insurance".to_string(), + "ins456", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); // Search all work keys let work_keys: Vec<_> = storage - .search_at_path("/work", ".*", true) + .search_at_path("/work", ".*", true, &dm) .unwrap() .collect(); assert_eq!(work_keys.len(), 2); // Search all personal keys let personal_keys: Vec<_> = storage - .search_at_path("/personal", ".*", true) + .search_at_path("/personal", ".*", true, &dm) .unwrap() .collect(); assert_eq!(personal_keys.len(), 2); // Search for specific pattern across all branches let api_keys: Vec<_> = storage - .search_at_path("/", "work/.*/api", true) + .search_at_path("/", "work/.*/api", true, &dm) .unwrap() .collect(); assert_eq!(api_keys.len(), 2); @@ -812,127 +1400,253 @@ fn test_multiple_nested_branches() { #[test] fn test_move_key_same_folder() { let mut storage = StorageV5::new(); - storage.put_at_path("/", "old_name".to_string(), "value".into(), 0); + let dm = test_domain_manager(); + storage + .put_at_path( + "/", + "old_name".to_string(), + "value", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); // Move with rename in same folder storage - .move_item("/", "old_name", "/", Some("new_name")) + .move_item("/", "old_name", "/", Some("new_name"), None, &dm) .unwrap(); // Old key should be gone let results: Vec<_> = storage - .get_at_path("/", "old_name", false) + .get_at_path("/", "old_name", false, &dm) .unwrap() .collect(); assert_eq!(results.len(), 0); // New key should exist let results: Vec<_> = storage - .get_at_path("/", "new_name", false) + .get_at_path("/", "new_name", false, &dm) .unwrap() .collect(); assert_eq!(results.len(), 1); - assert_eq!(results[0].2.as_bytes(), "value".as_bytes()); + assert_eq!( + results[0] + .2 + .decrypt(dm.get_master_cypher()) + .unwrap() + .as_str(), + "value" + ); } #[test] fn test_move_key_between_folders() { let mut storage = StorageV5::new(); - storage.mkdir("/", "source").unwrap(); - storage.mkdir("/", "dest").unwrap(); - storage.put_at_path("/source", "key1".to_string(), "value1".into(), 0); + let dm = test_domain_manager(); + storage.mkdir("/", "source", &dm).unwrap(); + storage.mkdir("/", "dest", &dm).unwrap(); + storage + .put_at_path( + "/source", + "key1".to_string(), + "value1", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); // Move to different folder keeping same name - storage.move_item("/source", "key1", "/dest", None).unwrap(); + storage + .move_item("/source", "key1", "/dest", None, None, &dm) + .unwrap(); // Should be gone from source let results: Vec<_> = storage - .get_at_path("/source", "key1", false) + .get_at_path("/source", "key1", false, &dm) .unwrap() .collect(); assert_eq!(results.len(), 0); // Should exist in dest let results: Vec<_> = storage - .get_at_path("/dest", "key1", false) + .get_at_path("/dest", "key1", false, &dm) .unwrap() .collect(); assert_eq!(results.len(), 1); - assert_eq!(results[0].2.as_bytes(), "value1".as_bytes()); + assert_eq!( + results[0] + .2 + .decrypt(dm.get_master_cypher()) + .unwrap() + .as_str(), + "value1" + ); } #[test] fn test_move_key_with_rename_between_folders() { let mut storage = StorageV5::new(); - storage.mkdir("/", "source").unwrap(); - storage.mkdir("/", "dest").unwrap(); - storage.put_at_path("/source", "old_key".to_string(), "value".into(), 0); + let dm = test_domain_manager(); + storage.mkdir("/", "source", &dm).unwrap(); + storage.mkdir("/", "dest", &dm).unwrap(); + storage + .put_at_path( + "/source", + "old_key".to_string(), + "value", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); // Move and rename storage - .move_item("/source", "old_key", "/dest", Some("new_key")) + .move_item("/source", "old_key", "/dest", Some("new_key"), None, &dm) .unwrap(); // Should be gone from source let results: Vec<_> = storage - .get_at_path("/source", "old_key", false) + .get_at_path("/source", "old_key", false, &dm) .unwrap() .collect(); assert_eq!(results.len(), 0); // Should exist in dest with new name let results: Vec<_> = storage - .get_at_path("/dest", "new_key", false) + .get_at_path("/dest", "new_key", false, &dm) .unwrap() .collect(); assert_eq!(results.len(), 1); - assert_eq!(results[0].2.as_bytes(), "value".as_bytes()); + assert_eq!( + results[0] + .2 + .decrypt(dm.get_master_cypher()) + .unwrap() + .as_str(), + "value" + ); } #[test] fn test_move_key_preserves_history() { use std::time::{SystemTime, UNIX_EPOCH}; let mut storage = StorageV5::new(); + let dm = test_domain_manager(); let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .unwrap() .as_secs(); - storage.mkdir("/", "source").unwrap(); - storage.mkdir("/", "dest").unwrap(); + storage.mkdir("/", "source", &dm).unwrap(); + storage.mkdir("/", "dest", &dm).unwrap(); // Add multiple versions - storage.put_at_path("/source", "key1".to_string(), "v1".into(), timestamp); - storage.put_at_path("/source", "key1".to_string(), "v2".into(), timestamp.add(1)); - storage.put_at_path("/source", "key1".to_string(), "v3".into(), timestamp.add(2)); + storage + .put_at_path( + "/source", + "key1".to_string(), + "v1", + timestamp, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); + storage + .put_at_path( + "/source", + "key1".to_string(), + "v2", + timestamp.add(1), + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); + storage + .put_at_path( + "/source", + "key1".to_string(), + "v3", + timestamp.add(2), + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); // Move the key - storage.move_item("/source", "key1", "/dest", None).unwrap(); + storage + .move_item("/source", "key1", "/dest", None, None, &dm) + .unwrap(); // Check history is preserved - let history: Vec<_> = storage.history_at_path("/dest", "key1").unwrap().collect(); + let history: Vec<_> = storage + .history_at_path("/dest", "key1", &dm) + .unwrap() + .collect(); assert_eq!(history.len(), 3); - assert_eq!(history[0].encrypted_value().as_bytes(), "v1".as_bytes()); - assert_eq!(history[1].encrypted_value().as_bytes(), "v2".as_bytes()); - assert_eq!(history[2].encrypted_value().as_bytes(), "v3".as_bytes()); + assert_eq!( + history[0] + .encrypted_value() + .decrypt(dm.get_master_cypher()) + .unwrap() + .as_str(), + "v1" + ); + assert_eq!( + history[1] + .encrypted_value() + .decrypt(dm.get_master_cypher()) + .unwrap() + .as_str(), + "v2" + ); + assert_eq!( + history[2] + .encrypted_value() + .decrypt(dm.get_master_cypher()) + .unwrap() + .as_str(), + "v3" + ); } #[test] fn test_move_key_collision_error() { let mut storage = StorageV5::new(); - storage.mkdir("/", "source").unwrap(); - storage.mkdir("/", "dest").unwrap(); - storage.put_at_path("/source", "key1".to_string(), "value1".into(), 0); - storage.put_at_path("/dest", "key1".to_string(), "existing".into(), 0); + let dm = test_domain_manager(); + storage.mkdir("/", "source", &dm).unwrap(); + storage.mkdir("/", "dest", &dm).unwrap(); + storage + .put_at_path( + "/source", + "key1".to_string(), + "value1", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); + storage + .put_at_path( + "/dest", + "key1".to_string(), + "existing", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); // Should fail due to collision - let result = storage.move_item("/source", "key1", "/dest", None); + let result = storage.move_item("/source", "key1", "/dest", None, None, &dm); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("already exists")); // Source should still have the key (move was not performed) let results: Vec<_> = storage - .get_at_path("/source", "key1", false) + .get_at_path("/source", "key1", false, &dm) .unwrap() .collect(); assert_eq!(results.len(), 1); @@ -941,9 +1655,10 @@ fn test_move_key_collision_error() { #[test] fn test_move_key_nonexistent_source() { let mut storage = StorageV5::new(); - storage.mkdir("/", "dest").unwrap(); + let dm = test_domain_manager(); + storage.mkdir("/", "dest", &dm).unwrap(); - let result = storage.move_item("/", "nonexistent", "/dest", None); + let result = storage.move_item("/", "nonexistent", "/dest", None, None, &dm); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("not found")); } @@ -951,9 +1666,12 @@ fn test_move_key_nonexistent_source() { #[test] fn test_move_key_nonexistent_dest_folder() { let mut storage = StorageV5::new(); - storage.put_at_path("/", "key1".to_string(), "value".into(), 0); + let dm = test_domain_manager(); + storage + .put_at_path("/", "key1".to_string(), "value", 0, MASTER_DOMAIN_ID, &dm) + .unwrap(); - let result = storage.move_item("/", "key1", "/nonexistent", None); + let result = storage.move_item("/", "key1", "/nonexistent", None, None, &dm); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("not found")); } @@ -961,14 +1679,24 @@ fn test_move_key_nonexistent_dest_folder() { #[test] fn test_move_folder_between_parents() { let mut storage = StorageV5::new(); - storage.mkdir("/", "source").unwrap(); - storage.mkdir("/source", "folder1").unwrap(); - storage.mkdir("/", "dest").unwrap(); - storage.put_at_path("/source/folder1", "key1".to_string(), "value1".into(), 0); + let dm = test_domain_manager(); + storage.mkdir("/", "source", &dm).unwrap(); + storage.mkdir("/source", "folder1", &dm).unwrap(); + storage.mkdir("/", "dest", &dm).unwrap(); + storage + .put_at_path( + "/source/folder1", + "key1".to_string(), + "value1", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); // Move folder1 from /source to /dest storage - .move_item("/source", "folder1", "/dest", None) + .move_item("/source", "folder1", "/dest", None, None, &dm) .unwrap(); // Should be gone from source @@ -977,22 +1705,39 @@ fn test_move_folder_between_parents() { // Should exist in dest with contents assert!(storage.get_folder("/dest/folder1").is_some()); let results: Vec<_> = storage - .get_at_path("/dest/folder1", "key1", false) + .get_at_path("/dest/folder1", "key1", false, &dm) .unwrap() .collect(); assert_eq!(results.len(), 1); - assert_eq!(results[0].2.as_bytes(), "value1".as_bytes()); + assert_eq!( + results[0] + .2 + .decrypt(dm.get_master_cypher()) + .unwrap() + .as_str(), + "value1" + ); } #[test] fn test_move_folder_with_rename() { let mut storage = StorageV5::new(); - storage.mkdir("/", "old_name").unwrap(); - storage.put_at_path("/old_name", "key1".to_string(), "value1".into(), 0); + let dm = test_domain_manager(); + storage.mkdir("/", "old_name", &dm).unwrap(); + storage + .put_at_path( + "/old_name", + "key1".to_string(), + "value1", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); // Rename folder storage - .move_item("/", "old_name", "/", Some("new_name")) + .move_item("/", "old_name", "/", Some("new_name"), None, &dm) .unwrap(); // Old should be gone @@ -1001,7 +1746,7 @@ fn test_move_folder_with_rename() { // New should exist with contents assert!(storage.get_folder("/new_name").is_some()); let results: Vec<_> = storage - .get_at_path("/new_name", "key1", false) + .get_at_path("/new_name", "key1", false, &dm) .unwrap() .collect(); assert_eq!(results.len(), 1); @@ -1010,43 +1755,56 @@ fn test_move_folder_with_rename() { #[test] fn test_move_folder_preserves_nested_structure() { let mut storage = StorageV5::new(); - storage.mkdir("/", "source").unwrap(); - storage.mkdir("/source", "folder1").unwrap(); - storage.mkdir("/source/folder1", "subfolder").unwrap(); - storage.put_at_path( - "/source/folder1/subfolder", - "deep_key".to_string(), - "deep_value".into(), - 0, - ); - storage.mkdir("/", "dest").unwrap(); + let dm = test_domain_manager(); + storage.mkdir("/", "source", &dm).unwrap(); + storage.mkdir("/source", "folder1", &dm).unwrap(); + storage.mkdir("/source/folder1", "subfolder", &dm).unwrap(); + storage + .put_at_path( + "/source/folder1/subfolder", + "deep_key".to_string(), + "deep_value", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); + storage.mkdir("/", "dest", &dm).unwrap(); // Move entire folder tree storage - .move_item("/source", "folder1", "/dest", None) + .move_item("/source", "folder1", "/dest", None, None, &dm) .unwrap(); // Verify nested structure preserved assert!(storage.get_folder("/dest/folder1").is_some()); assert!(storage.get_folder("/dest/folder1/subfolder").is_some()); let results: Vec<_> = storage - .get_at_path("/dest/folder1/subfolder", "deep_key", false) + .get_at_path("/dest/folder1/subfolder", "deep_key", false, &dm) .unwrap() .collect(); assert_eq!(results.len(), 1); - assert_eq!(results[0].2.as_bytes(), "deep_value".as_bytes()); + assert_eq!( + results[0] + .2 + .decrypt(dm.get_master_cypher()) + .unwrap() + .as_str(), + "deep_value" + ); } #[test] fn test_move_folder_collision_error() { let mut storage = StorageV5::new(); - storage.mkdir("/", "source").unwrap(); - storage.mkdir("/source", "folder1").unwrap(); - storage.mkdir("/", "dest").unwrap(); - storage.mkdir("/dest", "folder1").unwrap(); // Collision + let dm = test_domain_manager(); + storage.mkdir("/", "source", &dm).unwrap(); + storage.mkdir("/source", "folder1", &dm).unwrap(); + storage.mkdir("/", "dest", &dm).unwrap(); + storage.mkdir("/dest", "folder1", &dm).unwrap(); // Collision // Should fail - let result = storage.move_item("/source", "folder1", "/dest", None); + let result = storage.move_item("/source", "folder1", "/dest", None, None, &dm); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("already exists")); @@ -1070,18 +1828,32 @@ fn test_encrypted_folder_decrypted_state_not_serialized() { ) .unwrap(), ); + let mut dm = EncryptionDomainManager::new(cypher); + + // Create and unlock domain 1 (needed for re-encryption during save) + dm.unlock_domain( + 1, + "domain1".to_string(), + "domain1_password", + &Argon2Params::insecure(), + ) + .unwrap(); // Create storage with an encrypted folder let mut storage = StorageV5::new(); // Create a regular folder with some content - storage.mkdir("/", "subfolder").unwrap(); - storage.put_at_path( - "/subfolder", - "secret1".to_string(), - EncryptedValue::encrypt(&cypher, "value1").unwrap(), - 0, - ); + storage.mkdir("/", "subfolder", &dm).unwrap(); + storage + .put_at_path( + "/subfolder", + "secret1".to_string(), + "value1", + 0, + MASTER_DOMAIN_ID, + &dm, + ) + .unwrap(); // Create an encrypted folder with some encrypted_data use std::collections::BTreeMap; @@ -1094,7 +1866,6 @@ fn test_encrypted_folder_decrypted_state_not_serialized() { // Create a decrypted folder with content let mut decrypted = Folder { name: "decrypted_content".to_string(), - encryption_domain: 1, items: BTreeMap::new(), }; decrypted.items.insert( @@ -1102,7 +1873,7 @@ fn test_encrypted_folder_decrypted_state_not_serialized() { FolderItem::new_secret( "inner_secret".to_string(), vec![SecretEntry::new( - EncryptedValue::encrypt(&cypher, "sensitive_data").unwrap(), + EncryptedValue::encrypt(dm.get_cypher(1).unwrap(), "sensitive_data").unwrap(), 12345, )], 1, @@ -1127,10 +1898,10 @@ fn test_encrypted_folder_decrypted_state_not_serialized() { .insert("locked_folder".to_string(), encrypted_folder); // Save storage - save_storage_v5(&cypher, &storage, &path).unwrap(); + save_storage_v5(&dm, &mut storage, &path).unwrap(); // Load storage back - let loaded_storage = load_storage_v5(&cypher, &path).unwrap(); + let loaded_storage = load_storage_v5(dm.get_master_cypher(), &path).unwrap(); // CRITICAL TEST: decrypted_folder should be None after deserialization // This verifies #[serde(skip)] works correctly @@ -1140,27 +1911,52 @@ fn test_encrypted_folder_decrypted_state_not_serialized() { "Folder MUST be locked after deserialization - decrypted_folder should be None" ); - // Verify encrypted_data is preserved - assert_eq!( - loaded_item.test_get_encrypted_data(), - Some(&[1, 2, 3, 4, 5][..]), - "encrypted_data should be preserved" + // Verify encrypted_data exists (will be different from dummy bytes due to re-encryption) + assert!( + loaded_item.test_get_encrypted_data().is_some(), + "encrypted_data should exist" + ); + assert!( + !loaded_item.test_get_encrypted_data().unwrap().is_empty(), + "encrypted_data should not be empty" ); // Verify encryption_domain is preserved assert_eq!( loaded_item.encryption_domain(), - 1, + Some(1), "encryption_domain should be preserved" ); + // Verify the folder can be properly unlocked and contains the expected content + // (transparent decryption happens when accessing the path with unlocked domain) + let mut loaded_storage_mut = loaded_storage; + let unlocked_folder = loaded_storage_mut + .get_folder_mut("/locked_folder", &dm) + .unwrap(); + assert!( + unlocked_folder.items.contains_key("inner_secret"), + "Decrypted folder should contain the inner secret" + ); + let inner_secret = unlocked_folder.items.get("inner_secret").unwrap(); + let decrypted_value = inner_secret.get_entries().unwrap()[0] + .encrypted_value() + .decrypt(dm.get_cypher(1).unwrap()) + .unwrap(); + assert_eq!( + decrypted_value.as_str(), + "sensitive_data", + "Inner secret value should match" + ); + // Verify other content is still there - assert_eq!(loaded_storage.root.items.len(), 2, "Should have 2 items"); + assert_eq!( + loaded_storage_mut.root.items.len(), + 2, + "Should have 2 items" + ); assert!( - loaded_storage.get_folder("/subfolder").is_some(), + loaded_storage_mut.get_folder("/subfolder").is_some(), "Regular folder should still exist" ); - - println!("✓ Critical test passed: decrypted_folder is NOT serialized"); - println!("✓ Unlocked folders return to locked state on disk"); } From 71cc1c1eb2cbf1c5ab2ff7a0b6cb6d89208bf780 Mon Sep 17 00:00:00 2001 From: Roman Studenikin Date: Sun, 4 Jan 2026 21:40:46 +0000 Subject: [PATCH 11/12] rename store.rs -> v4.rs --- src/storage/mod.rs | 4 ++-- src/storage/serialization.rs | 2 +- src/storage/{store.rs => v4.rs} | 0 src/storage/v5.rs | 4 +--- 4 files changed, 4 insertions(+), 6 deletions(-) rename src/storage/{store.rs => v4.rs} (100%) diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 165f953..dc08852 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -1,5 +1,5 @@ mod serialization; -mod store; +mod v4; mod v5; mod value; @@ -7,7 +7,7 @@ pub use serialization::{ deserialize_storage_v4, load_storage_v4, load_storage_v5, save_storage_v4, save_storage_v5, serialize_storage_v4, }; -pub use store::StorageV4; +pub use v4::StorageV4; pub use v5::{ Folder, FolderItem, SecretEntry, StorageV5, deserialize_storage_v5_from_slice, serialize_storage_v5_to_vec, diff --git a/src/storage/serialization.rs b/src/storage/serialization.rs index a34b3b7..0869288 100644 --- a/src/storage/serialization.rs +++ b/src/storage/serialization.rs @@ -10,7 +10,7 @@ use crate::EncryptionDomainManager; use crate::crypto::Cypher; use crate::version::StoreVersion; -use super::store::StorageV4; +use super::v4::StorageV4; use super::v5::{self, StorageV5}; use super::value::{EncryptedValue, ValueEntry}; diff --git a/src/storage/store.rs b/src/storage/v4.rs similarity index 100% rename from src/storage/store.rs rename to src/storage/v4.rs diff --git a/src/storage/v5.rs b/src/storage/v5.rs index 968c02f..34204bd 100644 --- a/src/storage/v5.rs +++ b/src/storage/v5.rs @@ -956,8 +956,6 @@ impl FolderItem { target_domain_id: u32, domain_manager: &EncryptionDomainManager, ) -> Result<()> { - - match self { Self::Folder { folder, name } => { // Convert regular folder to encrypted folder @@ -1434,7 +1432,7 @@ fn sort_folder_entries(folder: &mut Folder) { // Migration from V4 to V5 // ============================================================================ -use super::store::StorageV4; +use super::v4::StorageV4; use super::value::ValueEntry; /// Migrate V4 storage to V5 format From 6730a315824fbd3a63edc2c2b16e4c000157ecbc Mon Sep 17 00:00:00 2001 From: Roman Studenikin Date: Sun, 4 Jan 2026 22:00:34 +0000 Subject: [PATCH 12/12] Rename src/crypto/domain_keys.rs -> src/crypto/encryption_domain.rs --- .../{domain_keys.rs => encryption_domain.rs} | 102 ++++++++++-------- src/crypto/mod.rs | 4 +- tests/storage_v5_tests.rs | 7 +- 3 files changed, 62 insertions(+), 51 deletions(-) rename src/crypto/{domain_keys.rs => encryption_domain.rs} (75%) diff --git a/src/crypto/domain_keys.rs b/src/crypto/encryption_domain.rs similarity index 75% rename from src/crypto/domain_keys.rs rename to src/crypto/encryption_domain.rs index b316fc2..e6b2356 100644 --- a/src/crypto/domain_keys.rs +++ b/src/crypto/encryption_domain.rs @@ -2,15 +2,11 @@ use std::collections::HashMap; use anyhow::{Result, bail}; -use crate::{Argon2Params, Cypher, CypherVersion, EncryptionKey}; +use crate::Cypher; -/// Master domain ID (uses the storage file password) pub const MASTER_DOMAIN_ID: u32 = 0; - -/// Master domain name pub const MASTER_DOMAIN_NAME: &str = "master"; -/// Encryption domain with a name and cypher pub struct EncryptionDomain { pub name: String, pub cypher: Cypher, @@ -55,30 +51,15 @@ impl EncryptionDomainManager { /// # Arguments /// * `domain_id` - The domain to unlock /// * `name` - Name of the domain (from `StorageV5` metadata) - /// * `password` - Password to derive the domain key from - /// * `argon2_params` - Argon2 parameters for key derivation + /// * `cypher` - `Cypher` for the domain /// /// # Errors /// * If the domain is already unlocked - /// * If key derivation fails - pub fn unlock_domain( - &mut self, - domain_id: u32, - name: String, - password: &str, - argon2_params: &Argon2Params, - ) -> Result<()> { + pub fn unlock_domain(&mut self, domain_id: u32, name: String, cypher: Cypher) -> Result<()> { if self.domains.contains_key(&domain_id) { bail!("Domain {domain_id} is already unlocked"); } - let key = EncryptionKey::from_password_with_params( - CypherVersion::default(), - password, - argon2_params, - )?; - - let cypher = Cypher::new(key); self.domains .insert(domain_id, EncryptionDomain::new(name, cypher)); @@ -127,6 +108,9 @@ impl EncryptionDomainManager { #[cfg(test)] mod tests { use super::*; + use crate::Argon2Params; + use crate::CypherVersion; + use crate::EncryptionKey; fn create_test_cypher() -> Cypher { let key = EncryptionKey::from_password_with_params( @@ -155,13 +139,14 @@ mod tests { let cypher = create_test_cypher(); let mut manager = EncryptionDomainManager::new(cypher); + let key1 = EncryptionKey::from_password_with_params( + CypherVersion::V7WithKdf, + "domain1_password", + &Argon2Params::insecure(), + ) + .unwrap(); manager - .unlock_domain( - 1, - "work".to_string(), - "domain1_password", - &Argon2Params::insecure(), - ) + .unlock_domain(1, "work".to_string(), Cypher::new(key1)) .unwrap(); assert!(manager.is_domain_unlocked(1)); @@ -176,12 +161,17 @@ mod tests { let cypher = create_test_cypher(); let mut manager = EncryptionDomainManager::new(cypher); + let key1 = EncryptionKey::from_password_with_params( + CypherVersion::V7WithKdf, + "domain1_password", + &Argon2Params::insecure(), + ) + .unwrap(); manager - .unlock_domain(1, "test".to_string(), "password", &Argon2Params::insecure()) + .unlock_domain(1, "work".to_string(), Cypher::new(key1.clone())) .unwrap(); - let result = - manager.unlock_domain(1, "test".to_string(), "password", &Argon2Params::insecure()); + let result = manager.unlock_domain(1, "work".to_string(), Cypher::new(key1)); assert!(result.is_err()); assert!(result.unwrap_err().to_string().contains("already unlocked")); } @@ -191,8 +181,14 @@ mod tests { let cypher = create_test_cypher(); let mut manager = EncryptionDomainManager::new(cypher); + let key1 = EncryptionKey::from_password_with_params( + CypherVersion::V7WithKdf, + "domain1_password", + &Argon2Params::insecure(), + ) + .unwrap(); manager - .unlock_domain(1, "test".to_string(), "password", &Argon2Params::insecure()) + .unlock_domain(1, "work".to_string(), Cypher::new(key1)) .unwrap(); assert!(manager.is_domain_unlocked(1)); @@ -226,19 +222,32 @@ mod tests { let cypher = create_test_cypher(); let mut manager = EncryptionDomainManager::new(cypher); + let key1 = EncryptionKey::from_password_with_params( + CypherVersion::V7WithKdf, + "pass1", + &Argon2Params::insecure(), + ) + .unwrap(); + let key2 = EncryptionKey::from_password_with_params( + CypherVersion::V7WithKdf, + "pass2", + &Argon2Params::insecure(), + ) + .unwrap(); + let key3 = EncryptionKey::from_password_with_params( + CypherVersion::V7WithKdf, + "pass3", + &Argon2Params::insecure(), + ) + .unwrap(); manager - .unlock_domain( - 1, - "personal".to_string(), - "pass1", - &Argon2Params::insecure(), - ) + .unlock_domain(1, "personal".to_string(), Cypher::new(key1)) .unwrap(); manager - .unlock_domain(2, "work".to_string(), "pass2", &Argon2Params::insecure()) + .unlock_domain(2, "work".to_string(), Cypher::new(key2)) .unwrap(); manager - .unlock_domain(3, "shared".to_string(), "pass3", &Argon2Params::insecure()) + .unlock_domain(3, "shared".to_string(), Cypher::new(key3)) .unwrap(); assert!(manager.is_domain_unlocked(1)); @@ -261,13 +270,14 @@ mod tests { let cypher = create_test_cypher(); let mut manager = EncryptionDomainManager::new(cypher); + let key1 = EncryptionKey::from_password_with_params( + CypherVersion::V7WithKdf, + "domain1_password", + &Argon2Params::insecure(), + ) + .unwrap(); manager - .unlock_domain( - 1, - "test".to_string(), - "domain_password", - &Argon2Params::insecure(), - ) + .unlock_domain(1, "work".to_string(), Cypher::new(key1)) .unwrap(); let domain_cypher = manager.get_cypher(1).unwrap(); diff --git a/src/crypto/mod.rs b/src/crypto/mod.rs index ae85841..2e98719 100644 --- a/src/crypto/mod.rs +++ b/src/crypto/mod.rs @@ -1,11 +1,11 @@ mod cipher; -mod domain_keys; +mod encryption_domain; mod key; mod stream_ops; mod utils; pub use cipher::Cypher; -pub use domain_keys::{ +pub use encryption_domain::{ EncryptionDomain, EncryptionDomainManager, MASTER_DOMAIN_ID, MASTER_DOMAIN_NAME, }; pub use key::{Argon2Params, EncryptionKey}; diff --git a/tests/storage_v5_tests.rs b/tests/storage_v5_tests.rs index c1f9af2..a2ce9a1 100644 --- a/tests/storage_v5_tests.rs +++ b/tests/storage_v5_tests.rs @@ -1831,13 +1831,14 @@ fn test_encrypted_folder_decrypted_state_not_serialized() { let mut dm = EncryptionDomainManager::new(cypher); // Create and unlock domain 1 (needed for re-encryption during save) - dm.unlock_domain( - 1, - "domain1".to_string(), + let key1 = EncryptionKey::from_password_with_params( + CypherVersion::V7WithKdf, "domain1_password", &Argon2Params::insecure(), ) .unwrap(); + dm.unlock_domain(1, "domain1".to_string(), Cypher::new(key1)) + .unwrap(); // Create storage with an encrypted folder let mut storage = StorageV5::new();