From 66d338a065176f44329e54a256056933a597075b Mon Sep 17 00:00:00 2001 From: 0xasun Date: Wed, 16 Jul 2025 18:08:29 -0600 Subject: [PATCH 1/8] fix(parser): correct heading levels and blank line formatting for vault entries - Fix heading level calculation: root entries now use level 2 (##) instead of level 1 (#) by changing formula from scope_parts.len() to scope_parts.len() + 1 - Fix blank line formatting for first entry after root header to prevent format corruption - Add context-aware formatting with format_entry_with_context() method - Add post-save validation to detect corruption immediately - Add comprehensive test suite with 7 new tests for multiple root entry scenarios These fixes ensure vault files with multiple root-level entries can be saved and loaded reliably without data loss or corruption. --- src/models.rs | 44 +++-- src/parser.rs | 427 ++++++++++++++++++++++++++++++++++++++++++++++++- src/service.rs | 4 +- 3 files changed, 459 insertions(+), 16 deletions(-) diff --git a/src/models.rs b/src/models.rs index e2d2d0d..4614e42 100644 --- a/src/models.rs +++ b/src/models.rs @@ -117,9 +117,6 @@ impl VaultDocument { // 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] @@ -132,17 +129,24 @@ impl VaultDocument { // Create any missing ancestors let ancestors = VaultParser::create_missing_ancestors(self, &entry.scope_path); + // Check if this will be the first entry (right after root) + let is_first_entry = self.entries.is_empty() && ancestors.is_empty(); + // 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()); + for (i, ancestor) in ancestors.iter().enumerate() { + let is_first_ancestor = self.entries.is_empty() && i == 0; + let ancestor_lines = parser.format_entry_with_context(ancestor, is_first_ancestor); + for (j, line) in ancestor_lines.iter().enumerate() { + self.raw_lines.insert(current_insert + j, line.clone()); } current_insert += ancestor_lines.len(); - self.entries.push(ancestor); + self.entries.push(ancestor.clone()); } + // Format the new entry with context + let entry_lines = parser.format_entry_with_context(&entry, is_first_entry); + // Insert the new entry for (i, line) in entry_lines.iter().enumerate() { self.raw_lines.insert(current_insert + i, line.clone()); @@ -160,11 +164,31 @@ impl VaultDocument { Ok(()) } - /// Save the document to a file. + /// Save the document to a file with post-save validation. 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) + + // Write the file + fs::write(path, &content)?; + + // Post-save validation: try to parse the file we just wrote + use crate::parser::VaultParser; + let parser = VaultParser::new(); + match parser.parse(&content) { + Ok(parsed_doc) => { + // Verify we have the same number of entries + if parsed_doc.entries.len() != self.entries.len() { + eprintln!("Warning: Saved vault has {} entries but expected {}", + parsed_doc.entries.len(), self.entries.len()); + } + } + Err(e) => { + eprintln!("Warning: Saved vault file may be corrupted: {}", e); + } + } + + Ok(()) } } diff --git a/src/parser.rs b/src/parser.rs index bdf92e3..3801b55 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -222,10 +222,19 @@ impl VaultParser { /// Format a vault entry as markdown lines. pub fn format_entry(&self, entry: &VaultEntry) -> Vec { + self.format_entry_with_context(entry, false) + } + + /// Format a vault entry as markdown lines with context about position. + pub fn format_entry_with_context( + &self, + entry: &VaultEntry, + is_first_entry: bool, + ) -> Vec { let mut lines = Vec::new(); - // Add blank line before heading (except for root) - if entry.heading_level > 1 { + // Add blank line before heading (except for the first entry after root) + if entry.heading_level > 1 && !is_first_entry { lines.push("\n".to_string()); } @@ -309,7 +318,7 @@ impl VaultParser { // Create ancestor entry let ancestor = VaultEntry { scope_path: ancestor_path.to_vec(), - heading_level: ancestor_path.len() as u8, + heading_level: (ancestor_path.len() + 1) as u8, // +1 because # root is level 1 description: format!("{} secrets", ancestor_path.join("/")), encrypted_content: String::new(), start_line: 0, // Will be set during insertion @@ -406,7 +415,7 @@ Test fn test_format_entry() { let entry = VaultEntry { scope_path: vec!["test".to_string(), "item".to_string()], - heading_level: 2, + heading_level: 3, // Should be 3 for nested entries (test/item) description: "Test entry".to_string(), encrypted_content: "encrypted_data".to_string(), salt: Some(b"test_salt_bytes".to_vec()), @@ -422,4 +431,414 @@ Test assert!(content.contains("salt=")); assert!(content.contains("encrypted_data")); } + + #[test] + fn test_multiple_root_entries_save_reload() { + use tempfile::TempDir; + + let parser = VaultParser::new(); + let temp_dir = TempDir::new().unwrap(); + let vault_path = temp_dir.path().join("test_vault.md"); + + // Create a vault with multiple root entries + let mut doc = VaultDocument::new(); + + // Add first root entry + let entry1 = VaultEntry { + scope_path: vec!["work".to_string()], + heading_level: 2, // Should be 2 for root-level entries + description: "Work credentials".to_string(), + encrypted_content: "encrypted_work_data".to_string(), + salt: Some(b"work_salt".to_vec()), + start_line: 0, + end_line: 0, + }; + doc.add_entry(entry1).unwrap(); + + // Add second root entry + let entry2 = VaultEntry { + scope_path: vec!["personal".to_string()], + heading_level: 2, // Should be 2 for root-level entries + description: "Personal accounts".to_string(), + encrypted_content: "encrypted_personal_data".to_string(), + salt: Some(b"personal_salt".to_vec()), + start_line: 0, + end_line: 0, + }; + doc.add_entry(entry2).unwrap(); + + // Add third root entry + let entry3 = VaultEntry { + scope_path: vec!["finance".to_string()], + heading_level: 2, // Should be 2 for root-level entries + description: "Financial accounts".to_string(), + encrypted_content: "encrypted_finance_data".to_string(), + salt: Some(b"finance_salt".to_vec()), + start_line: 0, + end_line: 0, + }; + doc.add_entry(entry3).unwrap(); + + // Save the document + doc.save(&vault_path).unwrap(); + + // Print the saved content for debugging + let saved_content = std::fs::read_to_string(&vault_path).unwrap(); + println!("Saved vault content:\n{}", saved_content); + + // Reload the document + let reloaded_doc = parser.parse_file(&vault_path).unwrap(); + + // Verify all entries were preserved + assert_eq!(reloaded_doc.entries.len(), 3); + + // Verify each entry's data + let work_entry = reloaded_doc.find_entry(&["work".to_string()]).unwrap(); + assert_eq!(work_entry.description, "Work credentials"); + assert_eq!(work_entry.encrypted_content, "encrypted_work_data"); + + let personal_entry = reloaded_doc.find_entry(&["personal".to_string()]).unwrap(); + assert_eq!(personal_entry.description, "Personal accounts"); + assert_eq!(personal_entry.encrypted_content, "encrypted_personal_data"); + + let finance_entry = reloaded_doc.find_entry(&["finance".to_string()]).unwrap(); + assert_eq!(finance_entry.description, "Financial accounts"); + assert_eq!(finance_entry.encrypted_content, "encrypted_finance_data"); + } + + #[test] + fn test_multiple_root_entries_edge_cases() { + use tempfile::TempDir; + + let parser = VaultParser::new(); + let temp_dir = TempDir::new().unwrap(); + let vault_path = temp_dir.path().join("test_edge_cases.md"); + + // Create vault content manually to test specific formatting issues + let vault_content = r#"# root + +## work + +Work stuff + + +YmFzZTY0X2VuY3J5cHRlZF9kYXRh + + +## personal + +Personal items + + +YW5vdGhlcl9lbmNyeXB0ZWRfZGF0YQ== + + +## finance + +Financial data + + +ZmluYW5jZV9lbmNyeXB0ZWRfZGF0YQ== + +"#; + + // Write the vault content + std::fs::write(&vault_path, vault_content).unwrap(); + + // Parse it + let doc = parser.parse_file(&vault_path).unwrap(); + assert_eq!(doc.entries.len(), 3); + + // Now save it again + doc.save(&vault_path).unwrap(); + + // Read the saved content + let saved_content = std::fs::read_to_string(&vault_path).unwrap(); + println!("After re-save:\n{}", saved_content); + + // Try to parse again + let reloaded_doc = parser.parse_file(&vault_path).unwrap(); + assert_eq!(reloaded_doc.entries.len(), 3); + + // Verify content integrity + for (original, reloaded) in doc.entries.iter().zip(reloaded_doc.entries.iter()) { + assert_eq!(original.scope_path, reloaded.scope_path); + assert_eq!(original.description, reloaded.description); + assert_eq!(original.encrypted_content, reloaded.encrypted_content); + } + } + + #[test] + fn test_blank_line_formatting_bug() { + use tempfile::TempDir; + + let parser = VaultParser::new(); + let temp_dir = TempDir::new().unwrap(); + let vault_path = temp_dir.path().join("test_blank_lines.md"); + + // Start with an empty vault + let mut doc = VaultDocument::new(); + + // Add first root entry - this should NOT have a blank line before it + let entry1 = VaultEntry { + scope_path: vec!["first".to_string()], + heading_level: 2, // Should be 2 for root-level entries + description: "First entry".to_string(), + encrypted_content: "data1".to_string(), + salt: Some(b"salt1".to_vec()), + start_line: 0, + end_line: 0, + }; + doc.add_entry(entry1).unwrap(); + + // Check the formatting + doc.save(&vault_path).unwrap(); + let content1 = std::fs::read_to_string(&vault_path).unwrap(); + println!("After first entry:\n{}", content1); + + // The first root entry should NOT have a blank line after the root header + assert!( + !content1.contains("# root \n\n\n"), + "First root entry should not have double blank line" + ); + + // Add second root entry + let entry2 = VaultEntry { + scope_path: vec!["second".to_string()], + heading_level: 2, // Should be 2 for root-level entries + description: "Second entry".to_string(), + encrypted_content: "data2".to_string(), + salt: Some(b"salt2".to_vec()), + start_line: 0, + end_line: 0, + }; + + // Reload and add to ensure we test the full cycle + let mut doc2 = parser.parse_file(&vault_path).unwrap(); + doc2.add_entry(entry2).unwrap(); + doc2.save(&vault_path).unwrap(); + + let content2 = std::fs::read_to_string(&vault_path).unwrap(); + println!("After second entry:\n{}", content2); + + // Parse again to verify + let final_doc = parser.parse_file(&vault_path).unwrap(); + assert_eq!(final_doc.entries.len(), 2); + } + + #[test] + fn test_empty_root_entries() { + use tempfile::TempDir; + + let parser = VaultParser::new(); + let temp_dir = TempDir::new().unwrap(); + let vault_path = temp_dir.path().join("test_empty_roots.md"); + + let mut doc = VaultDocument::new(); + + // Add empty root entries (no encrypted content) + let entry1 = VaultEntry { + scope_path: vec!["empty1".to_string()], + heading_level: 2, // Should be 2 for root-level entries + description: "Empty container 1".to_string(), + encrypted_content: String::new(), + salt: None, + start_line: 0, + end_line: 0, + }; + doc.add_entry(entry1).unwrap(); + + let entry2 = VaultEntry { + scope_path: vec!["empty2".to_string()], + heading_level: 2, // Should be 2 for root-level entries + description: "Empty container 2".to_string(), + encrypted_content: String::new(), + salt: None, + start_line: 0, + end_line: 0, + }; + doc.add_entry(entry2).unwrap(); + + // Save and reload + doc.save(&vault_path).unwrap(); + let reloaded = parser.parse_file(&vault_path).unwrap(); + + assert_eq!(reloaded.entries.len(), 2); + assert_eq!(reloaded.entries[0].encrypted_content, ""); + assert_eq!(reloaded.entries[1].encrypted_content, ""); + } + + #[test] + fn test_special_characters_in_scope_names() { + use tempfile::TempDir; + + let parser = VaultParser::new(); + let temp_dir = TempDir::new().unwrap(); + let vault_path = temp_dir.path().join("test_special_chars.md"); + + let mut doc = VaultDocument::new(); + + // Add entries with special characters + let entry1 = VaultEntry { + scope_path: vec!["work-2024".to_string()], + heading_level: 2, // Should be 2 for root-level entries + description: "Work with dash".to_string(), + encrypted_content: "data1".to_string(), + salt: Some(b"salt1".to_vec()), + start_line: 0, + end_line: 0, + }; + doc.add_entry(entry1).unwrap(); + + let entry2 = VaultEntry { + scope_path: vec!["personal_backup".to_string()], + heading_level: 2, // Should be 2 for root-level entries + description: "Personal with underscore".to_string(), + encrypted_content: "data2".to_string(), + salt: Some(b"salt2".to_vec()), + start_line: 0, + end_line: 0, + }; + doc.add_entry(entry2).unwrap(); + + let entry3 = VaultEntry { + scope_path: vec!["email@domain".to_string()], + heading_level: 2, // Should be 2 for root-level entries + description: "Email with at sign".to_string(), + encrypted_content: "data3".to_string(), + salt: Some(b"salt3".to_vec()), + start_line: 0, + end_line: 0, + }; + doc.add_entry(entry3).unwrap(); + + // Save and reload + doc.save(&vault_path).unwrap(); + let reloaded = parser.parse_file(&vault_path).unwrap(); + + assert_eq!(reloaded.entries.len(), 3); + assert_eq!(reloaded.entries[0].scope_path[0], "work-2024"); + assert_eq!(reloaded.entries[1].scope_path[0], "personal_backup"); + assert_eq!(reloaded.entries[2].scope_path[0], "email@domain"); + } + + #[test] + fn test_many_root_entries() { + use tempfile::TempDir; + + let parser = VaultParser::new(); + let temp_dir = TempDir::new().unwrap(); + let vault_path = temp_dir.path().join("test_many_roots.md"); + + let mut doc = VaultDocument::new(); + + // Add 10 root entries + for i in 0..10 { + let entry = VaultEntry { + scope_path: vec![format!("root{}", i)], + heading_level: 2, // Should be 2 for root-level entries + description: format!("Root entry {}", i), + encrypted_content: format!("data{}", i), + salt: Some(format!("salt{}", i).as_bytes().to_vec()), + start_line: 0, + end_line: 0, + }; + doc.add_entry(entry).unwrap(); + } + + // Save and reload multiple times to test stability + for _ in 0..3 { + doc.save(&vault_path).unwrap(); + doc = parser.parse_file(&vault_path).unwrap(); + } + + // Verify all entries preserved + assert_eq!(doc.entries.len(), 10); + for i in 0..10 { + assert_eq!(doc.entries[i].scope_path[0], format!("root{}", i)); + assert_eq!(doc.entries[i].encrypted_content, format!("data{}", i)); + } + } + + #[test] + fn test_root_entries_with_children() { + use tempfile::TempDir; + + let parser = VaultParser::new(); + let temp_dir = TempDir::new().unwrap(); + let vault_path = temp_dir.path().join("test_vault_nested.md"); + + // Create a vault with root entries that have children + let mut doc = VaultDocument::new(); + + // Add first root entry + let work_root = VaultEntry { + scope_path: vec!["work".to_string()], + heading_level: 2, // Should be 2 for root-level entries + description: "Work credentials".to_string(), + encrypted_content: String::new(), + salt: None, + start_line: 0, + end_line: 0, + }; + doc.add_entry(work_root).unwrap(); + + // Add child of work + let work_email = VaultEntry { + scope_path: vec!["work".to_string(), "email".to_string()], + heading_level: 3, + description: "Work email".to_string(), + encrypted_content: "encrypted_work_email".to_string(), + salt: Some(b"work_email_salt".to_vec()), + start_line: 0, + end_line: 0, + }; + doc.add_entry(work_email).unwrap(); + + // Add second root entry + let personal_root = VaultEntry { + scope_path: vec!["personal".to_string()], + heading_level: 2, // Should be 2 for root-level entries + description: "Personal accounts".to_string(), + encrypted_content: String::new(), + salt: None, + start_line: 0, + end_line: 0, + }; + doc.add_entry(personal_root).unwrap(); + + // Add child of personal + let personal_bank = VaultEntry { + scope_path: vec!["personal".to_string(), "bank".to_string()], + heading_level: 3, + description: "Personal banking".to_string(), + encrypted_content: "encrypted_bank_data".to_string(), + salt: Some(b"bank_salt".to_vec()), + start_line: 0, + end_line: 0, + }; + doc.add_entry(personal_bank).unwrap(); + + // Save and reload + doc.save(&vault_path).unwrap(); + let reloaded_doc = parser.parse_file(&vault_path).unwrap(); + + // Verify structure + assert_eq!(reloaded_doc.entries.len(), 4); + + // Verify root entries + assert!(reloaded_doc.find_entry(&["work".to_string()]).is_some()); + assert!(reloaded_doc.find_entry(&["personal".to_string()]).is_some()); + + // Verify children + let work_email_entry = reloaded_doc + .find_entry(&["work".to_string(), "email".to_string()]) + .unwrap(); + assert_eq!(work_email_entry.encrypted_content, "encrypted_work_email"); + + let bank_entry = reloaded_doc + .find_entry(&["personal".to_string(), "bank".to_string()]) + .unwrap(); + assert_eq!(bank_entry.encrypted_content, "encrypted_bank_data"); + } } diff --git a/src/service.rs b/src/service.rs index 1afcc88..cf4607e 100644 --- a/src/service.rs +++ b/src/service.rs @@ -68,7 +68,7 @@ impl VaultService { // Create new entry let entry = VaultEntry { scope_path: scope_parts.clone(), - heading_level: scope_parts.len() as u8, + heading_level: (scope_parts.len() + 1) as u8, // +1 because # root is level 1 description, encrypted_content, salt: Some(salt), @@ -178,7 +178,7 @@ impl VaultService { // Update scope entry.scope_path = new_scope_parts.clone(); - entry.heading_level = new_scope_parts.len() as u8; + entry.heading_level = (new_scope_parts.len() + 1) as u8; // +1 because # root is level 1 Ok(()) } From 8143884e5571defaab1bfe68ef1a792719a1aca9 Mon Sep 17 00:00:00 2001 From: 0xasun Date: Wed, 16 Jul 2025 20:23:48 -0600 Subject: [PATCH 2/8] feat: implement TOML format support (v0.3) - Add TOML parser module with flexible parsing behavior - Support implicit parent node creation - Preserve custom fields for forward compatibility - Add format auto-detection (Markdown vs TOML) - Update VaultEntry to include custom_fields HashMap - Add comprehensive test suite for TOML functionality - Update service layer to use appropriate parser based on content - Maintain backward compatibility with Markdown format --- Cargo.lock | 85 +++++++- Cargo.toml | 3 +- src/lib.rs | 1 + src/models.rs | 26 ++- src/parser.rs | 19 ++ src/service.rs | 73 ++++++- src/toml_parser.rs | 418 ++++++++++++++++++++++++++++++++++++++ tests/toml_integration.rs | 121 +++++++++++ 8 files changed, 730 insertions(+), 16 deletions(-) create mode 100644 src/toml_parser.rs create mode 100644 tests/toml_integration.rs 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/src/lib.rs b/src/lib.rs index 24add38..1b2f5bb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,6 +11,7 @@ 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/models.rs b/src/models.rs index 4614e42..6dc595b 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,18 +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) + /// Markdown heading depth (1-6) - used for Markdown format pub heading_level: u8, /// Plain text description pub description: String, /// Base64 encrypted secret pub encrypted_content: String, - /// Starting line in file (0-based) + /// Starting line in file (0-based) - used for Markdown format pub start_line: usize, - /// Ending line in file (0-based) + /// Ending line in file (0-based) - used for Markdown format 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 { @@ -168,10 +172,10 @@ impl VaultDocument { pub fn save(&self, path: &std::path::Path) -> Result<(), std::io::Error> { use std::fs; let content = self.raw_lines.join(""); - + // Write the file fs::write(path, &content)?; - + // Post-save validation: try to parse the file we just wrote use crate::parser::VaultParser; let parser = VaultParser::new(); @@ -179,15 +183,18 @@ impl VaultDocument { Ok(parsed_doc) => { // Verify we have the same number of entries if parsed_doc.entries.len() != self.entries.len() { - eprintln!("Warning: Saved vault has {} entries but expected {}", - parsed_doc.entries.len(), self.entries.len()); + eprintln!( + "Warning: Saved vault has {} entries but expected {}", + parsed_doc.entries.len(), + self.entries.len() + ); } } Err(e) => { eprintln!("Warning: Saved vault file may be corrupted: {}", e); } } - + Ok(()) } } @@ -206,6 +213,7 @@ mod tests { start_line: 0, end_line: 5, salt: None, + custom_fields: HashMap::new(), }; assert_eq!(entry.scope_string(), "personal/banking"); } @@ -220,6 +228,7 @@ mod tests { start_line: 0, end_line: 5, salt: None, + custom_fields: HashMap::new(), }; let child = VaultEntry { @@ -230,6 +239,7 @@ mod tests { start_line: 6, end_line: 10, salt: None, + custom_fields: HashMap::new(), }; assert!(parent.is_parent_of(&child)); diff --git a/src/parser.rs b/src/parser.rs index 3801b55..51c3e50 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -3,6 +3,7 @@ use crate::crypto::VaultCrypto; use crate::models::{VaultDocument, VaultEntry}; use regex::Regex; +use std::collections::HashMap; use std::path::Path; use thiserror::Error; @@ -215,6 +216,7 @@ impl VaultParser { start_line: start_idx, end_line: current_idx.saturating_sub(1), salt: entry_salt, + custom_fields: HashMap::new(), }; Ok(Some((entry, current_idx))) @@ -324,6 +326,7 @@ impl VaultParser { start_line: 0, // Will be set during insertion end_line: 0, salt: None, + custom_fields: HashMap::new(), }; ancestors_to_create.push(ancestor); } @@ -421,6 +424,7 @@ Test salt: Some(b"test_salt_bytes".to_vec()), start_line: 0, end_line: 0, + custom_fields: HashMap::new(), }; let parser = VaultParser::new(); @@ -452,6 +456,7 @@ Test salt: Some(b"work_salt".to_vec()), start_line: 0, end_line: 0, + custom_fields: HashMap::new(), }; doc.add_entry(entry1).unwrap(); @@ -464,6 +469,7 @@ Test salt: Some(b"personal_salt".to_vec()), start_line: 0, end_line: 0, + custom_fields: HashMap::new(), }; doc.add_entry(entry2).unwrap(); @@ -476,6 +482,7 @@ Test salt: Some(b"finance_salt".to_vec()), start_line: 0, end_line: 0, + custom_fields: HashMap::new(), }; doc.add_entry(entry3).unwrap(); @@ -588,6 +595,7 @@ ZmluYW5jZV9lbmNyeXB0ZWRfZGF0YQ== salt: Some(b"salt1".to_vec()), start_line: 0, end_line: 0, + custom_fields: HashMap::new(), }; doc.add_entry(entry1).unwrap(); @@ -611,6 +619,7 @@ ZmluYW5jZV9lbmNyeXB0ZWRfZGF0YQ== salt: Some(b"salt2".to_vec()), start_line: 0, end_line: 0, + custom_fields: HashMap::new(), }; // Reload and add to ensure we test the full cycle @@ -645,6 +654,7 @@ ZmluYW5jZV9lbmNyeXB0ZWRfZGF0YQ== salt: None, start_line: 0, end_line: 0, + custom_fields: HashMap::new(), }; doc.add_entry(entry1).unwrap(); @@ -656,6 +666,7 @@ ZmluYW5jZV9lbmNyeXB0ZWRfZGF0YQ== salt: None, start_line: 0, end_line: 0, + custom_fields: HashMap::new(), }; doc.add_entry(entry2).unwrap(); @@ -687,6 +698,7 @@ ZmluYW5jZV9lbmNyeXB0ZWRfZGF0YQ== salt: Some(b"salt1".to_vec()), start_line: 0, end_line: 0, + custom_fields: HashMap::new(), }; doc.add_entry(entry1).unwrap(); @@ -698,6 +710,7 @@ ZmluYW5jZV9lbmNyeXB0ZWRfZGF0YQ== salt: Some(b"salt2".to_vec()), start_line: 0, end_line: 0, + custom_fields: HashMap::new(), }; doc.add_entry(entry2).unwrap(); @@ -709,6 +722,7 @@ ZmluYW5jZV9lbmNyeXB0ZWRfZGF0YQ== salt: Some(b"salt3".to_vec()), start_line: 0, end_line: 0, + custom_fields: HashMap::new(), }; doc.add_entry(entry3).unwrap(); @@ -742,6 +756,7 @@ ZmluYW5jZV9lbmNyeXB0ZWRfZGF0YQ== salt: Some(format!("salt{}", i).as_bytes().to_vec()), start_line: 0, end_line: 0, + custom_fields: HashMap::new(), }; doc.add_entry(entry).unwrap(); } @@ -780,6 +795,7 @@ ZmluYW5jZV9lbmNyeXB0ZWRfZGF0YQ== salt: None, start_line: 0, end_line: 0, + custom_fields: HashMap::new(), }; doc.add_entry(work_root).unwrap(); @@ -792,6 +808,7 @@ ZmluYW5jZV9lbmNyeXB0ZWRfZGF0YQ== salt: Some(b"work_email_salt".to_vec()), start_line: 0, end_line: 0, + custom_fields: HashMap::new(), }; doc.add_entry(work_email).unwrap(); @@ -804,6 +821,7 @@ ZmluYW5jZV9lbmNyeXB0ZWRfZGF0YQ== salt: None, start_line: 0, end_line: 0, + custom_fields: HashMap::new(), }; doc.add_entry(personal_root).unwrap(); @@ -816,6 +834,7 @@ ZmluYW5jZV9lbmNyeXB0ZWRfZGF0YQ== salt: Some(b"bank_salt".to_vec()), start_line: 0, end_line: 0, + custom_fields: HashMap::new(), }; doc.add_entry(personal_bank).unwrap(); diff --git a/src/service.rs b/src/service.rs index cf4607e..88f7068 100644 --- a/src/service.rs +++ b/src/service.rs @@ -4,13 +4,16 @@ 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, + markdown_parser: VaultParser, + toml_parser: TomlParser, } impl Default for VaultService { @@ -24,20 +27,53 @@ impl VaultService { pub fn new() -> Self { Self { crypto: VaultCrypto::new(), - parser: VaultParser::new(), + markdown_parser: VaultParser::new(), + toml_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)?; + + // Detect format + let mut doc = if self.is_toml_format(&content) { + self.toml_parser.parse(&content)? + } else { + self.markdown_parser + .parse(&content) + .map_err(|e| VaultError::Other(e.to_string()))? + }; + + // 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) + // Check if this is a TOML file by extension or existing content + let is_toml = if path.exists() { + let content = std::fs::read_to_string(path).map_err(VaultError::Io)?; + self.is_toml_format(&content) + } else { + path.extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.eq_ignore_ascii_case("toml")) + .unwrap_or(false) + }; + + if is_toml { + // Format as TOML and save + let content = self.toml_parser.format(doc); + std::fs::write(path, content).map_err(VaultError::Io)?; + } else { + // Use existing Markdown save + doc.save(path).map_err(VaultError::Io)?; + } + + Ok(()) } /// Add a new entry to the vault. @@ -74,6 +110,7 @@ impl VaultService { salt: Some(salt), start_line: 0, end_line: 0, + custom_fields: HashMap::new(), }; // Add to document @@ -265,6 +302,30 @@ impl VaultService { "entries": entries, }) } + + /// Detect if content is TOML format. + fn is_toml_format(&self, content: &str) -> bool { + // Skip leading whitespace and comments + let trimmed = content.trim_start(); + + // Check for TOML indicators + if trimmed.starts_with('[') || trimmed.contains(" = ") { + return true; + } + + // Check for version field (TOML format) + if trimmed.starts_with("version = ") { + return true; + } + + // Check for Markdown format indicators + if trimmed.starts_with("# ") || trimmed.contains("\n\n## test\n\nTest entry\n\n"; + std::fs::write(&md_path, md_content).unwrap(); + + // Create TOML vault + let toml_content = + "version = \"v0.3\"\n\n[test]\ndescription = \"Test entry\"\nencrypted = \"\"\n"; + std::fs::write(&toml_path, toml_content).unwrap(); + + // Load both and verify correct parsing + let md_doc = service.load_vault(&md_path).unwrap(); + assert_eq!(md_doc.entries.len(), 1); + assert_eq!(md_doc.entries[0].description, "Test entry"); + + let toml_doc = service.load_vault(&toml_path).unwrap(); + assert_eq!(toml_doc.entries.len(), 1); + assert_eq!(toml_doc.entries[0].description, "Test entry"); +} From 4dd027563159c61f37beb5b6f0e0fb7e1b94de3e Mon Sep 17 00:00:00 2001 From: 0xasun Date: Thu, 17 Jul 2025 10:49:45 -0600 Subject: [PATCH 3/8] fix: preserve insertion order and implement smart group insertion - Remove all alphabetical sorting from TOML parser - Maintain exact file order when reading/writing vault files - Implement smart group insertion: new entries are added at the end of their group - Example: with [a.a2], [b.a2], adding [a.a11] results in [a.a2], [a.a11], [b.a2] - Groups are determined by top-level prefix (e.g., all a.* entries stay together) - Add comprehensive tests for insertion order preservation - Update documentation to reflect the new behavior This ensures vault entries maintain their original organization and new entries are logically grouped with related items rather than appended to the end. --- src/models.rs | 125 ++++++----------------- src/toml_parser.rs | 182 +++++++++++++++++++++++++--------- tests/group_insertion_test.rs | 93 +++++++++++++++++ 3 files changed, 260 insertions(+), 140 deletions(-) create mode 100644 tests/group_insertion_test.rs diff --git a/src/models.rs b/src/models.rs index 6dc595b..20f1cea 100644 --- a/src/models.rs +++ b/src/models.rs @@ -9,16 +9,10 @@ use std::path::PathBuf; pub struct VaultEntry { /// Scope path, e.g., ("personal", "banking", "chase") pub scope_path: Vec, - /// Markdown heading depth (1-6) - used for Markdown format - pub heading_level: u8, /// Plain text description pub description: String, /// Base64 encrypted secret pub encrypted_content: String, - /// Starting line in file (0-based) - used for Markdown format - pub start_line: usize, - /// Ending line in file (0-based) - used for Markdown format - pub end_line: usize, /// Per-item salt for key derivation pub salt: Option>, /// Custom fields for extensibility (TOML format) @@ -27,9 +21,9 @@ pub struct VaultEntry { } 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. @@ -56,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, @@ -116,85 +110,41 @@ impl VaultDocument { /// Add a new entry to the document. pub fn add_entry(&mut self, entry: VaultEntry) -> Result<(), Box> { - use crate::parser::VaultParser; - - // Create parser for formatting - let parser = VaultParser::new(); - - // 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); - - // Create any missing ancestors - let ancestors = VaultParser::create_missing_ancestors(self, &entry.scope_path); - - // Check if this will be the first entry (right after root) - let is_first_entry = self.entries.is_empty() && ancestors.is_empty(); - - // Insert ancestors first - let mut current_insert = insert_point; - for (i, ancestor) in ancestors.iter().enumerate() { - let is_first_ancestor = self.entries.is_empty() && i == 0; - let ancestor_lines = parser.format_entry_with_context(ancestor, is_first_ancestor); - for (j, line) in ancestor_lines.iter().enumerate() { - self.raw_lines.insert(current_insert + j, line.clone()); + // 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 + + if self.entries.is_empty() { + self.entries.push(entry); + return Ok(()); + } + + // Get the top-level prefix of the new entry + let new_prefix = &entry.scope_path[0]; + + // 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.clone()); } - - // Format the new entry with context - let entry_lines = parser.format_entry_with_context(&entry, is_first_entry); - - // 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 with post-save validation. - pub fn save(&self, path: &std::path::Path) -> Result<(), std::io::Error> { - use std::fs; - let content = self.raw_lines.join(""); - - // Write the file - fs::write(path, &content)?; - - // Post-save validation: try to parse the file we just wrote - use crate::parser::VaultParser; - let parser = VaultParser::new(); - match parser.parse(&content) { - Ok(parsed_doc) => { - // Verify we have the same number of entries - if parsed_doc.entries.len() != self.entries.len() { - eprintln!( - "Warning: Saved vault has {} entries but expected {}", - parsed_doc.entries.len(), - self.entries.len() - ); - } - } - Err(e) => { - eprintln!("Warning: Saved vault file may be corrupted: {}", e); - } - } - + /// Save the document to a file. + 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(()) } } @@ -207,37 +157,28 @@ 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(), }; diff --git a/src/toml_parser.rs b/src/toml_parser.rs index 48c20f6..2082ca8 100644 --- a/src/toml_parser.rs +++ b/src/toml_parser.rs @@ -74,8 +74,7 @@ impl TomlParser { // Process all table entries recursively self.process_tables(&table, &[], &mut entries, &mut processed_keys)?; - // Sort entries by scope path for consistent ordering - entries.sort_by(|a, b| a.scope_path.cmp(&b.scope_path)); + // Preserve original insertion order - no sorting Ok(VaultDocument { entries, @@ -94,49 +93,59 @@ impl TomlParser { /// Format a VaultDocument as TOML. pub fn format(&self, doc: &VaultDocument) -> String { - let mut root = toml::Table::new(); + // Build hierarchical structure for proper TOML dotted keys + let mut lines = Vec::new(); - // Add metadata - root.insert( - "version".to_string(), - toml::Value::String(self.current_version.to_string()), - ); - - // Get current timestamp + // Add metadata at the top + lines.push(format!("version = \"{}\"", self.current_version)); let now = chrono::Utc::now().to_rfc3339(); - root.insert("modified".to_string(), toml::Value::String(now)); - - // Add entries - for entry in &doc.entries { - let key = entry.scope_path.join("."); - let mut entry_table = toml::Table::new(); - - // Core fields - entry_table.insert( - "description".to_string(), - toml::Value::String(entry.description.clone()), - ); - entry_table.insert( - "encrypted".to_string(), - toml::Value::String(entry.encrypted_content.clone()), - ); + 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 { - entry_table.insert( - "salt".to_string(), - toml::Value::String(VaultCrypto::encode_salt(salt)), - ); + lines.push(format!("salt = \"{}\"", VaultCrypto::encode_salt(salt))); + } else { + lines.push("salt = \"\"".to_string()); } - // Custom fields + // Add custom fields for (k, v) in &entry.custom_fields { - entry_table.insert(k.clone(), v.clone()); + lines.push(format!("{} = {}", k, format_toml_value(v))); } - root.insert(key, toml::Value::Table(entry_table)); + lines.push(String::new()); // blank line between entries } - toml::to_string_pretty(&root).unwrap_or_default() + lines.join("\n") } /// Update an entry preserving custom fields. @@ -147,7 +156,7 @@ impl TomlParser { ) -> Result<()> { let entry = doc .find_entry_mut(scope_path) - .ok_or_else(|| VaultError::EntryNotFound(scope_path.join("/")))?; + .ok_or_else(|| VaultError::EntryNotFound(scope_path.join(".")))?; // Update only specified fields for (key, value) in updates { @@ -214,12 +223,9 @@ impl TomlParser { if !processed_keys.contains(&parent_key) { let parent_entry = VaultEntry { scope_path: parent_path.clone(), - heading_level: 0, - description: format!("{} secrets", parent_path.join("/")), + description: format!("{} secrets", parent_path.join(".")), encrypted_content: String::new(), salt: None, - start_line: 0, - end_line: 0, custom_fields: HashMap::new(), }; entries.push(parent_entry); @@ -231,14 +237,11 @@ impl TomlParser { let toml_entry = extract_toml_entry(nested_table)?; let entry = VaultEntry { scope_path: scope_parts.clone(), - heading_level: 0, description: toml_entry.description, encrypted_content: toml_entry.encrypted, salt: toml_entry .salt .and_then(|s| VaultCrypto::decode_salt(&s).ok()), - start_line: 0, - end_line: 0, custom_fields: toml_entry.custom_fields, }; entries.push(entry); @@ -261,6 +264,31 @@ fn parse_scope_key(key: &str) -> Vec { 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 @@ -280,11 +308,14 @@ fn extract_toml_entry(table: &Table) -> Result { .and_then(|v| v.as_str()) .map(|s| s.to_string()); - // Collect custom fields + // Collect custom fields (excluding nested tables) let mut custom_fields = HashMap::new(); for (k, v) in table { if k != "description" && k != "encrypted" && k != "salt" { - custom_fields.insert(k.clone(), v.clone()); + // Skip nested tables - they are separate vault entries + if !v.is_table() { + custom_fields.insert(k.clone(), v.clone()); + } } } @@ -324,6 +355,64 @@ salt = "YmFzZTY0X3NhbHQ=" 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#" @@ -396,12 +485,9 @@ tags = ["finance", "important"] let entry = VaultEntry { scope_path: vec!["work".to_string(), "email".to_string()], - heading_level: 0, description: "Work email".to_string(), encrypted_content: "encrypted".to_string(), salt: Some(b"salt".to_vec()), - start_line: 0, - end_line: 0, custom_fields, }; @@ -411,7 +497,7 @@ tags = ["finance", "important"] let formatted = parser.format(&doc); assert!(formatted.contains("version = \"v0.3\"")); - assert!(formatted.contains("[\"work.email\"]")); // TOML quotes keys with dots + 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/tests/group_insertion_test.rs b/tests/group_insertion_test.rs new file mode 100644 index 0000000..0eedbf6 --- /dev/null +++ b/tests/group_insertion_test.rs @@ -0,0 +1,93 @@ +use vaultify::{ + models::{VaultDocument, VaultEntry}, + toml_parser::TomlParser, +}; +use std::collections::HashMap; + +#[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"]); +} \ No newline at end of file From 6b0d3c76ef2fd47f6e538f3117c1df4138abdabb Mon Sep 17 00:00:00 2001 From: 0xasun Date: Thu, 17 Jul 2025 14:50:10 -0600 Subject: [PATCH 4/8] fix(toml): preserve insertion order with smart group insertion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove alphabetical sorting from TOML parser - Maintain exact file order when reading/writing vaults - Add smart group insertion: new entries append to their group - Example: [a.a2], [b.a2] + [a.a11] → [a.a2], [a.a11], [b.a2] - Groups determined by top-level prefix (all a.* stay together) This ensures vault entries maintain logical organization with related items grouped together rather than scattered. --- src/cli.rs | 82 ++-- src/error.rs | 3 - src/gpg.rs | 6 +- src/interactive.rs | 33 +- src/lib.rs | 1 - src/main.rs | 7 +- src/parser.rs | 863 -------------------------------------- src/service.rs | 80 +--- src/utils.rs | 64 ++- tests/toml_integration.rs | 35 +- vault.toml | 17 + 11 files changed, 144 insertions(+), 1047 deletions(-) delete mode 100644 src/parser.rs create mode 100644 vault.toml diff --git a/src/cli.rs b/src/cli.rs index d90f966..bcec7ba 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 { @@ -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 1b2f5bb..a566035 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -7,7 +7,6 @@ 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; diff --git a/src/main.rs b/src/main.rs index 6a19d88..d09f09d 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 = \"{}\"\n", now); match std::fs::write(&vault_path, content) { Ok(_) => { // Set secure permissions diff --git a/src/parser.rs b/src/parser.rs deleted file mode 100644 index 51c3e50..0000000 --- a/src/parser.rs +++ /dev/null @@ -1,863 +0,0 @@ -//! Order-preserving parser for vault markdown files. - -use crate::crypto::VaultCrypto; -use crate::models::{VaultDocument, VaultEntry}; -use regex::Regex; -use std::collections::HashMap; -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: 3, // Should be 3 for nested entries (test/item) - 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, - custom_fields: HashMap::new(), - }; - - 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")); - } - - #[test] - fn test_multiple_root_entries_save_reload() { - use tempfile::TempDir; - - let parser = VaultParser::new(); - let temp_dir = TempDir::new().unwrap(); - let vault_path = temp_dir.path().join("test_vault.md"); - - // Create a vault with multiple root entries - let mut doc = VaultDocument::new(); - - // Add first root entry - let entry1 = VaultEntry { - scope_path: vec!["work".to_string()], - heading_level: 2, // Should be 2 for root-level entries - description: "Work credentials".to_string(), - encrypted_content: "encrypted_work_data".to_string(), - salt: Some(b"work_salt".to_vec()), - start_line: 0, - end_line: 0, - custom_fields: HashMap::new(), - }; - doc.add_entry(entry1).unwrap(); - - // Add second root entry - let entry2 = VaultEntry { - scope_path: vec!["personal".to_string()], - heading_level: 2, // Should be 2 for root-level entries - description: "Personal accounts".to_string(), - encrypted_content: "encrypted_personal_data".to_string(), - salt: Some(b"personal_salt".to_vec()), - start_line: 0, - end_line: 0, - custom_fields: HashMap::new(), - }; - doc.add_entry(entry2).unwrap(); - - // Add third root entry - let entry3 = VaultEntry { - scope_path: vec!["finance".to_string()], - heading_level: 2, // Should be 2 for root-level entries - description: "Financial accounts".to_string(), - encrypted_content: "encrypted_finance_data".to_string(), - salt: Some(b"finance_salt".to_vec()), - start_line: 0, - end_line: 0, - custom_fields: HashMap::new(), - }; - doc.add_entry(entry3).unwrap(); - - // Save the document - doc.save(&vault_path).unwrap(); - - // Print the saved content for debugging - let saved_content = std::fs::read_to_string(&vault_path).unwrap(); - println!("Saved vault content:\n{}", saved_content); - - // Reload the document - let reloaded_doc = parser.parse_file(&vault_path).unwrap(); - - // Verify all entries were preserved - assert_eq!(reloaded_doc.entries.len(), 3); - - // Verify each entry's data - let work_entry = reloaded_doc.find_entry(&["work".to_string()]).unwrap(); - assert_eq!(work_entry.description, "Work credentials"); - assert_eq!(work_entry.encrypted_content, "encrypted_work_data"); - - let personal_entry = reloaded_doc.find_entry(&["personal".to_string()]).unwrap(); - assert_eq!(personal_entry.description, "Personal accounts"); - assert_eq!(personal_entry.encrypted_content, "encrypted_personal_data"); - - let finance_entry = reloaded_doc.find_entry(&["finance".to_string()]).unwrap(); - assert_eq!(finance_entry.description, "Financial accounts"); - assert_eq!(finance_entry.encrypted_content, "encrypted_finance_data"); - } - - #[test] - fn test_multiple_root_entries_edge_cases() { - use tempfile::TempDir; - - let parser = VaultParser::new(); - let temp_dir = TempDir::new().unwrap(); - let vault_path = temp_dir.path().join("test_edge_cases.md"); - - // Create vault content manually to test specific formatting issues - let vault_content = r#"# root - -## work - -Work stuff - - -YmFzZTY0X2VuY3J5cHRlZF9kYXRh - - -## personal - -Personal items - - -YW5vdGhlcl9lbmNyeXB0ZWRfZGF0YQ== - - -## finance - -Financial data - - -ZmluYW5jZV9lbmNyeXB0ZWRfZGF0YQ== - -"#; - - // Write the vault content - std::fs::write(&vault_path, vault_content).unwrap(); - - // Parse it - let doc = parser.parse_file(&vault_path).unwrap(); - assert_eq!(doc.entries.len(), 3); - - // Now save it again - doc.save(&vault_path).unwrap(); - - // Read the saved content - let saved_content = std::fs::read_to_string(&vault_path).unwrap(); - println!("After re-save:\n{}", saved_content); - - // Try to parse again - let reloaded_doc = parser.parse_file(&vault_path).unwrap(); - assert_eq!(reloaded_doc.entries.len(), 3); - - // Verify content integrity - for (original, reloaded) in doc.entries.iter().zip(reloaded_doc.entries.iter()) { - assert_eq!(original.scope_path, reloaded.scope_path); - assert_eq!(original.description, reloaded.description); - assert_eq!(original.encrypted_content, reloaded.encrypted_content); - } - } - - #[test] - fn test_blank_line_formatting_bug() { - use tempfile::TempDir; - - let parser = VaultParser::new(); - let temp_dir = TempDir::new().unwrap(); - let vault_path = temp_dir.path().join("test_blank_lines.md"); - - // Start with an empty vault - let mut doc = VaultDocument::new(); - - // Add first root entry - this should NOT have a blank line before it - let entry1 = VaultEntry { - scope_path: vec!["first".to_string()], - heading_level: 2, // Should be 2 for root-level entries - description: "First entry".to_string(), - encrypted_content: "data1".to_string(), - salt: Some(b"salt1".to_vec()), - start_line: 0, - end_line: 0, - custom_fields: HashMap::new(), - }; - doc.add_entry(entry1).unwrap(); - - // Check the formatting - doc.save(&vault_path).unwrap(); - let content1 = std::fs::read_to_string(&vault_path).unwrap(); - println!("After first entry:\n{}", content1); - - // The first root entry should NOT have a blank line after the root header - assert!( - !content1.contains("# root \n\n\n"), - "First root entry should not have double blank line" - ); - - // Add second root entry - let entry2 = VaultEntry { - scope_path: vec!["second".to_string()], - heading_level: 2, // Should be 2 for root-level entries - description: "Second entry".to_string(), - encrypted_content: "data2".to_string(), - salt: Some(b"salt2".to_vec()), - start_line: 0, - end_line: 0, - custom_fields: HashMap::new(), - }; - - // Reload and add to ensure we test the full cycle - let mut doc2 = parser.parse_file(&vault_path).unwrap(); - doc2.add_entry(entry2).unwrap(); - doc2.save(&vault_path).unwrap(); - - let content2 = std::fs::read_to_string(&vault_path).unwrap(); - println!("After second entry:\n{}", content2); - - // Parse again to verify - let final_doc = parser.parse_file(&vault_path).unwrap(); - assert_eq!(final_doc.entries.len(), 2); - } - - #[test] - fn test_empty_root_entries() { - use tempfile::TempDir; - - let parser = VaultParser::new(); - let temp_dir = TempDir::new().unwrap(); - let vault_path = temp_dir.path().join("test_empty_roots.md"); - - let mut doc = VaultDocument::new(); - - // Add empty root entries (no encrypted content) - let entry1 = VaultEntry { - scope_path: vec!["empty1".to_string()], - heading_level: 2, // Should be 2 for root-level entries - description: "Empty container 1".to_string(), - encrypted_content: String::new(), - salt: None, - start_line: 0, - end_line: 0, - custom_fields: HashMap::new(), - }; - doc.add_entry(entry1).unwrap(); - - let entry2 = VaultEntry { - scope_path: vec!["empty2".to_string()], - heading_level: 2, // Should be 2 for root-level entries - description: "Empty container 2".to_string(), - encrypted_content: String::new(), - salt: None, - start_line: 0, - end_line: 0, - custom_fields: HashMap::new(), - }; - doc.add_entry(entry2).unwrap(); - - // Save and reload - doc.save(&vault_path).unwrap(); - let reloaded = parser.parse_file(&vault_path).unwrap(); - - assert_eq!(reloaded.entries.len(), 2); - assert_eq!(reloaded.entries[0].encrypted_content, ""); - assert_eq!(reloaded.entries[1].encrypted_content, ""); - } - - #[test] - fn test_special_characters_in_scope_names() { - use tempfile::TempDir; - - let parser = VaultParser::new(); - let temp_dir = TempDir::new().unwrap(); - let vault_path = temp_dir.path().join("test_special_chars.md"); - - let mut doc = VaultDocument::new(); - - // Add entries with special characters - let entry1 = VaultEntry { - scope_path: vec!["work-2024".to_string()], - heading_level: 2, // Should be 2 for root-level entries - description: "Work with dash".to_string(), - encrypted_content: "data1".to_string(), - salt: Some(b"salt1".to_vec()), - start_line: 0, - end_line: 0, - custom_fields: HashMap::new(), - }; - doc.add_entry(entry1).unwrap(); - - let entry2 = VaultEntry { - scope_path: vec!["personal_backup".to_string()], - heading_level: 2, // Should be 2 for root-level entries - description: "Personal with underscore".to_string(), - encrypted_content: "data2".to_string(), - salt: Some(b"salt2".to_vec()), - start_line: 0, - end_line: 0, - custom_fields: HashMap::new(), - }; - doc.add_entry(entry2).unwrap(); - - let entry3 = VaultEntry { - scope_path: vec!["email@domain".to_string()], - heading_level: 2, // Should be 2 for root-level entries - description: "Email with at sign".to_string(), - encrypted_content: "data3".to_string(), - salt: Some(b"salt3".to_vec()), - start_line: 0, - end_line: 0, - custom_fields: HashMap::new(), - }; - doc.add_entry(entry3).unwrap(); - - // Save and reload - doc.save(&vault_path).unwrap(); - let reloaded = parser.parse_file(&vault_path).unwrap(); - - assert_eq!(reloaded.entries.len(), 3); - assert_eq!(reloaded.entries[0].scope_path[0], "work-2024"); - assert_eq!(reloaded.entries[1].scope_path[0], "personal_backup"); - assert_eq!(reloaded.entries[2].scope_path[0], "email@domain"); - } - - #[test] - fn test_many_root_entries() { - use tempfile::TempDir; - - let parser = VaultParser::new(); - let temp_dir = TempDir::new().unwrap(); - let vault_path = temp_dir.path().join("test_many_roots.md"); - - let mut doc = VaultDocument::new(); - - // Add 10 root entries - for i in 0..10 { - let entry = VaultEntry { - scope_path: vec![format!("root{}", i)], - heading_level: 2, // Should be 2 for root-level entries - description: format!("Root entry {}", i), - encrypted_content: format!("data{}", i), - salt: Some(format!("salt{}", i).as_bytes().to_vec()), - start_line: 0, - end_line: 0, - custom_fields: HashMap::new(), - }; - doc.add_entry(entry).unwrap(); - } - - // Save and reload multiple times to test stability - for _ in 0..3 { - doc.save(&vault_path).unwrap(); - doc = parser.parse_file(&vault_path).unwrap(); - } - - // Verify all entries preserved - assert_eq!(doc.entries.len(), 10); - for i in 0..10 { - assert_eq!(doc.entries[i].scope_path[0], format!("root{}", i)); - assert_eq!(doc.entries[i].encrypted_content, format!("data{}", i)); - } - } - - #[test] - fn test_root_entries_with_children() { - use tempfile::TempDir; - - let parser = VaultParser::new(); - let temp_dir = TempDir::new().unwrap(); - let vault_path = temp_dir.path().join("test_vault_nested.md"); - - // Create a vault with root entries that have children - let mut doc = VaultDocument::new(); - - // Add first root entry - let work_root = VaultEntry { - scope_path: vec!["work".to_string()], - heading_level: 2, // Should be 2 for root-level entries - description: "Work credentials".to_string(), - encrypted_content: String::new(), - salt: None, - start_line: 0, - end_line: 0, - custom_fields: HashMap::new(), - }; - doc.add_entry(work_root).unwrap(); - - // Add child of work - let work_email = VaultEntry { - scope_path: vec!["work".to_string(), "email".to_string()], - heading_level: 3, - description: "Work email".to_string(), - encrypted_content: "encrypted_work_email".to_string(), - salt: Some(b"work_email_salt".to_vec()), - start_line: 0, - end_line: 0, - custom_fields: HashMap::new(), - }; - doc.add_entry(work_email).unwrap(); - - // Add second root entry - let personal_root = VaultEntry { - scope_path: vec!["personal".to_string()], - heading_level: 2, // Should be 2 for root-level entries - description: "Personal accounts".to_string(), - encrypted_content: String::new(), - salt: None, - start_line: 0, - end_line: 0, - custom_fields: HashMap::new(), - }; - doc.add_entry(personal_root).unwrap(); - - // Add child of personal - let personal_bank = VaultEntry { - scope_path: vec!["personal".to_string(), "bank".to_string()], - heading_level: 3, - description: "Personal banking".to_string(), - encrypted_content: "encrypted_bank_data".to_string(), - salt: Some(b"bank_salt".to_vec()), - start_line: 0, - end_line: 0, - custom_fields: HashMap::new(), - }; - doc.add_entry(personal_bank).unwrap(); - - // Save and reload - doc.save(&vault_path).unwrap(); - let reloaded_doc = parser.parse_file(&vault_path).unwrap(); - - // Verify structure - assert_eq!(reloaded_doc.entries.len(), 4); - - // Verify root entries - assert!(reloaded_doc.find_entry(&["work".to_string()]).is_some()); - assert!(reloaded_doc.find_entry(&["personal".to_string()]).is_some()); - - // Verify children - let work_email_entry = reloaded_doc - .find_entry(&["work".to_string(), "email".to_string()]) - .unwrap(); - assert_eq!(work_email_entry.encrypted_content, "encrypted_work_email"); - - let bank_entry = reloaded_doc - .find_entry(&["personal".to_string(), "bank".to_string()]) - .unwrap(); - assert_eq!(bank_entry.encrypted_content, "encrypted_bank_data"); - } -} diff --git a/src/service.rs b/src/service.rs index 88f7068..bce2f03 100644 --- a/src/service.rs +++ b/src/service.rs @@ -3,7 +3,6 @@ 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; @@ -12,8 +11,7 @@ use std::path::Path; /// Service for vault operations. pub struct VaultService { crypto: VaultCrypto, - markdown_parser: VaultParser, - toml_parser: TomlParser, + parser: TomlParser, } impl Default for VaultService { @@ -27,8 +25,7 @@ impl VaultService { pub fn new() -> Self { Self { crypto: VaultCrypto::new(), - markdown_parser: VaultParser::new(), - toml_parser: TomlParser::new(), + parser: TomlParser::new(), } } @@ -37,14 +34,8 @@ impl VaultService { // Read file content let content = std::fs::read_to_string(path).map_err(VaultError::Io)?; - // Detect format - let mut doc = if self.is_toml_format(&content) { - self.toml_parser.parse(&content)? - } else { - self.markdown_parser - .parse(&content) - .map_err(|e| VaultError::Other(e.to_string()))? - }; + // Parse TOML format + let mut doc = self.parser.parse(&content)?; // Set file path doc.file_path = Some(path.to_path_buf()); @@ -53,26 +44,9 @@ impl VaultService { /// Save a vault document to file. pub fn save_vault(&self, doc: &VaultDocument, path: &Path) -> Result<()> { - // Check if this is a TOML file by extension or existing content - let is_toml = if path.exists() { - let content = std::fs::read_to_string(path).map_err(VaultError::Io)?; - self.is_toml_format(&content) - } else { - path.extension() - .and_then(|ext| ext.to_str()) - .map(|ext| ext.eq_ignore_ascii_case("toml")) - .unwrap_or(false) - }; - - if is_toml { - // Format as TOML and save - let content = self.toml_parser.format(doc); - std::fs::write(path, content).map_err(VaultError::Io)?; - } else { - // Use existing Markdown save - 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(()) } @@ -104,12 +78,9 @@ impl VaultService { // Create new entry let entry = VaultEntry { scope_path: scope_parts.clone(), - heading_level: (scope_parts.len() + 1) as u8, // +1 because # root is level 1 description, encrypted_content, salt: Some(salt), - start_line: 0, - end_line: 0, custom_fields: HashMap::new(), }; @@ -215,7 +186,6 @@ impl VaultService { // Update scope entry.scope_path = new_scope_parts.clone(); - entry.heading_level = (new_scope_parts.len() + 1) as u8; // +1 because # root is level 1 Ok(()) } @@ -302,30 +272,6 @@ impl VaultService { "entries": entries, }) } - - /// Detect if content is TOML format. - fn is_toml_format(&self, content: &str) -> bool { - // Skip leading whitespace and comments - let trimmed = content.trim_start(); - - // Check for TOML indicators - if trimmed.starts_with('[') || trimmed.contains(" = ") { - return true; - } - - // Check for version field (TOML format) - if trimmed.starts_with("version = ") { - return true; - } - - // Check for Markdown format indicators - if trimmed.starts_with("# ") || trimmed.contains("\n\n## test\n\nTest entry\n\n"; - std::fs::write(&md_path, md_content).unwrap(); - - // Create TOML vault - let toml_content = - "version = \"v0.3\"\n\n[test]\ndescription = \"Test entry\"\nencrypted = \"\"\n"; - std::fs::write(&toml_path, toml_content).unwrap(); - - // Load both and verify correct parsing - let md_doc = service.load_vault(&md_path).unwrap(); - assert_eq!(md_doc.entries.len(), 1); - assert_eq!(md_doc.entries[0].description, "Test entry"); - - let toml_doc = service.load_vault(&toml_path).unwrap(); - assert_eq!(toml_doc.entries.len(), 1); - assert_eq!(toml_doc.entries[0].description, "Test entry"); -} diff --git a/vault.toml b/vault.toml new file mode 100644 index 0000000..57ead15 --- /dev/null +++ b/vault.toml @@ -0,0 +1,17 @@ +version = "v0.3" +modified = "2025-07-17T06:20:10.775562+00:00" + +[a.a2] +description = "a.a2 credentials" +encrypted = "EAT3lpbVemlYRJQHCWWJNk7z0/W/7TvqSkGuKvQ=" +salt = "f8gvr+VAcljwjopGsJDt2Q==" + +[a.a11] +description = "a.a11 credentials" +encrypted = "DlerEY1WJ5jeNlxxYV0ofu4gI8Cb1LW+nnWY08w=" +salt = "U/GE/NILXBs9PpuQSV5cvA==" + +[b.a2] +description = "b.a2 credentials" +encrypted = "4J4c6AVZ/SLlLZB70G5v/0/a3LOjAahL/NvMov0=" +salt = "UQEzm60DKL6ciqD41zh6OQ==" From b5e2f10a76b13c9f08b4868903477e3f8c1fc685 Mon Sep 17 00:00:00 2001 From: 0xasun Date: Thu, 17 Jul 2025 23:43:51 -0600 Subject: [PATCH 5/8] docs: update README and preserve list order - Update README to reflect TOML format instead of Markdown - Add documentation about insertion order preservation - Fix list commands to maintain vault file order (no sorting) - Remove alphabetical sorting from list_scopes and list operations This ensures the list output matches the order in the TOML file. --- README.md | 37 ++++++++++++++++++++----------------- src/operations.rs | 3 +-- src/service.rs | 14 +++++++++++--- vault.toml | 17 ----------------- 4 files changed, 32 insertions(+), 39 deletions(-) delete mode 100644 vault.toml 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/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/service.rs b/src/service.rs index bce2f03..8c2780d 100644 --- a/src/service.rs +++ b/src/service.rs @@ -231,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 } diff --git a/vault.toml b/vault.toml deleted file mode 100644 index 57ead15..0000000 --- a/vault.toml +++ /dev/null @@ -1,17 +0,0 @@ -version = "v0.3" -modified = "2025-07-17T06:20:10.775562+00:00" - -[a.a2] -description = "a.a2 credentials" -encrypted = "EAT3lpbVemlYRJQHCWWJNk7z0/W/7TvqSkGuKvQ=" -salt = "f8gvr+VAcljwjopGsJDt2Q==" - -[a.a11] -description = "a.a11 credentials" -encrypted = "DlerEY1WJ5jeNlxxYV0ofu4gI8Cb1LW+nnWY08w=" -salt = "U/GE/NILXBs9PpuQSV5cvA==" - -[b.a2] -description = "b.a2 credentials" -encrypted = "4J4c6AVZ/SLlLZB70G5v/0/a3LOjAahL/NvMov0=" -salt = "UQEzm60DKL6ciqD41zh6OQ==" From ecbfa322f12555d9d2a33d941f9778be1d285fbd Mon Sep 17 00:00:00 2001 From: 0xasun Date: Mon, 21 Jul 2025 10:32:11 -0600 Subject: [PATCH 6/8] style: apply cargo fmt formatting - Format code according to Rust standards - Ensure CI passes with cargo fmt and cargo clippy --- src/models.rs | 10 ++++---- src/service.rs | 4 ++-- src/toml_parser.rs | 10 ++++++-- tests/group_insertion_test.rs | 44 ++++++++++++++++++++--------------- 4 files changed, 40 insertions(+), 28 deletions(-) diff --git a/src/models.rs b/src/models.rs index 20f1cea..db95c72 100644 --- a/src/models.rs +++ b/src/models.rs @@ -113,15 +113,15 @@ impl VaultDocument { // 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 - + if self.entries.is_empty() { self.entries.push(entry); return Ok(()); } - + // Get the top-level prefix of the new entry let new_prefix = &entry.scope_path[0]; - + // 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() { @@ -130,14 +130,14 @@ impl VaultDocument { insert_position = Some(i + 1); } } - + // 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), } - + Ok(()) } diff --git a/src/service.rs b/src/service.rs index 8c2780d..154772f 100644 --- a/src/service.rs +++ b/src/service.rs @@ -234,14 +234,14 @@ impl VaultService { // 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 } diff --git a/src/toml_parser.rs b/src/toml_parser.rs index 2082ca8..39b8039 100644 --- a/src/toml_parser.rs +++ b/src/toml_parser.rs @@ -409,8 +409,14 @@ salt = "YmFzZTY0X3NhbHQ=" 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)"); + 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] diff --git a/tests/group_insertion_test.rs b/tests/group_insertion_test.rs index 0eedbf6..f879b33 100644 --- a/tests/group_insertion_test.rs +++ b/tests/group_insertion_test.rs @@ -1,14 +1,14 @@ +use std::collections::HashMap; use vaultify::{ models::{VaultDocument, VaultEntry}, toml_parser::TomlParser, }; -use std::collections::HashMap; #[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()], @@ -16,8 +16,9 @@ fn test_smart_group_insertion() { encrypted_content: "encrypted_a_a2".to_string(), salt: Some(vec![1, 2, 3]), custom_fields: HashMap::new(), - }).unwrap(); - + }) + .unwrap(); + // Add b.a2 doc.add_entry(VaultEntry { scope_path: vec!["b".to_string(), "a2".to_string()], @@ -25,8 +26,9 @@ fn test_smart_group_insertion() { encrypted_content: "encrypted_b_a2".to_string(), salt: Some(vec![4, 5, 6]), custom_fields: HashMap::new(), - }).unwrap(); - + }) + .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()], @@ -34,23 +36,24 @@ fn test_smart_group_insertion() { encrypted_content: "encrypted_a_a11".to_string(), salt: Some(vec![7, 8, 9]), custom_fields: HashMap::new(), - }).unwrap(); - + }) + .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"); @@ -59,7 +62,7 @@ fn test_smart_group_insertion() { #[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()], @@ -67,16 +70,18 @@ fn test_multiple_level_group_insertion() { encrypted_content: "enc1".to_string(), salt: Some(vec![1]), custom_fields: HashMap::new(), - }).unwrap(); - + }) + .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(); - + }) + .unwrap(); + // Add another work entry - should go after existing work entries doc.add_entry(VaultEntry { scope_path: vec!["work".to_string(), "vpn".to_string()], @@ -84,10 +89,11 @@ fn test_multiple_level_group_insertion() { encrypted_content: "enc3".to_string(), salt: Some(vec![3]), custom_fields: HashMap::new(), - }).unwrap(); - + }) + .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"]); -} \ No newline at end of file +} From 7d3f3fa9375f5afd95f5d3b64b49464defd5ebc0 Mon Sep 17 00:00:00 2001 From: 0xasun Date: Mon, 21 Jul 2025 10:40:00 -0600 Subject: [PATCH 7/8] fix: resolve clippy format string interpolation warnings - Use direct variable interpolation in format strings - Change format!("{}", var) to format!("{var}") - Ensures CI passes with cargo clippy -- -D warnings --- src/cli.rs | 6 +++--- src/toml_parser.rs | 11 +++++------ 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index bcec7ba..b7ab472 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -345,7 +345,7 @@ impl Cli { if !entry.has_content { println!("{} {} - {}", line, "[empty]".yellow(), first_line); } else { - println!("{} - {}", line, first_line); + println!("{line} - {first_line}"); } // Print additional description lines with appropriate indentation @@ -354,7 +354,7 @@ impl Cli { 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); + println!("{indent}{desc_line}"); } } } else { @@ -390,7 +390,7 @@ impl Cli { // Print additional description lines indented for line in desc_lines.iter().skip(1) { - println!(" {}", line); + println!(" {line}"); } } } diff --git a/src/toml_parser.rs b/src/toml_parser.rs index 39b8039..aba364e 100644 --- a/src/toml_parser.rs +++ b/src/toml_parser.rs @@ -56,14 +56,13 @@ impl TomlParser { // Parse TOML with order preservation let table: Table = content .parse() - .map_err(|e| VaultError::Other(format!("TOML parse error: {}", e)))?; + .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 + "Unsupported TOML version: {version}" ))); } } @@ -99,7 +98,7 @@ impl TomlParser { // 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(format!("modified = \"{now}\"")); lines.push(String::new()); // blank line // Preserve exact file order - no sorting at all @@ -115,7 +114,7 @@ impl TomlParser { // Create the dotted key section header let section_key = entry.scope_path.join("."); - lines.push(format!("[{}]", section_key)); + lines.push(format!("[{section_key}]")); // Add fields lines.push(format!( @@ -284,7 +283,7 @@ fn format_toml_value(value: &toml::Value) -> String { let items: Vec = arr.iter().map(format_toml_value).collect(); format!("[{}]", items.join(", ")) } - toml::Value::Datetime(dt) => format!("\"{}\"", dt), + toml::Value::Datetime(dt) => format!("\"{dt}\""), toml::Value::Table(_) => "{ ... }".to_string(), // Shouldn't happen for custom fields } } From cea1b31d3350d9ff9e4aa1fdd7665619ad2ed37e Mon Sep 17 00:00:00 2001 From: 0xasun Date: Mon, 21 Jul 2025 13:49:55 -0600 Subject: [PATCH 8/8] fix: additional format string interpolation fixes - Fix format string in main.rs for vault creation - Fix format string in toml_parser.rs for custom fields - Ensures all clippy warnings are resolved --- src/main.rs | 2 +- src/toml_parser.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index d09f09d..d28a29e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -91,7 +91,7 @@ async fn run_interactive() { if create { // Create new vault file with TOML format let now = chrono::Utc::now().to_rfc3339(); - let content = format!("version = \"v0.3\"\ncreated = \"{}\"\n", now); + let content = format!("version = \"v0.3\"\ncreated = \"{now}\"\n"); match std::fs::write(&vault_path, content) { Ok(_) => { // Set secure permissions diff --git a/src/toml_parser.rs b/src/toml_parser.rs index aba364e..5a99d2d 100644 --- a/src/toml_parser.rs +++ b/src/toml_parser.rs @@ -138,7 +138,7 @@ impl TomlParser { // Add custom fields for (k, v) in &entry.custom_fields { - lines.push(format!("{} = {}", k, format_toml_value(v))); + lines.push(format!("{k} = {}", format_toml_value(v))); } lines.push(String::new()); // blank line between entries