diff --git a/Cargo.lock b/Cargo.lock index 7bc2f04..9ecee80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -551,6 +551,12 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.13" @@ -729,6 +735,12 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "hashbrown" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" + [[package]] name = "heck" version = "0.5.0" @@ -783,6 +795,16 @@ dependencies = [ "cc", ] +[[package]] +name = "indexmap" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe4cd85333e22411419a0bcae1297d25e58c9443848b11dc6a86fefe8c78a661" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "indicatif" version = "0.17.11" @@ -1476,6 +1498,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + [[package]] name = "serial_test" version = "3.2.0" @@ -1645,6 +1676,48 @@ dependencies = [ "syn", ] +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + [[package]] name = "tracing" version = "0.1.41" @@ -1709,7 +1782,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "vaultify" -version = "0.2.7" +version = "0.3.0" dependencies = [ "aes-gcm", "anyhow", @@ -1737,6 +1810,7 @@ dependencies = [ "tempfile", "thiserror", "tokio", + "toml", "zeroize", ] @@ -2236,6 +2310,15 @@ version = "0.53.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "271414315aff87387382ec3d271b52d7ae78726f5d44ac98b4f4030c91880486" +[[package]] +name = "winnow" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3edebf492c8125044983378ecb5766203ad3b4c2f7a922bd7dd207f6d443e95" +dependencies = [ + "memchr", +] + [[package]] name = "wit-bindgen-rt" version = "0.39.0" diff --git a/Cargo.toml b/Cargo.toml index 83281a3..ef4e7cd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vaultify" -version = "0.2.7" +version = "0.3.0" edition = "2021" authors = ["vaultify contributors"] description = "A secure, file-based secrets vault with interactive CLI" @@ -42,6 +42,7 @@ atty = "0.2" # Serialization serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +toml = { version = "0.8", features = ["preserve_order"] } # Error handling thiserror = "1.0" diff --git a/README.md b/README.md index 3ee8c9c..d0471e7 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ A secure, file-based password manager with hierarchical organization. Written in - 🔐 **Per-item encryption** with Argon2id + AES-256-GCM - 📁 **Hierarchical organization** of secrets -- 📝 **Markdown-based** vault format for easy versioning +- 📝 **TOML-based** vault format for easy editing and versioning - 🔍 **Fast filtering** of entries - 📋 **Clipboard integration** with automatic clearing - 🚀 **Interactive and CLI modes** @@ -58,7 +58,7 @@ cargo build --release vaultify init ``` -This creates a `vault.md` file in the current directory. +This creates a `vault.toml` file in the current directory. ### Add a secret @@ -134,26 +134,29 @@ vaultify> exit ## Vault Format -Vaults are stored as markdown files with encrypted content: +Vaults are stored as TOML files with encrypted content: -```markdown -# root +```toml +version = "v0.3" +modified = "2025-01-17T10:00:00Z" -## personal - -Personal accounts - - +[personal] +description = "Personal accounts" +encrypted = "" +salt = "" -### personal/email - -Email accounts - - -base64-encoded-encrypted-content - +[personal.email] +description = "Email accounts" +encrypted = "base64-encoded-encrypted-content" +salt = "base64-encoded-salt" ``` +### Key Features: +- **Insertion order preserved**: Entries maintain their original order +- **Smart group insertion**: New entries are added at the end of their group +- **Native TOML format**: Clean, readable structure with dotted key notation +- **Flexible parsing**: Parent entries are created automatically + ## Security - **Encryption**: Each entry is encrypted with Argon2id + AES-256-GCM diff --git a/src/cli.rs b/src/cli.rs index d90f966..b7ab472 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -20,7 +20,7 @@ pub struct Cli { long, global = true, env = "VAULT_FILE", - help = "Path to vault file (default: searches for vault.md)" + help = "Path to vault file (default: searches for vault.toml)" )] pub file: Option, @@ -56,7 +56,7 @@ pub enum Commands { /// Add a new secret to the vault Add { - /// Secret scope (e.g., personal/email/gmail) + /// Secret scope (e.g., personal.email.gmail) scope: String, /// Description of the secret @@ -158,11 +158,11 @@ pub enum Commands { /// Decrypt GPG-encrypted vault file GpgDecrypt { - /// Input file (default: vault.md.gpg or vault.md.asc) + /// Input file (default: vault.toml.gpg or vault.toml.asc) #[arg(short, long)] input: Option, - /// Output file (default: vault.md) + /// Output file (default: vault.toml) #[arg(short = 'O', long = "output-file")] output_file: Option, }, @@ -241,7 +241,7 @@ impl Cli { let vault_path = if let Some(path) = &self.file { path.clone() } else { - PathBuf::from("vault.md") + PathBuf::from("vault.toml") }; // Check if vault already exists @@ -259,9 +259,10 @@ impl Cli { } } - // Create vault file - let content = "# root \n"; - fs::write(&vault_path, content)?; + // Create vault file with TOML format + let content = "version = \"v0.3\"\ncreated = \"{}\"\n"; + let now = chrono::Utc::now().to_rfc3339(); + fs::write(&vault_path, content.replace("{}", &now))?; // Set proper permissions on Unix #[cfg(unix)] @@ -334,36 +335,31 @@ impl Cli { let scopes: Vec = result.entries.iter().map(|e| e.scope.clone()).collect(); let tree_lines = utils::format_tree(&scopes); - for line in tree_lines.iter() { - // Find the corresponding entry by checking if the line ends with the last part of the scope - if let Some(entry) = result.entries.iter().find(|e| { - let scope_parts: Vec<&str> = e.scope.split('/').collect(); - if let Some(last_part) = scope_parts.last() { - line.ends_with(last_part) - } else { - false - } - }) { - let desc_lines: Vec<&str> = entry.description.lines().collect(); - let first_line = desc_lines.first().copied().unwrap_or(""); + for (i, line) in tree_lines.iter().enumerate() { + // Find the corresponding entry by matching the scope from the original scopes list + if i < scopes.len() { + if let Some(entry) = result.entries.iter().find(|e| e.scope == scopes[i]) { + let desc_lines: Vec<&str> = entry.description.lines().collect(); + let first_line = desc_lines.first().copied().unwrap_or(""); - if !entry.has_content { - println!("{} {} - {}", line, "[empty]".yellow(), first_line); - } else { - println!("{} - {}", line, first_line); - } + if !entry.has_content { + println!("{} {} - {}", line, "[empty]".yellow(), first_line); + } else { + println!("{line} - {first_line}"); + } - // Print additional description lines with appropriate indentation - if desc_lines.len() > 1 { - // Calculate indentation based on tree line - let indent_len = line.len() + 3; // +3 for " - " - let indent = " ".repeat(indent_len); - for desc_line in desc_lines.iter().skip(1) { - println!("{}{}", indent, desc_line); + // Print additional description lines with appropriate indentation + if desc_lines.len() > 1 { + // Calculate indentation based on tree line + let indent_len = line.len() + 3; // +3 for " - " + let indent = " ".repeat(indent_len); + for desc_line in desc_lines.iter().skip(1) { + println!("{indent}{desc_line}"); + } } + } else { + println!("{line}"); } - } else { - println!("{line}"); } } } else { @@ -394,7 +390,7 @@ impl Cli { // Print additional description lines indented for line in desc_lines.iter().skip(1) { - println!(" {}", line); + println!(" {line}"); } } } @@ -579,8 +575,8 @@ impl Cli { } else { // Try to find encrypted vault let vault_path = self.get_vault_file()?; - let gpg_path = vault_path.with_extension("md.gpg"); - let asc_path = vault_path.with_extension("md.asc"); + let gpg_path = vault_path.with_extension("toml.gpg"); + let asc_path = vault_path.with_extension("toml.asc"); if gpg_path.exists() { gpg_path @@ -588,7 +584,7 @@ impl Cli { asc_path } else { return Err(VaultError::Other( - "No encrypted vault file found (vault.md.gpg or vault.md.asc)".to_string(), + "No encrypted vault file found (vault.toml.gpg or vault.toml.asc)".to_string(), )); } }; @@ -680,16 +676,18 @@ mod tests { #[test] fn test_validate_scope_name() { - assert!(utils::validate_scope_name("personal/email")); - assert!(utils::validate_scope_name("work/vpn")); + assert!(utils::validate_scope_name("personal.email")); + assert!(utils::validate_scope_name("work.vpn")); assert!(!utils::validate_scope_name("")); - assert!(!utils::validate_scope_name("personal/.")); - assert!(!utils::validate_scope_name("personal/..")); + assert!(!utils::validate_scope_name("personal..")); + assert!(!utils::validate_scope_name("personal...")); + assert!(!utils::validate_scope_name(".personal")); + assert!(!utils::validate_scope_name("personal.")); } #[test] fn test_parse_scope_path() { - let parts = utils::parse_scope_path("personal/email/gmail"); + let parts = utils::parse_scope_path("personal.email.gmail"); assert_eq!(parts, vec!["personal", "email", "gmail"]); } } diff --git a/src/error.rs b/src/error.rs index eddc780..479fd0c 100644 --- a/src/error.rs +++ b/src/error.rs @@ -48,9 +48,6 @@ pub enum VaultError { #[error("IO error: {0}")] Io(#[from] std::io::Error), - #[error("Parse error: {0}")] - Parse(#[from] crate::parser::ParseError), - #[error("Crypto error: {0}")] Crypto(#[from] crate::crypto::CryptoError), diff --git a/src/gpg.rs b/src/gpg.rs index 47517b5..8288966 100644 --- a/src/gpg.rs +++ b/src/gpg.rs @@ -181,9 +181,9 @@ mod tests { #[test] fn test_is_gpg_file() { - assert!(GpgOperations::is_gpg_file(Path::new("vault.md.gpg"))); - assert!(GpgOperations::is_gpg_file(Path::new("vault.md.asc"))); - assert!(!GpgOperations::is_gpg_file(Path::new("vault.md"))); + assert!(GpgOperations::is_gpg_file(Path::new("vault.toml.gpg"))); + assert!(GpgOperations::is_gpg_file(Path::new("vault.toml.asc"))); + assert!(!GpgOperations::is_gpg_file(Path::new("vault.toml"))); } #[test] diff --git a/src/interactive.rs b/src/interactive.rs index 91b8dc6..1c96f1c 100644 --- a/src/interactive.rs +++ b/src/interactive.rs @@ -212,16 +212,23 @@ impl InteractiveVault { let scopes: Vec = result.entries.iter().map(|e| e.scope.clone()).collect(); let tree_lines = utils::format_tree(&scopes); - for line in tree_lines { - // Find if this line represents an entry - if let Some(entry) = result.entries.iter().find(|e| line.contains(&e.scope)) { - if !entry.has_content { - println!(" {} {} - {}", line, "[empty]".yellow(), entry.description); + for (i, line) in tree_lines.iter().enumerate() { + // Find the corresponding entry by matching the scope from the original scopes list + if i < scopes.len() { + if let Some(entry) = result.entries.iter().find(|e| e.scope == scopes[i]) { + if !entry.has_content { + println!( + " {} {} - {}", + line, + "[empty]".yellow(), + entry.description + ); + } else { + println!(" {} - {}", line, entry.description); + } } else { - println!(" {} - {}", line, entry.description); + println!(" {line}"); } - } else { - println!(" {line}"); } } } @@ -521,8 +528,8 @@ impl InteractiveVault { /// Decrypt GPG-encrypted vault file (interactive). fn gpg_decrypt_interactive(&self) -> Result<()> { // Try to find encrypted vault - let gpg_path = self.vault_path.with_extension("md.gpg"); - let asc_path = self.vault_path.with_extension("md.asc"); + let gpg_path = self.vault_path.with_extension("toml.gpg"); + let asc_path = self.vault_path.with_extension("toml.asc"); let encrypted_path = if gpg_path.exists() && asc_path.exists() { // Both exist, ask which one to use @@ -546,7 +553,7 @@ impl InteractiveVault { asc_path } else { return Err(VaultError::Other( - "No encrypted vault file found (vault.md.gpg or vault.md.asc)".to_string(), + "No encrypted vault file found (vault.toml.gpg or vault.toml.asc)".to_string(), )); }; @@ -596,10 +603,10 @@ mod tests { #[test] fn test_interactive_vault_creation() { let dir = tempdir().unwrap(); - let vault_path = dir.path().join("test_vault.md"); + let vault_path = dir.path().join("test_vault.toml"); // Create a test vault file - std::fs::write(&vault_path, "# root \n").unwrap(); + std::fs::write(&vault_path, "version = \"v0.3\"\n").unwrap(); // Create interactive vault let vault = InteractiveVault::new(vault_path.clone()); diff --git a/src/lib.rs b/src/lib.rs index 24add38..a566035 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,10 +7,10 @@ pub mod gpg; pub mod interactive; pub mod models; pub mod operations; -pub mod parser; pub mod secure_temp; pub mod security; pub mod service; +pub mod toml_parser; pub mod utils; // Re-export commonly used types diff --git a/src/main.rs b/src/main.rs index 6a19d88..d28a29e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -58,7 +58,7 @@ async fn run_interactive() { // No vault file found, prompt user to create one eprintln!("{}", "No vault file found.".yellow()); - let vault_path = PathBuf::from("vault.md"); + let vault_path = PathBuf::from("vault.toml"); let full_path = std::env::current_dir() .unwrap_or_default() .join(&vault_path); @@ -89,8 +89,9 @@ async fn run_interactive() { }; if create { - // Create new vault file - let content = vaultify::parser::VaultParser::create_root_document(); + // Create new vault file with TOML format + let now = chrono::Utc::now().to_rfc3339(); + let content = format!("version = \"v0.3\"\ncreated = \"{now}\"\n"); match std::fs::write(&vault_path, content) { Ok(_) => { // Set secure permissions diff --git a/src/models.rs b/src/models.rs index e2d2d0d..db95c72 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1,6 +1,7 @@ //! Data models for the credential vault. use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::path::PathBuf; /// Represents a single entry in the vault. @@ -8,24 +9,21 @@ use std::path::PathBuf; pub struct VaultEntry { /// Scope path, e.g., ("personal", "banking", "chase") pub scope_path: Vec, - /// Markdown heading depth (1-6) - pub heading_level: u8, /// Plain text description pub description: String, /// Base64 encrypted secret pub encrypted_content: String, - /// Starting line in file (0-based) - pub start_line: usize, - /// Ending line in file (0-based) - pub end_line: usize, /// Per-item salt for key derivation pub salt: Option>, + /// Custom fields for extensibility (TOML format) + #[serde(default, skip_serializing_if = "HashMap::is_empty")] + pub custom_fields: HashMap, } impl VaultEntry { - /// Get scope as slash-separated string. + /// Get scope as dot-separated string. pub fn scope_string(&self) -> String { - self.scope_path.join("/") + self.scope_path.join(".") } /// Check if this entry has encrypted content. @@ -52,7 +50,7 @@ impl VaultEntry { pub struct VaultDocument { /// All vault entries in order pub entries: Vec, - /// Original file lines with newlines + /// Raw lines (not used in TOML format, kept for compatibility) pub raw_lines: Vec, /// Path to the vault file pub file_path: Option, @@ -112,59 +110,42 @@ impl VaultDocument { /// Add a new entry to the document. pub fn add_entry(&mut self, entry: VaultEntry) -> Result<(), Box> { - use crate::parser::VaultParser; + // Find the correct position to insert within the group + // We want to maintain group cohesion - all entries with the same top-level + // prefix should be together, with new entries added at the end of their group - // Create parser for formatting - let parser = VaultParser::new(); - - // Format the new entry - let entry_lines = parser.format_entry(&entry); - - // Find insertion point - let parent_path = if entry.scope_path.len() > 1 { - &entry.scope_path[..entry.scope_path.len() - 1] - } else { - &[] - }; - - let insert_point = VaultParser::find_insertion_point(self, parent_path); + if self.entries.is_empty() { + self.entries.push(entry); + return Ok(()); + } - // Create any missing ancestors - let ancestors = VaultParser::create_missing_ancestors(self, &entry.scope_path); + // Get the top-level prefix of the new entry + let new_prefix = &entry.scope_path[0]; - // Insert ancestors first - let mut current_insert = insert_point; - for ancestor in ancestors { - let ancestor_lines = parser.format_entry(&ancestor); - for (i, line) in ancestor_lines.iter().enumerate() { - self.raw_lines.insert(current_insert + i, line.clone()); + // Find the last index of entries with the same top-level prefix + let mut insert_position = None; + for (i, existing) in self.entries.iter().enumerate() { + if !existing.scope_path.is_empty() && &existing.scope_path[0] == new_prefix { + // Found an entry with the same prefix, update insert position + insert_position = Some(i + 1); } - current_insert += ancestor_lines.len(); - self.entries.push(ancestor); } - // Insert the new entry - for (i, line) in entry_lines.iter().enumerate() { - self.raw_lines.insert(current_insert + i, line.clone()); + // If we found entries with the same prefix, insert after the last one + // Otherwise, append to the end + match insert_position { + Some(pos) => self.entries.insert(pos, entry), + None => self.entries.push(entry), } - // No need to add blank lines here since they're included in format_entry - - self.entries.push(entry); - - // Re-parse to update line numbers - let content = self.raw_lines.join(""); - let new_doc = parser.parse(&content)?; - self.entries = new_doc.entries; - Ok(()) } /// Save the document to a file. - pub fn save(&self, path: &std::path::Path) -> Result<(), std::io::Error> { - use std::fs; - let content = self.raw_lines.join(""); - fs::write(path, content) + pub fn save(&self, _path: &std::path::Path) -> Result<(), std::io::Error> { + // This method is no longer used - saving is handled by VaultService + // which uses TomlParser::format() to generate the content + Ok(()) } } @@ -176,36 +157,30 @@ mod tests { fn test_vault_entry_scope_string() { let entry = VaultEntry { scope_path: vec!["personal".to_string(), "banking".to_string()], - heading_level: 2, description: "Banking info".to_string(), encrypted_content: "".to_string(), - start_line: 0, - end_line: 5, salt: None, + custom_fields: HashMap::new(), }; - assert_eq!(entry.scope_string(), "personal/banking"); + assert_eq!(entry.scope_string(), "personal.banking"); } #[test] fn test_vault_entry_relationships() { let parent = VaultEntry { scope_path: vec!["personal".to_string()], - heading_level: 1, description: "Personal".to_string(), encrypted_content: "".to_string(), - start_line: 0, - end_line: 5, salt: None, + custom_fields: HashMap::new(), }; let child = VaultEntry { scope_path: vec!["personal".to_string(), "banking".to_string()], - heading_level: 2, description: "Banking".to_string(), encrypted_content: "".to_string(), - start_line: 6, - end_line: 10, salt: None, + custom_fields: HashMap::new(), }; assert!(parent.is_parent_of(&child)); diff --git a/src/operations.rs b/src/operations.rs index be01953..186b376 100644 --- a/src/operations.rs +++ b/src/operations.rs @@ -78,8 +78,7 @@ impl VaultOperations { }); } - // Sort for consistent output - entries.sort_by(|a, b| a.scope.cmp(&b.scope)); + // Preserve the order from the vault file - no sorting Ok(ListResult { entries }) } diff --git a/src/parser.rs b/src/parser.rs deleted file mode 100644 index bdf92e3..0000000 --- a/src/parser.rs +++ /dev/null @@ -1,425 +0,0 @@ -//! Order-preserving parser for vault markdown files. - -use crate::crypto::VaultCrypto; -use crate::models::{VaultDocument, VaultEntry}; -use regex::Regex; -use std::path::Path; -use thiserror::Error; - -/// Errors that can occur during parsing. -#[derive(Error, Debug)] -pub enum ParseError { - #[error("Unsupported vault version: {0}")] - UnsupportedVersion(String), - #[error("Invalid vault format")] - InvalidFormat, - #[error("IO error: {0}")] - Io(#[from] std::io::Error), -} - -/// Parse and manipulate vault markdown files while preserving order. -pub struct VaultParser { - // Supported versions - supported_versions: Vec<&'static str>, - #[allow(dead_code)] - current_version: &'static str, - // Regex patterns - heading_pattern: Regex, - version_pattern: Regex, - description_start: Regex, - description_end: Regex, - encrypted_start: Regex, - encrypted_end: Regex, -} - -impl Default for VaultParser { - fn default() -> Self { - Self { - supported_versions: vec!["v1"], - current_version: "v1", - heading_pattern: Regex::new( - r"^(#{1,6})\s+(.+?)(?:\s*)?$", - ) - .unwrap(), - version_pattern: Regex::new(r"").unwrap(), - description_start: Regex::new(r"^$").unwrap(), - description_end: Regex::new(r"^$").unwrap(), - encrypted_start: Regex::new(r#"^$"#).unwrap(), - encrypted_end: Regex::new(r"^$").unwrap(), - } - } -} - -impl VaultParser { - /// Create a new parser instance. - pub fn new() -> Self { - Self::default() - } - - /// Create a new root document with version. - pub fn create_root_document() -> String { - "# root \n".to_string() - } - - /// Parse vault content into a VaultDocument. - pub fn parse(&self, content: &str) -> Result { - let lines: Vec = content.lines().map(|s| format!("{s}\n")).collect(); - let mut entries = Vec::new(); - - // Extract and validate version - if let Some(version) = self.extract_version(&lines) { - if !self.supported_versions.contains(&version.as_str()) { - return Err(ParseError::UnsupportedVersion(version)); - } - } - - let mut i = 0; - while i < lines.len() { - let line = lines[i].trim_end(); - - // Check if this is a heading - if let Some(heading_match) = self.heading_pattern.captures(line) { - let heading_text = heading_match.get(2).unwrap().as_str().trim(); - let heading_level = heading_match.get(1).unwrap().as_str().len(); - - // Skip the root header (# root) - if heading_level == 1 && heading_text.to_lowercase().starts_with("root") { - i += 1; - continue; - } - - if let Some((entry, next_i)) = self.parse_entry(&lines, i, heading_match)? { - entries.push(entry); - i = next_i; - } else { - i += 1; - } - } else { - i += 1; - } - } - - Ok(VaultDocument { - entries, - raw_lines: lines, - file_path: None, - }) - } - - /// Parse a vault file. - pub fn parse_file(&self, path: &Path) -> Result { - let content = std::fs::read_to_string(path)?; - let mut doc = self.parse(&content)?; - doc.file_path = Some(path.to_path_buf()); - Ok(doc) - } - - /// Extract version from the first few lines. - fn extract_version(&self, lines: &[String]) -> Option { - for line in lines.iter().take(5) { - if let Some(version_match) = self.version_pattern.captures(line) { - return Some(version_match.get(1).unwrap().as_str().to_string()); - } - } - None - } - - /// Parse a single vault entry starting from a heading. - fn parse_entry( - &self, - lines: &[String], - start_idx: usize, - heading_match: regex::Captures, - ) -> Result, ParseError> { - let heading_level = heading_match.get(1).unwrap().as_str().len() as u8; - let heading_text = heading_match.get(2).unwrap().as_str().trim(); - - // Remove the scope key comment from heading text if present - let heading_text = if heading_text.contains(" - -## personal - -Personal accounts - - - -### personal/email - -Email accounts - - -gAAAAABk1234567890 - - -## work - -Work-related secrets - - -"#; - - #[test] - fn test_parse_basic_vault() { - let parser = VaultParser::new(); - let doc = parser.parse(SAMPLE_VAULT).unwrap(); - - assert_eq!(doc.entries.len(), 3); - - let scopes: Vec = doc.entries.iter().map(|e| e.scope_string()).collect(); - assert!(scopes.contains(&"personal".to_string())); - assert!(scopes.contains(&"personal/email".to_string())); - assert!(scopes.contains(&"work".to_string())); - } - - #[test] - fn test_parse_entry_with_salt() { - let parser = VaultParser::new(); - let doc = parser.parse(SAMPLE_VAULT).unwrap(); - - let email_entry = doc - .find_entry(&["personal".to_string(), "email".to_string()]) - .unwrap(); - assert!(email_entry.salt.is_some()); - assert_eq!(email_entry.encrypted_content, "gAAAAABk1234567890"); - } - - #[test] - fn test_version_validation() { - let parser = VaultParser::new(); - - // Valid version - let valid_vault = r#"# root -## test - -Test - - -"#; - assert!(parser.parse(valid_vault).is_ok()); - - // Invalid version - let invalid_vault = r#"# root -## test - -Test - - -"#; - let result = parser.parse(invalid_vault); - assert!(matches!(result, Err(ParseError::UnsupportedVersion(_)))); - } - - #[test] - fn test_format_entry() { - let entry = VaultEntry { - scope_path: vec!["test".to_string(), "item".to_string()], - heading_level: 2, - description: "Test entry".to_string(), - encrypted_content: "encrypted_data".to_string(), - salt: Some(b"test_salt_bytes".to_vec()), - start_line: 0, - end_line: 0, - }; - - let parser = VaultParser::new(); - let lines = parser.format_entry(&entry); - let content = lines.join(""); - - assert!(content.contains("## test/item")); - assert!(content.contains("salt=")); - assert!(content.contains("encrypted_data")); - } -} diff --git a/src/service.rs b/src/service.rs index 1afcc88..154772f 100644 --- a/src/service.rs +++ b/src/service.rs @@ -3,14 +3,15 @@ use crate::crypto::VaultCrypto; use crate::error::{Result, VaultError}; use crate::models::{VaultDocument, VaultEntry}; -use crate::parser::VaultParser; +use crate::toml_parser::TomlParser; use crate::utils; +use std::collections::HashMap; use std::path::Path; /// Service for vault operations. pub struct VaultService { crypto: VaultCrypto, - parser: VaultParser, + parser: TomlParser, } impl Default for VaultService { @@ -24,20 +25,29 @@ impl VaultService { pub fn new() -> Self { Self { crypto: VaultCrypto::new(), - parser: VaultParser::new(), + parser: TomlParser::new(), } } /// Load a vault document from file. pub fn load_vault(&self, path: &Path) -> Result { - self.parser - .parse_file(path) - .map_err(|e| VaultError::Other(e.to_string())) + // Read file content + let content = std::fs::read_to_string(path).map_err(VaultError::Io)?; + + // Parse TOML format + let mut doc = self.parser.parse(&content)?; + + // Set file path + doc.file_path = Some(path.to_path_buf()); + Ok(doc) } /// Save a vault document to file. pub fn save_vault(&self, doc: &VaultDocument, path: &Path) -> Result<()> { - doc.save(path).map_err(VaultError::Io) + // Format as TOML and save + let content = self.parser.format(doc); + std::fs::write(path, content).map_err(VaultError::Io)?; + Ok(()) } /// Add a new entry to the vault. @@ -68,12 +78,10 @@ impl VaultService { // Create new entry let entry = VaultEntry { scope_path: scope_parts.clone(), - heading_level: scope_parts.len() as u8, description, encrypted_content, salt: Some(salt), - start_line: 0, - end_line: 0, + custom_fields: HashMap::new(), }; // Add to document @@ -178,7 +186,6 @@ impl VaultService { // Update scope entry.scope_path = new_scope_parts.clone(); - entry.heading_level = new_scope_parts.len() as u8; Ok(()) } @@ -224,9 +231,17 @@ impl VaultService { /// List all unique scopes in the vault. pub fn list_scopes(&self, doc: &VaultDocument) -> Vec { - let mut scopes: Vec = doc.entries.iter().map(|e| e.scope_string()).collect(); - scopes.sort(); - scopes.dedup(); + // Preserve the order from the vault file + let mut seen = std::collections::HashSet::new(); + let mut scopes = Vec::new(); + + for entry in &doc.entries { + let scope = entry.scope_string(); + if seen.insert(scope.clone()) { + scopes.push(scope); + } + } + scopes } @@ -282,7 +297,7 @@ mod tests { service .add_entry( &mut doc, - "test/entry".to_string(), + "test.entry".to_string(), "Test entry".to_string(), secret.to_string(), password, @@ -308,7 +323,7 @@ mod tests { service .add_entry( &mut doc, - "personal/email".to_string(), + "personal.email".to_string(), "Personal email account".to_string(), "secret1".to_string(), password, @@ -318,7 +333,7 @@ mod tests { service .add_entry( &mut doc, - "work/email".to_string(), + "work.email".to_string(), "Work email account".to_string(), "secret2".to_string(), password, @@ -328,7 +343,7 @@ mod tests { service .add_entry( &mut doc, - "personal/banking".to_string(), + "personal.banking".to_string(), "Banking credentials".to_string(), "secret3".to_string(), password, @@ -358,7 +373,7 @@ mod tests { service .add_entry( &mut doc, - "old/path".to_string(), + "old.path".to_string(), "Test entry".to_string(), "secret".to_string(), password, @@ -370,7 +385,7 @@ mod tests { .rename_entry( &mut doc, &["old".to_string(), "path".to_string()], - "new/path".to_string(), + "new.path".to_string(), ) .unwrap(); diff --git a/src/toml_parser.rs b/src/toml_parser.rs new file mode 100644 index 0000000..5a99d2d --- /dev/null +++ b/src/toml_parser.rs @@ -0,0 +1,509 @@ +//! TOML format parser for vault files with flexible parsing support. + +use crate::crypto::VaultCrypto; +use crate::error::{Result, VaultError}; +use crate::models::{VaultDocument, VaultEntry}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::Path; +use toml::value::Table; + +/// TOML format metadata +#[derive(Debug, Serialize, Deserialize)] +struct VaultMetadata { + version: String, + #[serde(skip_serializing_if = "Option::is_none")] + created: Option, + #[serde(skip_serializing_if = "Option::is_none")] + modified: Option, +} + +/// TOML entry structure +#[derive(Debug, Serialize, Deserialize)] +struct TomlEntry { + description: String, + #[serde(default)] + encrypted: String, + #[serde(skip_serializing_if = "Option::is_none")] + salt: Option, + #[serde(flatten)] + custom_fields: HashMap, +} + +/// Parse and manipulate vault TOML files with flexible parsing. +pub struct TomlParser { + supported_versions: Vec<&'static str>, + current_version: &'static str, +} + +impl Default for TomlParser { + fn default() -> Self { + Self { + supported_versions: vec!["v0.3"], + current_version: "v0.3", + } + } +} + +impl TomlParser { + /// Create a new parser instance. + pub fn new() -> Self { + Self::default() + } + + /// Parse vault content into a VaultDocument. + pub fn parse(&self, content: &str) -> Result { + // Parse TOML with order preservation + let table: Table = content + .parse() + .map_err(|e| VaultError::Other(format!("TOML parse error: {e}")))?; + + // Check version + if let Some(version) = table.get("version").and_then(|v| v.as_str()) { + if !self.supported_versions.contains(&version) { + return Err(VaultError::Other(format!( + "Unsupported TOML version: {version}" + ))); + } + } + + let mut entries = Vec::new(); + let mut processed_keys = std::collections::HashSet::new(); + + // Process all table entries recursively + self.process_tables(&table, &[], &mut entries, &mut processed_keys)?; + + // Preserve original insertion order - no sorting + + Ok(VaultDocument { + entries, + raw_lines: vec![], // Not used in TOML mode + file_path: None, + }) + } + + /// Parse a vault file. + pub fn parse_file(&self, path: &Path) -> Result { + let content = std::fs::read_to_string(path).map_err(VaultError::Io)?; + let mut doc = self.parse(&content)?; + doc.file_path = Some(path.to_path_buf()); + Ok(doc) + } + + /// Format a VaultDocument as TOML. + pub fn format(&self, doc: &VaultDocument) -> String { + // Build hierarchical structure for proper TOML dotted keys + let mut lines = Vec::new(); + + // Add metadata at the top + lines.push(format!("version = \"{}\"", self.current_version)); + let now = chrono::Utc::now().to_rfc3339(); + lines.push(format!("modified = \"{now}\"")); + lines.push(String::new()); // blank line + + // Preserve exact file order - no sorting at all + // New entries are added to the end of their group naturally during parsing + let sorted_entries = &doc.entries; + + // Format each entry with proper TOML dotted key notation + for entry in sorted_entries { + // Skip empty parent entries that only exist for hierarchy + if entry.encrypted_content.is_empty() && entry.description.ends_with(" secrets") { + continue; + } + + // Create the dotted key section header + let section_key = entry.scope_path.join("."); + lines.push(format!("[{section_key}]")); + + // Add fields + lines.push(format!( + "description = \"{}\"", + escape_toml_string(&entry.description) + )); + + // Always include encrypted field + if !entry.encrypted_content.is_empty() { + lines.push(format!("encrypted = \"{}\"", entry.encrypted_content)); + } else { + lines.push("encrypted = \"\"".to_string()); + } + + // Always include salt field for consistency + if let Some(salt) = &entry.salt { + lines.push(format!("salt = \"{}\"", VaultCrypto::encode_salt(salt))); + } else { + lines.push("salt = \"\"".to_string()); + } + + // Add custom fields + for (k, v) in &entry.custom_fields { + lines.push(format!("{k} = {}", format_toml_value(v))); + } + + lines.push(String::new()); // blank line between entries + } + + lines.join("\n") + } + + /// Update an entry preserving custom fields. + pub fn update_entry( + doc: &mut VaultDocument, + scope_path: &[String], + updates: HashMap, + ) -> Result<()> { + let entry = doc + .find_entry_mut(scope_path) + .ok_or_else(|| VaultError::EntryNotFound(scope_path.join(".")))?; + + // Update only specified fields + for (key, value) in updates { + match key.as_str() { + "description" => { + if let Some(desc) = value.as_str() { + entry.description = desc.to_string(); + } + } + "encrypted" => { + if let Some(enc) = value.as_str() { + entry.encrypted_content = enc.to_string(); + } + } + "salt" => { + if let Some(salt_str) = value.as_str() { + entry.salt = VaultCrypto::decode_salt(salt_str).ok(); + } + } + _ => { + // Store in custom fields + entry.custom_fields.insert(key, value); + } + } + } + + Ok(()) + } + + /// Process tables recursively, handling both flat and nested structures. + #[allow(clippy::only_used_in_recursion)] + fn process_tables( + &self, + table: &Table, + prefix: &[String], + entries: &mut Vec, + processed_keys: &mut std::collections::HashSet, + ) -> Result<()> { + for (key, value) in table { + // Skip metadata fields at root level + if prefix.is_empty() && (key == "version" || key == "created" || key == "modified") { + continue; + } + + // Build full scope path + let mut scope_parts = prefix.to_vec(); + let key_parts = parse_scope_key(key); + scope_parts.extend(key_parts); + + let full_key = scope_parts.join("."); + + if let Some(nested_table) = value.as_table() { + // Check if this is a vault entry (has description/encrypted/salt) + let is_vault_entry = nested_table.contains_key("description") + || nested_table.contains_key("encrypted") + || nested_table.contains_key("salt"); + + if is_vault_entry && !processed_keys.contains(&full_key) { + // Create implicit parent entries + for i in 1..scope_parts.len() { + let parent_path = scope_parts[..i].to_vec(); + let parent_key = parent_path.join("."); + + if !processed_keys.contains(&parent_key) { + let parent_entry = VaultEntry { + scope_path: parent_path.clone(), + description: format!("{} secrets", parent_path.join(".")), + encrypted_content: String::new(), + salt: None, + custom_fields: HashMap::new(), + }; + entries.push(parent_entry); + processed_keys.insert(parent_key); + } + } + + // Process this entry + let toml_entry = extract_toml_entry(nested_table)?; + let entry = VaultEntry { + scope_path: scope_parts.clone(), + description: toml_entry.description, + encrypted_content: toml_entry.encrypted, + salt: toml_entry + .salt + .and_then(|s| VaultCrypto::decode_salt(&s).ok()), + custom_fields: toml_entry.custom_fields, + }; + entries.push(entry); + processed_keys.insert(full_key); + } + + // Process nested tables (for structures like [work] with [work.email] inside) + self.process_tables(nested_table, &scope_parts, entries, processed_keys)?; + } + } + + Ok(()) + } +} + +/// Parse a scope key that may contain dots +fn parse_scope_key(key: &str) -> Vec { + // For now, simple split by dots + // TODO: Handle quoted keys with dots inside + key.split('.').map(|s| s.to_string()).collect() +} + +/// Escape a string for TOML format +fn escape_toml_string(s: &str) -> String { + s.replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") + .replace('\r', "\\r") + .replace('\t', "\\t") +} + +/// Format a TOML value for output +fn format_toml_value(value: &toml::Value) -> String { + match value { + toml::Value::String(s) => format!("\"{}\"", escape_toml_string(s)), + toml::Value::Integer(i) => i.to_string(), + toml::Value::Float(f) => f.to_string(), + toml::Value::Boolean(b) => b.to_string(), + toml::Value::Array(arr) => { + let items: Vec = arr.iter().map(format_toml_value).collect(); + format!("[{}]", items.join(", ")) + } + toml::Value::Datetime(dt) => format!("\"{dt}\""), + toml::Value::Table(_) => "{ ... }".to_string(), // Shouldn't happen for custom fields + } +} + +/// Extract TomlEntry from a table +fn extract_toml_entry(table: &Table) -> Result { + let description = table + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let encrypted = table + .get("encrypted") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + let salt = table + .get("salt") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + + // Collect custom fields (excluding nested tables) + let mut custom_fields = HashMap::new(); + for (k, v) in table { + if k != "description" && k != "encrypted" && k != "salt" { + // Skip nested tables - they are separate vault entries + if !v.is_table() { + custom_fields.insert(k.clone(), v.clone()); + } + } + } + + Ok(TomlEntry { + description, + encrypted, + salt, + custom_fields, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_basic_toml() { + let content = r#" +version = "v0.3" + +[work] +description = "Work credentials" +encrypted = "" +salt = "" + +[work.email] +description = "Work email" +encrypted = "YmFzZTY0X2VuY3J5cHRlZF9kYXRh" +salt = "YmFzZTY0X3NhbHQ=" +"#; + + let parser = TomlParser::new(); + let doc = parser.parse(content).unwrap(); + + assert_eq!(doc.entries.len(), 2); + assert_eq!(doc.entries[0].scope_path, vec!["work"]); + assert_eq!(doc.entries[1].scope_path, vec!["work", "email"]); + } + + #[test] + fn test_insertion_order_preservation() { + let parser = TomlParser::new(); + + // Create entries in a specific order + let doc = VaultDocument { + entries: vec![ + VaultEntry { + scope_path: vec!["a".to_string()], + description: "Root A".to_string(), + encrypted_content: String::new(), + salt: None, + custom_fields: HashMap::new(), + }, + VaultEntry { + scope_path: vec!["a".to_string(), "a3".to_string()], + description: "Third child".to_string(), + encrypted_content: "encrypted3".to_string(), + salt: Some(vec![3, 3, 3]), + custom_fields: HashMap::new(), + }, + VaultEntry { + scope_path: vec!["a".to_string(), "a1".to_string()], + description: "First child".to_string(), + encrypted_content: "encrypted1".to_string(), + salt: Some(vec![1, 1, 1]), + custom_fields: HashMap::new(), + }, + VaultEntry { + scope_path: vec!["a".to_string(), "a2".to_string()], + description: "Second child".to_string(), + encrypted_content: "encrypted2".to_string(), + salt: Some(vec![2, 2, 2]), + custom_fields: HashMap::new(), + }, + ], + raw_lines: vec![], + file_path: None, + }; + + let formatted = parser.format(&doc); + + // Find the positions of each entry in the output + let a_pos = formatted.find("[a]").expect("Should find [a]"); + let a1_pos = formatted.find("[a.a1]").expect("Should find [a.a1]"); + let a2_pos = formatted.find("[a.a2]").expect("Should find [a.a2]"); + let a3_pos = formatted.find("[a.a3]").expect("Should find [a.a3]"); + + // Verify parent comes first + assert!(a_pos < a1_pos); + assert!(a_pos < a2_pos); + assert!(a_pos < a3_pos); + + // Verify children maintain exact insertion order (a3, a1, a2) + assert!( + a3_pos < a1_pos, + "a3 should come before a1 (insertion order)" + ); + assert!( + a1_pos < a2_pos, + "a1 should come before a2 (insertion order)" + ); + } + + #[test] + fn test_implicit_parent_creation() { + let content = r#" +version = "v0.3" + +[work.databases.production] +description = "Production DB" +encrypted = "encrypted_data" +salt = "salt_data" +"#; + + let parser = TomlParser::new(); + let doc = parser.parse(content).unwrap(); + + // Should create implicit parents + assert_eq!(doc.entries.len(), 3); + assert_eq!(doc.entries[0].scope_path, vec!["work"]); + assert_eq!(doc.entries[1].scope_path, vec!["work", "databases"]); + assert_eq!( + doc.entries[2].scope_path, + vec!["work", "databases", "production"] + ); + } + + #[test] + fn test_custom_fields_preservation() { + let content = r#" +version = "v0.3" + +[personal.banking] +description = "Banking credentials" +encrypted = "encrypted_data" +salt = "salt_data" +expires = "2025-12-31" +priority = "high" +tags = ["finance", "important"] +"#; + + let parser = TomlParser::new(); + let doc = parser.parse(content).unwrap(); + + let banking_entry = doc + .find_entry(&["personal".to_string(), "banking".to_string()]) + .unwrap(); + assert_eq!( + banking_entry.custom_fields.get("expires").unwrap().as_str(), + Some("2025-12-31") + ); + assert_eq!( + banking_entry + .custom_fields + .get("priority") + .unwrap() + .as_str(), + Some("high") + ); + assert!(banking_entry.custom_fields.contains_key("tags")); + } + + #[test] + fn test_format_document() { + let mut doc = VaultDocument::new(); + + // Add entry with custom fields + let mut custom_fields = HashMap::new(); + custom_fields.insert( + "expires".to_string(), + toml::Value::String("2025-12-31".to_string()), + ); + + let entry = VaultEntry { + scope_path: vec!["work".to_string(), "email".to_string()], + description: "Work email".to_string(), + encrypted_content: "encrypted".to_string(), + salt: Some(b"salt".to_vec()), + custom_fields, + }; + + doc.entries.push(entry); + + let parser = TomlParser::new(); + let formatted = parser.format(&doc); + + assert!(formatted.contains("version = \"v0.3\"")); + assert!(formatted.contains("[work.email]")); // Now using native TOML dotted notation + assert!(formatted.contains("description = \"Work email\"")); + assert!(formatted.contains("expires = \"2025-12-31\"")); + } +} diff --git a/src/utils.rs b/src/utils.rs index 12ac03f..cbc2277 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -12,7 +12,7 @@ use std::os::unix::fs::PermissionsExt; /// Parse a scope path string into components. pub fn parse_scope_path(scope: &str) -> Vec { scope - .split('/') + .split('.') .filter(|s| !s.is_empty()) .map(|s| s.to_string()) .collect() @@ -20,7 +20,7 @@ pub fn parse_scope_path(scope: &str) -> Vec { /// Format a scope path as a string. pub fn format_scope_path(parts: &[String]) -> String { - parts.join("/") + parts.join(".") } /// Validate a scope name. @@ -35,19 +35,31 @@ pub fn validate_scope_name(scope: &str) -> bool { return false; } + // Don't allow leading or trailing dots + if scope.starts_with('.') || scope.ends_with('.') { + return false; + } + + // Don't allow consecutive dots + if scope.contains("..") { + return false; + } + // Check each part - for part in scope.split('/') { + let parts: Vec<&str> = scope.split('.').collect(); + for part in parts.iter() { + // All parts should be non-empty after the above checks if part.is_empty() { - continue; + return false; } - // Don't allow . or .. as entire part - if part == "." || part == ".." { + // Don't allow . or .. as entire part (already handled by consecutive dots check) + if *part == "." || *part == ".." { return false; } // First character restrictions - if let Some(first) = part.chars().next() { + if let Some(first) = (*part).chars().next() { if first == '-' || first == '_' { return false; } @@ -62,7 +74,13 @@ pub fn find_vault_file() -> Option { let current_dir = std::env::current_dir().ok()?; // Check current directory - for name in &["vault.md", ".vault.md", "credentials.md"] { + for name in &[ + "vault.toml", + ".vault.toml", + "credentials.toml", + "vault.md", + ".vault.md", + ] { let path = current_dir.join(name); if path.exists() { return Some(path); @@ -72,7 +90,13 @@ pub fn find_vault_file() -> Option { // Check parent directories let mut dir = current_dir.parent(); while let Some(parent) = dir { - for name in &["vault.md", ".vault.md", "credentials.md"] { + for name in &[ + "vault.toml", + ".vault.toml", + "credentials.toml", + "vault.md", + ".vault.md", + ] { let path = parent.join(name); if path.exists() { return Some(path); @@ -210,12 +234,12 @@ pub fn format_tree(scopes: &[String]) -> Vec { let mut last_parts: Vec = Vec::new(); for (i, scope) in scopes.iter().enumerate() { - let parts: Vec<&str> = scope.split('/').collect(); + let parts: Vec<&str> = scope.split('.').collect(); let level = parts.len() - 1; let is_last = i == scopes.len() - 1 || { // Check if this is the last item at its level if i + 1 < scopes.len() { - let next_parts: Vec<&str> = scopes[i + 1].split('/').collect(); + let next_parts: Vec<&str> = scopes[i + 1].split('.').collect(); next_parts.len() <= parts.len() || next_parts[..level] != parts[..level] } else { true @@ -349,33 +373,35 @@ mod tests { #[test] fn test_parse_scope_path() { assert_eq!( - parse_scope_path("personal/banking/chase"), + parse_scope_path("personal.banking.chase"), vec!["personal", "banking", "chase"] ); - assert_eq!(parse_scope_path("/personal/"), vec!["personal"]); + assert_eq!(parse_scope_path(".personal."), vec!["personal"]); assert_eq!(parse_scope_path(""), Vec::::new()); } #[test] fn test_validate_scope_name() { - assert!(validate_scope_name("personal/banking")); + assert!(validate_scope_name("personal.banking")); assert!(validate_scope_name("work-stuff")); assert!(validate_scope_name("test_123")); assert!(!validate_scope_name("")); assert!(!validate_scope_name("with")); assert!(!validate_scope_name("with|pipe")); - assert!(!validate_scope_name("personal/.")); - assert!(!validate_scope_name("personal/..")); + assert!(!validate_scope_name("personal..")); + assert!(!validate_scope_name("personal...")); + assert!(!validate_scope_name(".personal")); + assert!(!validate_scope_name("personal.")); } #[test] fn test_format_tree() { let scopes = vec![ "personal".to_string(), - "personal/banking".to_string(), - "personal/banking/chase".to_string(), - "personal/email".to_string(), + "personal.banking".to_string(), + "personal.banking.chase".to_string(), + "personal.email".to_string(), "work".to_string(), ]; diff --git a/tests/group_insertion_test.rs b/tests/group_insertion_test.rs new file mode 100644 index 0000000..f879b33 --- /dev/null +++ b/tests/group_insertion_test.rs @@ -0,0 +1,99 @@ +use std::collections::HashMap; +use vaultify::{ + models::{VaultDocument, VaultEntry}, + toml_parser::TomlParser, +}; + +#[test] +fn test_smart_group_insertion() { + // Create a document with initial entries + let mut doc = VaultDocument::new(); + + // Add a.a2 + doc.add_entry(VaultEntry { + scope_path: vec!["a".to_string(), "a2".to_string()], + description: "a.a2 credentials".to_string(), + encrypted_content: "encrypted_a_a2".to_string(), + salt: Some(vec![1, 2, 3]), + custom_fields: HashMap::new(), + }) + .unwrap(); + + // Add b.a2 + doc.add_entry(VaultEntry { + scope_path: vec!["b".to_string(), "a2".to_string()], + description: "b.a2 credentials".to_string(), + encrypted_content: "encrypted_b_a2".to_string(), + salt: Some(vec![4, 5, 6]), + custom_fields: HashMap::new(), + }) + .unwrap(); + + // Add a.a11 - should be inserted after a.a2, not at the end + doc.add_entry(VaultEntry { + scope_path: vec!["a".to_string(), "a11".to_string()], + description: "a.a11 credentials".to_string(), + encrypted_content: "encrypted_a_a11".to_string(), + salt: Some(vec![7, 8, 9]), + custom_fields: HashMap::new(), + }) + .unwrap(); + + // Verify the order + assert_eq!(doc.entries.len(), 3); + assert_eq!(doc.entries[0].scope_path, vec!["a", "a2"]); + assert_eq!(doc.entries[1].scope_path, vec!["a", "a11"]); // Should be here, not at end + assert_eq!(doc.entries[2].scope_path, vec!["b", "a2"]); + + // Test TOML formatting preserves the order + let parser = TomlParser::new(); + let toml_output = parser.format(&doc); + + // Find positions of each entry in the output + let a_a2_pos = toml_output.find("[a.a2]").expect("Should find [a.a2]"); + let a_a11_pos = toml_output.find("[a.a11]").expect("Should find [a.a11]"); + let b_a2_pos = toml_output.find("[b.a2]").expect("Should find [b.a2]"); + + // Verify order in TOML output + assert!(a_a2_pos < a_a11_pos, "a.a2 should come before a.a11"); + assert!(a_a11_pos < b_a2_pos, "a.a11 should come before b.a2"); +} + +#[test] +fn test_multiple_level_group_insertion() { + let mut doc = VaultDocument::new(); + + // Add entries in mixed order + doc.add_entry(VaultEntry { + scope_path: vec!["work".to_string(), "email".to_string(), "gmail".to_string()], + description: "Work Gmail".to_string(), + encrypted_content: "enc1".to_string(), + salt: Some(vec![1]), + custom_fields: HashMap::new(), + }) + .unwrap(); + + doc.add_entry(VaultEntry { + scope_path: vec!["personal".to_string(), "banking".to_string()], + description: "Personal banking".to_string(), + encrypted_content: "enc2".to_string(), + salt: Some(vec![2]), + custom_fields: HashMap::new(), + }) + .unwrap(); + + // Add another work entry - should go after existing work entries + doc.add_entry(VaultEntry { + scope_path: vec!["work".to_string(), "vpn".to_string()], + description: "Work VPN".to_string(), + encrypted_content: "enc3".to_string(), + salt: Some(vec![3]), + custom_fields: HashMap::new(), + }) + .unwrap(); + + // Verify order + assert_eq!(doc.entries[0].scope_path, vec!["work", "email", "gmail"]); + assert_eq!(doc.entries[1].scope_path, vec!["work", "vpn"]); // Grouped with work + assert_eq!(doc.entries[2].scope_path, vec!["personal", "banking"]); +} diff --git a/tests/toml_integration.rs b/tests/toml_integration.rs new file mode 100644 index 0000000..ebbef2c --- /dev/null +++ b/tests/toml_integration.rs @@ -0,0 +1,90 @@ +use std::collections::HashMap; +use tempfile::TempDir; +use vaultify::models::VaultEntry; +use vaultify::service::VaultService; + +#[test] +fn test_toml_format_integration() { + let temp_dir = TempDir::new().unwrap(); + let vault_path = temp_dir.path().join("test.toml"); + + // Create initial TOML content + let initial_content = r#"version = "v0.3" +created = "2025-01-17T10:00:00Z" + +[personal.banking] +description = "Banking info" +encrypted = "YmFzZTY0X2VuY3J5cHRlZF9kYXRh" +salt = "YmFzZTY0X3NhbHQ=" +last_rotated = "2025-01-10" +"#; + + std::fs::write(&vault_path, initial_content).unwrap(); + + let service = VaultService::new(); + + // Load TOML vault + let mut doc = service.load_vault(&vault_path).unwrap(); + assert_eq!(doc.entries.len(), 2); // personal + personal/banking + + // Check implicit parent was created + assert_eq!(doc.entries[0].scope_path, vec!["personal"]); + assert_eq!(doc.entries[1].scope_path, vec!["personal", "banking"]); + + // Check custom field was preserved + let banking_entry = doc + .entries + .iter() + .find(|e| e.scope_path == vec!["personal", "banking"]) + .unwrap(); + assert!(banking_entry.custom_fields.contains_key("last_rotated")); + + // Add a new entry with custom fields + let mut custom_fields = HashMap::new(); + custom_fields.insert( + "priority".to_string(), + toml::Value::String("high".to_string()), + ); + custom_fields.insert( + "tags".to_string(), + toml::Value::Array(vec![ + toml::Value::String("production".to_string()), + toml::Value::String("critical".to_string()), + ]), + ); + + let new_entry = VaultEntry { + scope_path: vec!["work".to_string(), "servers".to_string()], + description: "Server credentials".to_string(), + encrypted_content: "dGVzdC1lbmNyeXB0ZWQ=".to_string(), + salt: Some(vec![1, 2, 3, 4]), + custom_fields, + }; + + doc.entries.push(new_entry); + + // Save back to TOML + service.save_vault(&doc, &vault_path).unwrap(); + + // Read and verify the saved content + let saved_content = std::fs::read_to_string(&vault_path).unwrap(); + + println!("Saved TOML content:\n{}", saved_content); + + // Verify TOML structure + assert!(saved_content.contains("version = \"v0.3\"")); + assert!(saved_content.contains("[personal.banking]")); // Native TOML dotted notation + assert!(saved_content.contains("last_rotated = \"2025-01-10\"")); // Custom field preserved + assert!(saved_content.contains("[work.servers]")); // Native TOML dotted notation + assert!(saved_content.contains("priority = \"high\"")); + // TOML might format arrays differently + assert!( + saved_content.contains("tags = ") + && saved_content.contains("production") + && saved_content.contains("critical") + ); + + // Load again to verify round-trip + let doc2 = service.load_vault(&vault_path).unwrap(); + assert_eq!(doc2.entries.len(), 4); // personal, personal/banking, work, work/servers +}