use std::path::{Path, PathBuf};
use std::collections::HashMap;
use crate::error::{WalletError, Result};
use crate::database::{Database, IWItem, IWField, IWLabel, IWProperties};
use crate::database::queries::{self, parse_timestamp, CryptoRecord};
use crate::database::migrations;
use crate::crypto;
use crate::crypto::dek::DEK_LEN;
use crate::utils::generate_database_id;
use crate::{DATABASE_FILENAME, ROOT_ID, ROOT_PARENT_ID, DB_VERSION, ENCRYPTION_COUNT_DEFAULT};
use rand::Rng;
use zeroize::Zeroizing;
pub const PRE_V6_BACKUP_FILENAME: &str = "nswallet.pre-v6.bak";
const CRYPTO_SCHEME_V6: i64 = 1;
const KDF_SALT_LEN: usize = 16;
pub(crate) struct Unlocked {
dek: Zeroizing<[u8; DEK_LEN]>,
}
fn random_bytes(n: usize) -> Vec<u8> {
let mut v = vec![0u8; n];
rand::rng().fill_bytes(&mut v);
v
}
pub struct Wallet {
pub(crate) folder: PathBuf,
pub(crate) db: Option<Database>,
pub(crate) unlocked: Option<Unlocked>,
pub(crate) encryption_count: u32,
pub(crate) items_cache: Option<Vec<IWItem>>,
pub(crate) fields_cache: Option<Vec<IWField>>,
pub(crate) labels_cache: Option<HashMap<String, IWLabel>>,
}
impl Wallet {
pub fn open(folder: &Path) -> Result<Self> {
let db_path = folder.join(DATABASE_FILENAME);
if !db_path.exists() {
return Err(WalletError::DatabaseNotFound(
db_path.to_string_lossy().to_string()
));
}
let db = Database::open(&db_path)?;
{
let conn = db.connection()?;
let current = migrations::get_database_version(conn)?;
migrations::upgrade_database(conn, ¤t)?;
}
Ok(Self {
folder: folder.to_path_buf(),
db: Some(db),
unlocked: None,
encryption_count: ENCRYPTION_COUNT_DEFAULT,
items_cache: None,
fields_cache: None,
labels_cache: None,
})
}
pub fn create(folder: &Path, password: &str, lang: &str) -> Result<Self> {
std::fs::create_dir_all(folder)?;
let db_path = folder.join(DATABASE_FILENAME);
let db = Database::create(&db_path)?;
let mut wallet = Self {
folder: folder.to_path_buf(),
db: Some(db),
unlocked: None,
encryption_count: 0,
items_cache: None,
fields_cache: None,
labels_cache: None,
};
wallet.init_new_database(password, lang)?;
Ok(wallet)
}
fn init_new_database(&mut self, password: &str, lang: &str) -> Result<()> {
let dek = crypto::dek::generate_dek();
let params = crypto::kdf::KdfParams::current();
let salt = random_bytes(KDF_SALT_LEN);
let kek = crypto::kdf::derive_kek(password.as_bytes(), &salt, params)
.map_err(WalletError::EncryptionError)?;
let dek_wrapped = crypto::dek::wrap_dek(&kek, &dek)
.map_err(WalletError::EncryptionError)?;
self.unlocked = Some(Unlocked { dek: Zeroizing::new(dek) });
let db_id = generate_database_id();
let root_data = crate::utils::generate_id(32);
let encrypted_root = self.enc_value(&root_data)?;
{
let conn = self.db.as_ref()
.ok_or_else(|| WalletError::DatabaseError("Database not open".to_string()))?
.connection()?;
queries::set_properties(conn, &db_id, lang, DB_VERSION, 0)?;
queries::set_crypto_record(conn, &CryptoRecord {
scheme: CRYPTO_SCHEME_V6,
kdf: "argon2id".to_string(),
m_cost_kib: params.m_cost_kib,
t_cost: params.t_cost,
p_cost: params.p_cost,
salt,
dek_wrapped,
})?;
queries::create_item(conn, ROOT_ID, ROOT_PARENT_ID, &encrypted_root, "", true)?;
}
self.add_system_labels()?;
Ok(())
}
pub fn unlock(&mut self, password: &str) -> Result<bool> {
let (props, crypto_rec, root_blob) = {
let conn = self.db.as_ref()
.ok_or_else(|| WalletError::DatabaseError("Database not open".to_string()))?
.connection()?;
(
queries::get_properties(conn)?,
queries::get_crypto_record(conn)?,
queries::get_root_item_raw(conn)?,
)
};
self.encryption_count = props
.as_ref()
.and_then(|p| p.email.parse().ok())
.unwrap_or(ENCRYPTION_COUNT_DEFAULT);
if let Some(rec) = crypto_rec {
match self.unwrap_with_password(&rec, password) {
Some(dek) => {
self.unlocked = Some(Unlocked { dek: Zeroizing::new(dek) });
self.clear_caches();
self.add_system_labels()?;
Ok(true)
}
None => Ok(false),
}
} else {
let Some(encrypted_name) = root_blob else {
return Err(WalletError::DatabaseError("Root item not found".to_string()));
};
if crypto::legacy::decrypt(&encrypted_name, password, self.encryption_count, None).is_err() {
return Ok(false);
}
self.migrate_v5_to_v6(password)?;
self.clear_caches();
self.add_system_labels()?;
Ok(true)
}
}
fn unwrap_with_password(&self, rec: &CryptoRecord, password: &str) -> Option<[u8; DEK_LEN]> {
let params = crypto::kdf::KdfParams {
m_cost_kib: rec.m_cost_kib,
t_cost: rec.t_cost,
p_cost: rec.p_cost,
};
let kek = crypto::kdf::derive_kek(password.as_bytes(), &rec.salt, params).ok()?;
crypto::dek::unwrap_dek(&kek, &rec.dek_wrapped).ok()
}
pub fn lock(&mut self) {
self.unlocked = None;
self.clear_caches();
}
pub fn is_unlocked(&self) -> bool {
self.unlocked.is_some()
}
fn dek(&self) -> Result<&[u8; DEK_LEN]> {
self.unlocked.as_ref().map(|u| &*u.dek).ok_or(WalletError::Locked)
}
pub(crate) fn enc_value(&self, plaintext: &str) -> Result<Vec<u8>> {
crypto::aead::seal(self.dek()?, plaintext.as_bytes())
.map_err(WalletError::EncryptionError)
}
pub(crate) fn dec_value(&self, blob: &[u8]) -> Result<String> {
let pt = crypto::aead::open(self.dek()?, blob).map_err(WalletError::DecryptionError)?;
String::from_utf8(pt)
.map_err(|e| WalletError::DecryptionError(format!("invalid UTF-8: {e}")))
}
pub fn close(&mut self) {
self.lock();
if let Some(mut db) = self.db.take() {
db.close();
}
}
pub(crate) fn clear_caches(&mut self) {
self.items_cache = None;
self.fields_cache = None;
self.labels_cache = None;
}
pub fn folder(&self) -> &Path {
&self.folder
}
pub fn check_password(&self, password: &str) -> Result<bool> {
let (props, crypto_rec, root_blob) = {
let conn = self.db.as_ref()
.ok_or_else(|| WalletError::DatabaseError("Database not open".to_string()))?
.connection()?;
(
queries::get_properties(conn)?,
queries::get_crypto_record(conn)?,
queries::get_root_item_raw(conn)?,
)
};
if let Some(rec) = crypto_rec {
Ok(self.unwrap_with_password(&rec, password).is_some())
} else {
let Some(encrypted_name) = root_blob else {
return Err(WalletError::DatabaseError("Root item not found".to_string()));
};
let encryption_count = props
.as_ref()
.and_then(|p| p.email.parse().ok())
.unwrap_or(ENCRYPTION_COUNT_DEFAULT);
Ok(crypto::legacy::decrypt(&encrypted_name, password, encryption_count, None).is_ok())
}
}
pub fn get_properties(&self) -> Result<IWProperties> {
let db = self.db.as_ref().ok_or(WalletError::DatabaseError(
"Database not open".to_string()
))?;
let conn = db.connection()?;
let raw_props = queries::get_properties(conn)?
.ok_or_else(|| WalletError::DatabaseError("Properties not found".to_string()))?;
Ok(IWProperties {
database_id: raw_props.database_id,
lang: raw_props.lang,
version: raw_props.version,
encryption_count: raw_props.email.parse().unwrap_or(ENCRYPTION_COUNT_DEFAULT),
sync_timestamp: raw_props.sync_timestamp.as_ref().and_then(|s| parse_timestamp(s)),
update_timestamp: raw_props.update_timestamp.as_ref().and_then(|s| parse_timestamp(s)),
})
}
pub fn change_password(&mut self, new_password: &str) -> Result<bool> {
self.ensure_unlocked()?;
let dek = *self.dek()?;
let params = crypto::kdf::KdfParams::current();
let salt = random_bytes(KDF_SALT_LEN);
let kek = crypto::kdf::derive_kek(new_password.as_bytes(), &salt, params)
.map_err(WalletError::EncryptionError)?;
let dek_wrapped = crypto::dek::wrap_dek(&kek, &dek)
.map_err(WalletError::EncryptionError)?;
let conn = self.db.as_ref()
.ok_or_else(|| WalletError::DatabaseError("Database not open".to_string()))?
.connection()?;
queries::set_crypto_record(conn, &CryptoRecord {
scheme: CRYPTO_SCHEME_V6,
kdf: "argon2id".to_string(),
m_cost_kib: params.m_cost_kib,
t_cost: params.t_cost,
p_cost: params.p_cost,
salt,
dek_wrapped,
})?;
Ok(true)
}
pub(crate) fn ensure_unlocked(&self) -> Result<()> {
if self.unlocked.is_none() {
return Err(WalletError::Locked);
}
Ok(())
}
fn create_pre_v6_backup(&self) -> Result<()> {
let dst = self.folder.join(PRE_V6_BACKUP_FILENAME);
if dst.exists() {
std::fs::remove_file(&dst).map_err(|e| {
WalletError::BackupError(format!("Failed to remove stale pre-v6 backup: {e}"))
})?;
}
let conn = self.db.as_ref()
.ok_or_else(|| WalletError::DatabaseError("Database not open".to_string()))?
.connection()?;
conn.execute("VACUUM INTO ?", [dst.to_string_lossy().to_string()])
.map_err(|e| WalletError::BackupError(format!("Pre-v6 snapshot (VACUUM INTO) failed: {e}")))?;
Ok(())
}
fn migrate_v5_to_v6(&mut self, password: &str) -> Result<()> {
let enc_count = self.encryption_count;
self.db.as_ref()
.ok_or_else(|| WalletError::DatabaseError("Database not open".to_string()))?
.checkpoint()?;
self.create_pre_v6_backup()?;
let dek = crypto::dek::generate_dek();
let params = crypto::kdf::KdfParams::current();
let salt = random_bytes(KDF_SALT_LEN);
let kek = crypto::kdf::derive_kek(password.as_bytes(), &salt, params)
.map_err(WalletError::EncryptionError)?;
let dek_wrapped = crypto::dek::wrap_dek(&kek, &dek)
.map_err(WalletError::EncryptionError)?;
let (item_blobs, field_blobs) = {
let conn = self.db.as_ref()
.ok_or_else(|| WalletError::DatabaseError("Database not open".to_string()))?
.connection()?;
(queries::get_all_item_blobs(conn)?, queries::get_all_field_blobs(conn)?)
};
let db = self.db.as_mut()
.ok_or_else(|| WalletError::DatabaseError("Database not open".to_string()))?;
db.begin_transaction()?;
let result = (|| -> Result<()> {
let conn = db.connection()?;
queries::ensure_crypto_table(conn)?;
for (item_id, blob, deleted) in &item_blobs {
match crypto::legacy::decrypt(blob, password, enc_count, None) {
Ok(plaintext) => {
let new_blob = crypto::aead::seal(&dek, plaintext.as_bytes())
.map_err(WalletError::EncryptionError)?;
queries::update_item_name_only(conn, item_id, &new_blob)?;
}
Err(e) => {
if *deleted {
queries::hard_delete_item(conn, item_id)?;
} else {
return Err(WalletError::DecryptionError(
format!("active item {item_id}: {e}"),
));
}
}
}
}
for (item_id, field_id, blob, deleted) in &field_blobs {
match crypto::legacy::decrypt(blob, password, enc_count, None) {
Ok(plaintext) => {
let new_blob = crypto::aead::seal(&dek, plaintext.as_bytes())
.map_err(WalletError::EncryptionError)?;
queries::update_field_value_only(conn, item_id, field_id, &new_blob)?;
}
Err(e) => {
if *deleted {
queries::hard_delete_field(conn, item_id, field_id)?;
} else {
return Err(WalletError::DecryptionError(
format!("active field {item_id}/{field_id}: {e}"),
));
}
}
}
}
queries::set_crypto_record(conn, &CryptoRecord {
scheme: CRYPTO_SCHEME_V6,
kdf: "argon2id".to_string(),
m_cost_kib: params.m_cost_kib,
t_cost: params.t_cost,
p_cost: params.p_cost,
salt: salt.clone(),
dek_wrapped: dek_wrapped.clone(),
})?;
queries::set_db_version_no_checkpoint(conn, DB_VERSION)?;
Ok(())
})();
match result {
Ok(()) => db.commit_transaction()?,
Err(e) => {
db.rollback_transaction()?;
return Err(e);
}
}
let _ = self.db.as_ref().unwrap().checkpoint();
self.unlocked = Some(Unlocked { dek: Zeroizing::new(dek) });
Ok(())
}
pub fn database_path(&self) -> PathBuf {
self.folder.join(DATABASE_FILENAME)
}
pub fn compact(&mut self) -> Result<(u32, u32)> {
self.ensure_unlocked()?;
let conn = self.db.as_ref()
.ok_or_else(|| WalletError::DatabaseError("Database not open".to_string()))?
.connection()?;
let result = queries::purge_deleted(conn)?;
self.clear_caches();
Ok(result)
}
pub fn get_database_stats(&self) -> Result<queries::DatabaseStats> {
let conn = self.db.as_ref()
.ok_or_else(|| WalletError::DatabaseError("Database not open".to_string()))?
.connection()?;
let mut stats = queries::get_database_stats(conn)?;
let db_path = self.database_path();
if let Ok(metadata) = std::fs::metadata(&db_path) {
stats.file_size_bytes = metadata.len();
}
Ok(stats)
}
pub fn database(&self) -> Result<&Database> {
self.db.as_ref().ok_or_else(|| WalletError::DatabaseError("Database not open".to_string()))
}
}
impl Drop for Wallet {
fn drop(&mut self) {
self.close();
}
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
use tempfile::TempDir;
use crate::DB_VERSION;
pub fn create_test_wallet() -> (Wallet, TempDir) {
let temp_dir = TempDir::new().unwrap();
let wallet = Wallet::create(temp_dir.path(), "TestPassword123", "en").unwrap();
(wallet, temp_dir)
}
#[test]
fn test_create_and_unlock() {
let (mut wallet, _temp) = create_test_wallet();
wallet.lock();
assert!(!wallet.is_unlocked());
assert!(wallet.unlock("TestPassword123").unwrap());
assert!(wallet.is_unlocked());
}
#[test]
fn test_open_migrates_old_database() {
use rusqlite::Connection;
let temp_dir = TempDir::new().unwrap();
let wallet = Wallet::create(temp_dir.path(), "TestPassword123", "en").unwrap();
let folder = temp_dir.path().to_path_buf();
drop(wallet);
let db_path = folder.join(crate::DATABASE_FILENAME);
let conn = Connection::open(&db_path).unwrap();
conn.execute("UPDATE nswallet_properties SET version = ?", ["4"]).unwrap();
drop(conn);
let _ = Wallet::open(&folder).unwrap();
let conn = Connection::open(&db_path).unwrap();
let v: String = conn
.query_row(
"SELECT version FROM nswallet_properties LIMIT 1",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(v, migrations::CURRENT_VERSION);
let seed_count: i32 = conn
.query_row(
"SELECT COUNT(*) FROM nswallet_labels WHERE field_type = 'SEED'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(seed_count, 1);
}
#[test]
fn test_open_no_op_on_current_database() {
let temp_dir = TempDir::new().unwrap();
let wallet = Wallet::create(temp_dir.path(), "TestPassword123", "en").unwrap();
let folder = temp_dir.path().to_path_buf();
drop(wallet);
let _ = Wallet::open(&folder).unwrap();
use rusqlite::Connection;
let db_path = folder.join(crate::DATABASE_FILENAME);
let conn = Connection::open(&db_path).unwrap();
let v: String = conn
.query_row(
"SELECT version FROM nswallet_properties LIMIT 1",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(v, DB_VERSION);
}
#[test]
fn test_wrong_password() {
let (mut wallet, _temp) = create_test_wallet();
wallet.lock();
assert!(!wallet.unlock("WrongPassword").unwrap());
assert!(!wallet.is_unlocked());
}
#[test]
fn test_properties() {
let (wallet, _temp) = create_test_wallet();
let props = wallet.get_properties().unwrap();
assert_eq!(props.lang, "en");
assert_eq!(props.version, DB_VERSION);
assert_eq!(props.encryption_count, 0);
assert_eq!(props.database_id.len(), 32);
}
#[test]
fn test_change_password() {
let (mut wallet, _temp) = create_test_wallet();
let item_id = wallet.add_item("Test Item", "document", false, None).unwrap();
wallet.add_field(&item_id, "PASS", "secret123", None).unwrap();
assert!(wallet.change_password("NewPassword456").unwrap());
wallet.lock();
assert!(!wallet.unlock("TestPassword123").unwrap());
assert!(wallet.unlock("NewPassword456").unwrap());
let fields = wallet.get_fields_by_item(&item_id).unwrap();
assert_eq!(fields[0].value, "secret123");
}
#[test]
fn test_wallet_folder() {
let (wallet, temp) = create_test_wallet();
assert_eq!(wallet.folder(), temp.path());
}
#[test]
fn test_database_path() {
let (wallet, temp) = create_test_wallet();
assert_eq!(wallet.database_path(), temp.path().join("nswallet.dat"));
}
#[test]
fn test_open_nonexistent() {
let result = Wallet::open(std::path::Path::new("/nonexistent/path"));
assert!(result.is_err());
}
#[test]
fn test_check_password() {
let (mut wallet, _temp) = create_test_wallet();
wallet.lock();
assert!(wallet.check_password("TestPassword123").unwrap());
assert!(!wallet.check_password("WrongPassword").unwrap());
}
fn create_wallet_with_encryption_count(encryption_count: u32) -> (TempDir, std::path::PathBuf) {
let temp_dir = TempDir::new().unwrap();
let path = temp_dir.path().to_path_buf();
let password = "TestPassword123";
let wallet = Wallet::create(&path, password, "en").unwrap();
let conn = wallet.db.as_ref().unwrap().connection().unwrap();
let root_raw = queries::get_root_item_raw(conn).unwrap().unwrap();
let plaintext = wallet.dec_value(&root_raw).unwrap();
let legacy_root = crypto::legacy::encrypt(&plaintext, password, encryption_count, None).unwrap();
conn.execute(
"UPDATE nswallet_items SET name = ? WHERE item_id = '__ROOT__'",
rusqlite::params![legacy_root],
).unwrap();
conn.execute("DROP TABLE nswallet_crypto", []).unwrap();
conn.execute(
"UPDATE nswallet_properties SET version = '5', email = ?",
rusqlite::params![encryption_count.to_string()],
).unwrap();
drop(wallet);
(temp_dir, path)
}
#[test]
fn test_check_password_after_reopen_enc0() {
let (_temp, path) = create_wallet_with_encryption_count(0);
let wallet = Wallet::open(&path).unwrap();
assert!(wallet.check_password("TestPassword123").unwrap());
assert!(!wallet.check_password("WrongPassword").unwrap());
}
#[test]
fn test_check_password_after_reopen_enc33() {
let (_temp, path) = create_wallet_with_encryption_count(33);
let wallet = Wallet::open(&path).unwrap();
assert!(wallet.check_password("TestPassword123").unwrap());
assert!(!wallet.check_password("WrongPassword").unwrap());
}
#[test]
fn test_check_password_after_reopen_enc200() {
let (_temp, path) = create_wallet_with_encryption_count(200);
let wallet = Wallet::open(&path).unwrap();
assert!(wallet.check_password("TestPassword123").unwrap());
assert!(!wallet.check_password("WrongPassword").unwrap());
}
#[test]
fn test_check_password_after_reopen_enc500() {
let (_temp, path) = create_wallet_with_encryption_count(500);
let wallet = Wallet::open(&path).unwrap();
assert!(wallet.check_password("TestPassword123").unwrap());
assert!(!wallet.check_password("WrongPassword").unwrap());
}
#[test]
fn test_compact_items() {
let (mut wallet, _temp) = create_test_wallet();
let item_id = wallet.add_item("To Purge", "document", false, None).unwrap();
wallet.delete_item(&item_id).unwrap();
wallet.compact().unwrap();
let deleted = wallet.get_deleted_items().unwrap();
assert!(deleted.is_empty());
}
#[test]
fn test_compact_fields() {
let (mut wallet, _temp) = create_test_wallet();
let item_id = wallet.add_item("Item", "document", false, None).unwrap();
let field_id = wallet.add_field(&item_id, "MAIL", "purge@test.com", None).unwrap();
wallet.delete_field(&item_id, &field_id).unwrap();
wallet.compact().unwrap();
let deleted = wallet.get_deleted_fields().unwrap();
assert!(deleted.is_empty());
}
#[test]
fn test_compact_cascaded_fields() {
let (mut wallet, _temp) = create_test_wallet();
let item_id = wallet.add_item("Item", "document", false, None).unwrap();
wallet.add_field(&item_id, "MAIL", "orphan@test.com", None).unwrap();
wallet.add_field(&item_id, "PASS", "secret", None).unwrap();
wallet.delete_item(&item_id).unwrap();
wallet.compact().unwrap();
let deleted_fields = wallet.get_deleted_fields().unwrap();
assert!(deleted_fields.is_empty());
let deleted_items = wallet.get_deleted_items().unwrap();
assert!(deleted_items.is_empty());
}
#[test]
fn test_compact_returns_counts() {
let (mut wallet, _temp) = create_test_wallet();
let item1_id = wallet.add_item("Item 1", "document", false, None).unwrap();
let item2_id = wallet.add_item("Item 2", "document", false, None).unwrap();
let _field_id = wallet.add_field(&item1_id, "MAIL", "test@test.com", None).unwrap();
wallet.delete_item(&item1_id).unwrap();
wallet.delete_item(&item2_id).unwrap();
let (items_count, fields_count) = wallet.compact().unwrap();
assert_eq!(items_count, 2);
assert_eq!(fields_count, 1); }
#[test]
fn test_compact_empty() {
let (mut wallet, _temp) = create_test_wallet();
let (items, fields) = wallet.compact().unwrap();
assert_eq!(items, 0);
assert_eq!(fields, 0);
}
#[test]
fn test_compact_preserves_active_records() {
let (mut wallet, _temp) = create_test_wallet();
let item1 = wallet.add_item("Keep This", "document", false, None).unwrap();
wallet.add_field(&item1, "MAIL", "keep@test.com", None).unwrap();
let item2 = wallet.add_item("Delete This", "document", false, None).unwrap();
wallet.add_field(&item2, "PASS", "gone", None).unwrap();
wallet.delete_item(&item2).unwrap();
wallet.compact().unwrap();
let item = wallet.get_item(&item1).unwrap().unwrap();
assert_eq!(item.name, "Keep This");
let fields = wallet.get_fields_by_item(&item1).unwrap();
assert_eq!(fields.len(), 1);
assert_eq!(fields[0].value, "keep@test.com");
}
#[test]
fn test_compact_double_call_idempotent() {
let (mut wallet, _temp) = create_test_wallet();
let item_id = wallet.add_item("Delete Me", "document", false, None).unwrap();
wallet.delete_item(&item_id).unwrap();
let (i1, _f1) = wallet.compact().unwrap();
assert_eq!(i1, 1);
let (i2, f2) = wallet.compact().unwrap();
assert_eq!(i2, 0);
assert_eq!(f2, 0);
}
#[test]
fn test_compact_after_cascade_delete() {
let (mut wallet, _temp) = create_test_wallet();
let folder = wallet.add_item("Folder", "folder", true, None).unwrap();
let child1 = wallet.add_item("Child 1", "document", false, Some(&folder)).unwrap();
let child2 = wallet.add_item("Child 2", "document", false, Some(&folder)).unwrap();
wallet.add_field(&child1, "MAIL", "c1@test.com", None).unwrap();
wallet.add_field(&child2, "PASS", "secret", None).unwrap();
wallet.delete_item(&folder).unwrap();
let (items_count, fields_count) = wallet.compact().unwrap();
assert_eq!(items_count, 3); assert_eq!(fields_count, 2);
assert!(wallet.get_deleted_items().unwrap().is_empty());
assert!(wallet.get_deleted_fields().unwrap().is_empty());
}
#[test]
fn test_compact_mixed_deleted_and_cascaded_fields() {
let (mut wallet, _temp) = create_test_wallet();
let item_id = wallet.add_item("Item", "document", false, None).unwrap();
let f1 = wallet.add_field(&item_id, "MAIL", "test@test.com", None).unwrap();
wallet.add_field(&item_id, "PASS", "secret", None).unwrap();
wallet.delete_field(&item_id, &f1).unwrap();
wallet.delete_item(&item_id).unwrap();
let (items_count, fields_count) = wallet.compact().unwrap();
assert_eq!(items_count, 1);
assert_eq!(fields_count, 2);
}
#[test]
fn test_compact_with_active_and_deleted_fields_same_item() {
let (mut wallet, _temp) = create_test_wallet();
let item_id = wallet.add_item("Item", "document", false, None).unwrap();
let f_del = wallet.add_field(&item_id, "MAIL", "delete@me.com", None).unwrap();
wallet.add_field(&item_id, "PASS", "keep_me", None).unwrap();
wallet.delete_field(&item_id, &f_del).unwrap();
wallet.compact().unwrap();
assert!(wallet.get_deleted_fields().unwrap().is_empty());
let active = wallet.get_fields_by_item(&item_id).unwrap();
assert_eq!(active.len(), 1);
assert_eq!(active[0].value, "keep_me");
}
#[test]
fn test_database_stats() {
let (mut wallet, _temp) = create_test_wallet();
let folder = wallet.add_item("Folder", "folder", true, None).unwrap();
let item1 = wallet.add_item("Item 1", "document", false, None).unwrap();
let item2 = wallet.add_item("Item 2", "document", false, Some(&folder)).unwrap();
wallet.add_field(&item1, "MAIL", "a@a.com", None).unwrap();
wallet.add_field(&item1, "PASS", "secret", None).unwrap();
wallet.add_field(&item2, "NOTE", "note", None).unwrap();
wallet.delete_item(&item2).unwrap();
let stats = wallet.get_database_stats().unwrap();
assert_eq!(stats.total_items, 1); assert_eq!(stats.total_folders, 1); assert_eq!(stats.total_fields, 2); assert_eq!(stats.deleted_items, 1); assert_eq!(stats.deleted_fields, 1); assert!(stats.total_labels >= 19); assert!(stats.file_size_bytes > 0);
}
#[test]
fn test_change_password_reencrypts_deleted_items() {
let (mut wallet, _temp) = create_test_wallet();
let item_id = wallet.add_item("Deleted Item", "document", false, None).unwrap();
wallet.delete_item(&item_id).unwrap();
assert!(wallet.change_password("NewPassword456").unwrap());
let deleted = wallet.get_deleted_items().unwrap();
assert_eq!(deleted.len(), 1);
assert_eq!(deleted[0].name, "Deleted Item");
wallet.lock();
assert!(wallet.unlock("NewPassword456").unwrap());
let deleted2 = wallet.get_deleted_items().unwrap();
assert_eq!(deleted2.len(), 1);
assert_eq!(deleted2[0].name, "Deleted Item");
}
#[test]
fn test_change_password_reencrypts_deleted_fields() {
let (mut wallet, _temp) = create_test_wallet();
let item_id = wallet.add_item("Item", "document", false, None).unwrap();
let field_id = wallet.add_field(&item_id, "PASS", "my_secret", None).unwrap();
wallet.delete_field(&item_id, &field_id).unwrap();
assert!(wallet.change_password("NewPassword456").unwrap());
let deleted = wallet.get_deleted_fields().unwrap();
assert_eq!(deleted.len(), 1);
assert_eq!(deleted[0].value, "my_secret");
wallet.lock();
assert!(wallet.unlock("NewPassword456").unwrap());
let deleted2 = wallet.get_deleted_fields().unwrap();
assert_eq!(deleted2.len(), 1);
assert_eq!(deleted2[0].value, "my_secret");
}
#[test]
fn test_change_password_reencrypts_cascade_deleted_fields() {
let (mut wallet, _temp) = create_test_wallet();
let item_id = wallet.add_item("Item", "document", false, None).unwrap();
wallet.add_field(&item_id, "MAIL", "cascade@test.com", None).unwrap();
wallet.add_field(&item_id, "PASS", "cascade_secret", None).unwrap();
wallet.delete_item(&item_id).unwrap();
let deleted_before = wallet.get_deleted_fields().unwrap();
let our_fields: Vec<_> = deleted_before.iter().filter(|f| f.item_id == item_id).collect();
assert_eq!(our_fields.len(), 2);
assert!(wallet.change_password("NewPassword456").unwrap());
let deleted_after = wallet.get_deleted_fields().unwrap();
let our_fields_after: Vec<_> = deleted_after.iter().filter(|f| f.item_id == item_id).collect();
assert_eq!(our_fields_after.len(), 2);
let values: Vec<&str> = our_fields_after.iter().map(|f| f.value.as_str()).collect();
assert!(values.contains(&"cascade@test.com"));
assert!(values.contains(&"cascade_secret"));
wallet.lock();
assert!(wallet.unlock("NewPassword456").unwrap());
let deleted_reopen = wallet.get_deleted_fields().unwrap();
let our_fields_reopen: Vec<_> = deleted_reopen.iter().filter(|f| f.item_id == item_id).collect();
assert_eq!(our_fields_reopen.len(), 2);
}
#[test]
fn test_change_password_mixed_active_and_deleted() {
let (mut wallet, _temp) = create_test_wallet();
let active_item = wallet.add_item("Active Item", "document", false, None).unwrap();
wallet.add_field(&active_item, "MAIL", "active@test.com", None).unwrap();
let del_item = wallet.add_item("Deleted Item", "document", false, None).unwrap();
let del_field = wallet.add_field(&del_item, "PASS", "deleted_secret", None).unwrap();
wallet.delete_item(&del_item).unwrap();
wallet.delete_field(&del_item, &del_field).unwrap();
assert!(wallet.change_password("NewPassword456").unwrap());
let item = wallet.get_item(&active_item).unwrap().unwrap();
assert_eq!(item.name, "Active Item");
let fields = wallet.get_fields_by_item(&active_item).unwrap();
assert_eq!(fields[0].value, "active@test.com");
let deleted_items = wallet.get_deleted_items().unwrap();
assert_eq!(deleted_items.len(), 1);
assert_eq!(deleted_items[0].name, "Deleted Item");
let deleted_fields = wallet.get_deleted_fields().unwrap();
assert_eq!(deleted_fields.len(), 1);
assert_eq!(deleted_fields[0].value, "deleted_secret");
}
}