diff --git a/crates/idfoto-core/src/entry.rs b/crates/idfoto-core/src/entry.rs new file mode 100644 index 0000000..db0d36a --- /dev/null +++ b/crates/idfoto-core/src/entry.rs @@ -0,0 +1,194 @@ +use rand::Rng; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +/// A single password entry (stored encrypted in entries/.enc). +#[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, + pub created_at: String, + pub updated_at: String, +} + +/// Summary info about an entry (stored in the manifest). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ManifestEntry { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, + pub updated_at: String, +} + +/// The vault manifest — maps entry IDs to their metadata. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Manifest { + pub entries: HashMap, + pub version: u32, +} + +impl Manifest { + pub fn new() -> Self { + Manifest { + entries: HashMap::new(), + version: 1, + } + } + + pub fn add_entry(&mut self, id: String, entry: ManifestEntry) { + self.entries.insert(id, entry); + } + + pub fn remove_entry(&mut self, id: &str) -> Option { + self.entries.remove(id) + } + + 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. +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, + 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()), + 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()), + 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, + 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, + 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); + } +} diff --git a/crates/idfoto-core/src/lib.rs b/crates/idfoto-core/src/lib.rs index 221c1d3..954d1dc 100644 --- a/crates/idfoto-core/src/lib.rs +++ b/crates/idfoto-core/src/lib.rs @@ -3,3 +3,9 @@ pub use error::{IdfotoError, Result}; pub mod crypto; pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams}; + +pub mod entry; +pub use entry::{generate_entry_id, Entry, Manifest, ManifestEntry}; + +pub mod vault; +pub use vault::{decrypt_entry, decrypt_manifest, encrypt_entry, encrypt_manifest};