//! Vault data model: entries, manifest entries, and the manifest index. //! //! The vault stores credentials in two tiers: //! //! 1. **Individual entries** (`entries/.enc`): each file contains a single //! [`Entry`] encrypted with the master key. Only decrypted when the user //! needs to read or edit a specific credential. //! //! 2. **Manifest** (`manifest.enc`): a single encrypted file containing a //! [`Manifest`] -- a map from entry IDs to [`ManifestEntry`] summaries. //! This lets the CLI list and search entries by decrypting only one file, //! rather than decrypting every entry in the vault. //! //! ## Entry IDs //! //! Entry IDs are random 8-character lowercase hex strings (4 bytes of entropy, //! ~4 billion possible values). This is sufficient for family-scale vaults while //! keeping filenames short and filesystem-friendly. //! //! ## Serialization strategy //! //! All structs derive `Serialize`/`Deserialize` for JSON encoding. Optional fields //! use `#[serde(skip_serializing_if = "Option::is_none")]` to keep the JSON compact //! -- omitting null fields reduces ciphertext size and avoids leaking structural //! information about which optional fields a credential uses. use rand::Rng; use serde::{Deserialize, Serialize}; use std::collections::HashMap; /// A full credential entry stored encrypted in `entries/.enc`. /// /// Contains all sensitive data for a single credential. Each entry is encrypted /// independently, so accessing one entry does not require decrypting others. /// /// ## Fields /// /// - `name`: human-readable label (e.g., "GitHub", "Work Email"). Required. /// - `url`: the login URL. Optional; used for autofill matching in the browser extension. /// - `username`: the account username or email. Optional. /// - `password`: the credential password. Required (this is the core secret). /// - `notes`: free-form text (e.g., security questions, recovery codes). Optional. /// - `totp_secret`: base32-encoded TOTP secret for 2FA. Optional. /// - `created_at`: ISO 8601 timestamp (or Unix seconds) when the entry was created. /// - `updated_at`: ISO 8601 timestamp (or Unix seconds) of the last modification. /// - `group`: optional group label for organizing entries (e.g. "work", "personal"). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Entry { pub name: String, #[serde(skip_serializing_if = "Option::is_none")] pub url: Option, #[serde(skip_serializing_if = "Option::is_none")] pub username: Option, pub password: String, #[serde(skip_serializing_if = "Option::is_none")] pub notes: Option, #[serde(skip_serializing_if = "Option::is_none")] pub totp_secret: Option, /// Optional group for organizing entries (e.g. "work", "personal"). #[serde(skip_serializing_if = "Option::is_none")] pub group: Option, pub created_at: String, pub updated_at: String, } /// Summary metadata for a single entry, stored in the manifest. /// /// This is a lightweight projection of [`Entry`] that contains only the /// non-sensitive fields needed for listing and searching. The password, /// notes, and TOTP secret are intentionally excluded so that listing /// entries requires decrypting only the manifest, not every individual entry. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ManifestEntry { /// Human-readable label for display and search matching. pub name: String, /// Login URL for search matching and browser extension autofill. #[serde(skip_serializing_if = "Option::is_none")] pub url: Option, /// Account username for display in entry listings. #[serde(skip_serializing_if = "Option::is_none")] pub username: Option, /// Optional group for organizing entries (e.g. "work", "personal"). #[serde(skip_serializing_if = "Option::is_none")] pub group: Option, /// Timestamp of last modification, used for sorting and display. pub updated_at: String, } /// The vault manifest -- an encrypted index mapping entry IDs to their metadata. /// /// The manifest serves two purposes: /// /// 1. **Efficient listing**: decrypting the single manifest file is enough to show /// all entry names, URLs, and usernames without touching individual entry files. /// 2. **Search**: the [`search`](Manifest::search) method performs case-insensitive /// substring matching against entry names and URLs. /// /// The `version` field allows future schema migrations if the manifest format evolves. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Manifest { /// Map from entry ID (8-char hex string) to entry metadata. pub entries: HashMap, /// Schema version. Currently always `1`. pub version: u32, } impl Manifest { /// Create a new empty manifest with version 1. pub fn new() -> Self { Manifest { entries: HashMap::new(), version: 1, } } /// Insert or update an entry in the manifest. /// /// If an entry with the same ID already exists, it is overwritten. /// This is used both for `add` (new entry) and `edit` (update existing). pub fn add_entry(&mut self, id: String, entry: ManifestEntry) { self.entries.insert(id, entry); } /// Remove an entry from the manifest by ID, returning its metadata if it existed. pub fn remove_entry(&mut self, id: &str) -> Option { self.entries.remove(id) } /// Search entries by case-insensitive substring match against name and URL. /// /// Returns a vector of `(id, entry)` pairs for all matching entries. An entry /// matches if the query appears in its name or URL (case-insensitive). pub fn search(&self, query: &str) -> Vec<(&String, &ManifestEntry)> { let q = query.to_lowercase(); self.entries .iter() .filter(|(_, e)| { e.name.to_lowercase().contains(&q) || e.url .as_deref() .map(|u| u.to_lowercase().contains(&q)) .unwrap_or(false) }) .collect() } } impl Default for Manifest { fn default() -> Self { Self::new() } } /// Generate a random 8-character hex string to use as an entry ID. /// /// Uses 4 random bytes (32 bits of entropy), producing IDs like `"a1b2c3d4"`. /// This gives ~4 billion possible values, which is more than sufficient for /// a family-scale vault (typically < 1000 entries). pub fn generate_entry_id() -> String { let mut rng = rand::thread_rng(); let bytes: [u8; 4] = rng.gen(); bytes.iter().map(|b| format!("{:02x}", b)).collect() } #[cfg(test)] mod tests { use super::*; #[test] fn entry_serialization_round_trip() { let entry = Entry { name: "GitHub".to_string(), url: Some("https://github.com".to_string()), username: Some("alice".to_string()), password: "s3cr3t".to_string(), notes: None, totp_secret: None, group: None, created_at: "2024-01-01T00:00:00Z".to_string(), updated_at: "2024-01-01T00:00:00Z".to_string(), }; let json = serde_json::to_string(&entry).unwrap(); let decoded: Entry = serde_json::from_str(&json).unwrap(); assert_eq!(decoded.name, entry.name); assert_eq!(decoded.url, entry.url); assert_eq!(decoded.username, entry.username); assert_eq!(decoded.password, entry.password); assert_eq!(decoded.notes, entry.notes); } #[test] fn manifest_add_and_lookup() { let mut manifest = Manifest::new(); let me = ManifestEntry { name: "GitHub".to_string(), url: Some("https://github.com".to_string()), username: Some("alice".to_string()), group: None, updated_at: "2024-01-01T00:00:00Z".to_string(), }; manifest.add_entry("abc12345".to_string(), me); assert!(manifest.entries.contains_key("abc12345")); assert_eq!(manifest.entries["abc12345"].name, "GitHub"); let removed = manifest.remove_entry("abc12345"); assert!(removed.is_some()); assert!(!manifest.entries.contains_key("abc12345")); } #[test] fn manifest_serialization_round_trip() { let mut manifest = Manifest::new(); manifest.add_entry( "deadbeef".to_string(), ManifestEntry { name: "Gmail".to_string(), url: Some("https://mail.google.com".to_string()), username: Some("user@gmail.com".to_string()), group: None, updated_at: "2024-06-01T00:00:00Z".to_string(), }, ); let json = serde_json::to_string(&manifest).unwrap(); let decoded: Manifest = serde_json::from_str(&json).unwrap(); assert_eq!(decoded.version, 1); assert!(decoded.entries.contains_key("deadbeef")); assert_eq!(decoded.entries["deadbeef"].name, "Gmail"); } #[test] fn generate_entry_id_is_8_hex_chars() { let id = generate_entry_id(); assert_eq!(id.len(), 8); assert!(id.chars().all(|c| c.is_ascii_hexdigit())); } #[test] fn manifest_search_case_insensitive() { let mut manifest = Manifest::new(); manifest.add_entry( "id001".to_string(), ManifestEntry { name: "GitHub Account".to_string(), url: Some("https://github.com".to_string()), username: None, group: None, updated_at: "2024-01-01T00:00:00Z".to_string(), }, ); manifest.add_entry( "id002".to_string(), ManifestEntry { name: "Work Email".to_string(), url: Some("https://mail.example.com".to_string()), username: None, group: None, updated_at: "2024-01-01T00:00:00Z".to_string(), }, ); // partial name match, case-insensitive let results = manifest.search("github"); assert_eq!(results.len(), 1); assert_eq!(results[0].1.name, "GitHub Account"); // partial URL match let results = manifest.search("mail.example"); assert_eq!(results.len(), 1); assert_eq!(results[0].1.name, "Work Email"); // no match let results = manifest.search("nonexistent"); assert_eq!(results.len(), 0); } #[test] fn entry_deserializes_without_group_field() { // JSON from an older vault that has no "group" key — must deserialize with group: None let json = r#"{ "name": "OldEntry", "url": "https://example.com", "username": "bob", "password": "hunter2", "created_at": "2024-01-01T00:00:00Z", "updated_at": "2024-01-01T00:00:00Z" }"#; let entry: Entry = serde_json::from_str(json).expect("should deserialize without group field"); assert_eq!(entry.name, "OldEntry"); assert_eq!(entry.group, None); } #[test] fn manifest_entry_deserializes_without_group_field() { // JSON from an older manifest that has no "group" key — must deserialize with group: None let json = r#"{ "name": "OldEntry", "url": "https://example.com", "username": "bob", "updated_at": "2024-01-01T00:00:00Z" }"#; let me: ManifestEntry = serde_json::from_str(json) .expect("should deserialize ManifestEntry without group field"); assert_eq!(me.name, "OldEntry"); assert_eq!(me.group, None); } #[test] fn entry_with_group_round_trips() { let entry = Entry { name: "Work Laptop".to_string(), url: None, username: Some("alice@corp.example".to_string()), password: "p@ssw0rd".to_string(), notes: None, totp_secret: None, group: Some("work".to_string()), created_at: "2024-03-15T00:00:00Z".to_string(), updated_at: "2024-03-15T00:00:00Z".to_string(), }; let json = serde_json::to_string(&entry).unwrap(); // The group field should be present in the JSON output assert!(json.contains("\"group\""), "serialized JSON should contain group field"); assert!(json.contains("\"work\""), "serialized JSON should contain group value"); let decoded: Entry = serde_json::from_str(&json).unwrap(); assert_eq!(decoded.name, "Work Laptop"); assert_eq!(decoded.group, Some("work".to_string())); } }