feat: add Entry, Manifest, ManifestEntry data model with serde
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
194
crates/idfoto-core/src/entry.rs
Normal file
194
crates/idfoto-core/src/entry.rs
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
use rand::Rng;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// A single password entry (stored encrypted in entries/<id>.enc).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Entry {
|
||||||
|
pub name: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub url: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub password: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub notes: Option<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub totp_secret: Option<String>,
|
||||||
|
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<String>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub username: Option<String>,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The vault manifest — maps entry IDs to their metadata.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct Manifest {
|
||||||
|
pub entries: HashMap<String, ManifestEntry>,
|
||||||
|
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<ManifestEntry> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,3 +3,9 @@ pub use error::{IdfotoError, Result};
|
|||||||
|
|
||||||
pub mod crypto;
|
pub mod crypto;
|
||||||
pub use crypto::{decrypt, derive_master_key, encrypt, KdfParams};
|
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};
|
||||||
|
|||||||
Reference in New Issue
Block a user