diff --git a/core/conversations/src/api.rs b/core/conversations/src/api.rs index bd1e300..ea16f76 100644 --- a/core/conversations/src/api.rs +++ b/core/conversations/src/api.rs @@ -13,9 +13,12 @@ use safer_ffi::{ prelude::{c_slice, repr_c}, }; +use storage::StorageConfig; + use crate::{ context::{Context, Introduction}, errors::ChatError, + sqlite::ChatStorage, types::ContentData, }; @@ -42,7 +45,7 @@ pub fn is_ok(error: i32) -> bool { /// Opaque wrapper for Context #[derive_ReprC] #[repr(opaque)] -pub struct ContextHandle(pub(crate) Context); +pub struct ContextHandle(pub(crate) Context); /// Creates a new libchat Ctx /// @@ -51,7 +54,9 @@ pub struct ContextHandle(pub(crate) Context); #[ffi_export] pub fn create_context(name: repr_c::String) -> repr_c::Box { // Deference name to to `str` and then borrow to &str - Box::new(ContextHandle(Context::new_with_name(&*name))).into() + let store = + ChatStorage::new(StorageConfig::InMemory).expect("in-memory storage should not fail"); + Box::new(ContextHandle(Context::new_with_name(&*name, store))).into() } /// Returns the friendly name of the contexts installation. diff --git a/core/conversations/src/context.rs b/core/conversations/src/context.rs index 066b536..e7945b3 100644 --- a/core/conversations/src/context.rs +++ b/core/conversations/src/context.rs @@ -10,8 +10,8 @@ use crate::{ identity::Identity, inbox::Inbox, proto::{EncryptedPayload, EnvelopeV1, Message}, - storage::ChatStorage, - store::{ChatStore, ConversationKind, ConversationMeta}, + sqlite::ChatStorage, + store::{ChatStore, ConversationKind, ConversationMeta, IdentityStore}, types::{AddressedEnvelope, ContentData}, }; @@ -315,7 +315,7 @@ mod mock { #[cfg(test)] mod tests { - use crate::{context::mock::MockChatStore, store::ConversationStore}; + use crate::{context::mock::MockChatStore, sqlite::ChatStorage, store::ConversationStore}; use super::*; @@ -402,11 +402,13 @@ mod tests { .to_string(); let config = StorageConfig::File(db_path); - let mut ctx1 = Context::open("alice", config.clone(), MockChatStore::default()).unwrap(); + let store1 = ChatStorage::new(config.clone()).unwrap(); + let mut ctx1 = Context::open("alice", config.clone(), store1).unwrap(); let bundle1 = ctx1.create_intro_bundle().unwrap(); drop(ctx1); - let mut ctx2 = Context::open("alice", config.clone(), MockChatStore::default()).unwrap(); + let store2 = ChatStorage::new(config.clone()).unwrap(); + let mut ctx2 = Context::open("alice", config.clone(), store2).unwrap(); let intro = Introduction::try_from(bundle1.as_slice()).unwrap(); let mut bob = Context::new_with_name("bob", MockChatStore::default()); @@ -431,7 +433,8 @@ mod tests { .to_string(); let config = StorageConfig::File(db_path); - let mut alice = Context::open("alice", config.clone(), MockChatStore::default()).unwrap(); + let store = ChatStorage::new(config.clone()).unwrap(); + let mut alice = Context::open("alice", config.clone(), store).unwrap(); let mut bob = Context::new_with_name("bob", MockChatStore::default()); let bundle = alice.create_intro_bundle().unwrap(); @@ -447,7 +450,8 @@ mod tests { assert_eq!(convos[0].kind.as_str(), "private_v1"); drop(alice); - let alice2 = Context::open("alice", config, MockChatStore::default()).unwrap(); + let store2 = ChatStorage::new(config.clone()).unwrap(); + let alice2 = Context::open("alice", config, store2).unwrap(); let convos = alice2.storage.load_conversations().unwrap(); assert_eq!(convos.len(), 1, "conversation metadata should persist"); } @@ -463,7 +467,8 @@ mod tests { let config = StorageConfig::File(db_path); // Alice and Bob establish a conversation - let mut alice = Context::open("alice", config.clone(), MockChatStore::default()).unwrap(); + let store = ChatStorage::new(config.clone()).unwrap(); + let mut alice = Context::open("alice", config.clone(), store).unwrap(); let mut bob = Context::new_with_name("bob", MockChatStore::default()); let bundle = alice.create_intro_bundle().unwrap(); @@ -485,7 +490,8 @@ mod tests { // Drop Alice and reopen - conversation should survive drop(alice); - let mut alice2 = Context::open("alice", config, MockChatStore::default()).unwrap(); + let store2 = ChatStorage::new(config.clone()).unwrap(); + let mut alice2 = Context::open("alice", config, store2).unwrap(); // Verify conversation was restored let convo_ids = alice2.list_conversations().unwrap(); diff --git a/core/conversations/src/inbox/handler.rs b/core/conversations/src/inbox/handler.rs index b733097..6bc2221 100644 --- a/core/conversations/src/inbox/handler.rs +++ b/core/conversations/src/inbox/handler.rs @@ -149,9 +149,7 @@ impl Inbox { /// Extracts the ephemeral key hex from an incoming encrypted payload /// so the caller can look it up from storage before calling handle_frame. - pub fn extract_ephemeral_key_hex( - enc_payload: &EncryptedPayload, - ) -> Result { + pub fn extract_ephemeral_key_hex(enc_payload: &EncryptedPayload) -> Result { let Some(proto::Encryption::InboxHandshake(ref handshake)) = enc_payload.encryption else { let got = format!("{:?}", enc_payload.encryption); return Err(ChatError::ProtocolExpectation("inboxhandshake", got)); @@ -241,7 +239,8 @@ impl Id for Inbox { #[cfg(test)] mod tests { use super::*; - use crate::storage::ChatStorage; + use crate::sqlite::ChatStorage; + use crate::store::EphemeralKeyStore; use storage::StorageConfig; #[test] diff --git a/core/conversations/src/lib.rs b/core/conversations/src/lib.rs index 72ca22e..0e25b4f 100644 --- a/core/conversations/src/lib.rs +++ b/core/conversations/src/lib.rs @@ -6,7 +6,7 @@ mod errors; mod identity; mod inbox; mod proto; -mod storage; +mod sqlite; mod store; mod types; mod utils; @@ -14,6 +14,8 @@ mod utils; pub use api::*; pub use context::{Context, Introduction}; pub use errors::ChatError; +pub use sqlite::ChatStorage; +pub use storage::StorageConfig; #[cfg(test)] mod tests { diff --git a/core/conversations/src/storage.rs b/core/conversations/src/sqlite.rs similarity index 79% rename from core/conversations/src/storage.rs rename to core/conversations/src/sqlite.rs index 1b8c84d..480ae9f 100644 --- a/core/conversations/src/storage.rs +++ b/core/conversations/src/sqlite.rs @@ -9,7 +9,10 @@ use zeroize::Zeroize; use crate::{ identity::Identity, - storage::types::{ConversationRecord, IdentityRecord}, + sqlite::types::IdentityRecord, + store::{ + ConversationKind, ConversationMeta, ConversationStore, EphemeralKeyStore, IdentityStore, + }, }; /// Chat-specific storage operations. @@ -35,28 +38,14 @@ impl ChatStorage { Ok(Self { db }) } - // ==================== Identity Operations ==================== - - /// Saves the identity (secret key). - /// - /// Note: The secret key bytes are explicitly zeroized after use to minimize - /// the time sensitive data remains in stack memory. - pub fn save_identity(&mut self, identity: &Identity) -> Result<(), StorageError> { - let mut secret_bytes = identity.secret().DANGER_to_bytes(); - let result = self.db.connection().execute( - "INSERT OR REPLACE INTO identity (id, name, secret_key) VALUES (1, ?1, ?2)", - params![identity.get_name(), secret_bytes.as_slice()], - ); - secret_bytes.zeroize(); - result?; - Ok(()) - } +} +impl IdentityStore for ChatStorage { /// Loads the identity if it exists. /// /// Note: Secret key bytes are zeroized after being copied into IdentityRecord, /// which handles its own zeroization via ZeroizeOnDrop. - pub fn load_identity(&self) -> Result, StorageError> { + fn load_identity(&self) -> Result, StorageError> { let mut stmt = self .db .connection() @@ -92,10 +81,25 @@ impl ChatStorage { } } - // ==================== Ephemeral Key Operations ==================== + /// Saves the identity (secret key). + /// + /// Note: The secret key bytes are explicitly zeroized after use to minimize + /// the time sensitive data remains in stack memory. + fn save_identity(&mut self, identity: &Identity) -> Result<(), StorageError> { + let mut secret_bytes = identity.secret().DANGER_to_bytes(); + let result = self.db.connection().execute( + "INSERT OR REPLACE INTO identity (id, name, secret_key) VALUES (1, ?1, ?2)", + params![identity.get_name(), secret_bytes.as_slice()], + ); + secret_bytes.zeroize(); + result?; + Ok(()) + } +} +impl EphemeralKeyStore for ChatStorage { /// Saves an ephemeral key pair to storage. - pub fn save_ephemeral_key( + fn save_ephemeral_key( &mut self, public_key_hex: &str, private_key: &PrivateKey, @@ -111,7 +115,7 @@ impl ChatStorage { } /// Loads a single ephemeral key by its public key hex. - pub fn load_ephemeral_key( + fn load_ephemeral_key( &self, public_key_hex: &str, ) -> Result, StorageError> { @@ -146,43 +150,54 @@ impl ChatStorage { } /// Removes an ephemeral key from storage. - pub fn remove_ephemeral_key(&mut self, public_key_hex: &str) -> Result<(), StorageError> { + fn remove_ephemeral_key(&mut self, public_key_hex: &str) -> Result<(), StorageError> { self.db.connection().execute( "DELETE FROM ephemeral_keys WHERE public_key_hex = ?1", params![public_key_hex], )?; Ok(()) } +} - // ==================== Conversation Operations ==================== - +impl ConversationStore for ChatStorage { /// Saves conversation metadata. - pub fn save_conversation( - &mut self, - local_convo_id: &str, - remote_convo_id: &str, - convo_type: &str, - ) -> Result<(), StorageError> { + fn save_conversation(&mut self, meta: &ConversationMeta) -> Result<(), StorageError> { self.db.connection().execute( "INSERT OR REPLACE INTO conversations (local_convo_id, remote_convo_id, convo_type) VALUES (?1, ?2, ?3)", - params![local_convo_id, remote_convo_id, convo_type], + params![meta.local_convo_id, meta.remote_convo_id, meta.kind.as_str()], )?; Ok(()) } - /// Checks if a conversation exists by its local ID. - pub fn has_conversation(&self, local_convo_id: &str) -> Result { - let exists: bool = self.db.connection().query_row( - "SELECT EXISTS(SELECT 1 FROM conversations WHERE local_convo_id = ?1)", - params![local_convo_id], - |row| row.get(0), + /// Loads a single conversation record by its local ID. + fn load_conversation( + &self, + local_convo_id: &str, + ) -> Result, StorageError> { + let mut stmt = self.db.connection().prepare( + "SELECT local_convo_id, remote_convo_id, convo_type FROM conversations WHERE local_convo_id = ?1", )?; - Ok(exists) + + let result = stmt.query_row(params![local_convo_id], |row| { + let local_convo_id: String = row.get(0)?; + let remote_convo_id: String = row.get(1)?; + let convo_type: String = row.get(2)?; + Ok(ConversationMeta { + local_convo_id, + remote_convo_id, + kind: ConversationKind::from(convo_type.as_str()), + }) + }); + + match result { + Ok(meta) => Ok(Some(meta)), + Err(RusqliteError::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e.into()), + } } /// Removes a conversation by its local ID. - #[allow(dead_code)] - pub fn remove_conversation(&mut self, local_convo_id: &str) -> Result<(), StorageError> { + fn remove_conversation(&mut self, local_convo_id: &str) -> Result<(), StorageError> { self.db.connection().execute( "DELETE FROM conversations WHERE local_convo_id = ?1", params![local_convo_id], @@ -190,32 +205,8 @@ impl ChatStorage { Ok(()) } - /// Loads a single conversation record by its local ID. - pub fn load_conversation( - &self, - local_convo_id: &str, - ) -> Result, StorageError> { - let mut stmt = self.db.connection().prepare( - "SELECT local_convo_id, remote_convo_id, convo_type FROM conversations WHERE local_convo_id = ?1", - )?; - - let result = stmt.query_row(params![local_convo_id], |row| { - Ok(ConversationRecord { - local_convo_id: row.get(0)?, - remote_convo_id: row.get(1)?, - convo_type: row.get(2)?, - }) - }); - - match result { - Ok(record) => Ok(Some(record)), - Err(RusqliteError::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e.into()), - } - } - /// Loads all conversation records. - pub fn load_conversations(&self) -> Result, StorageError> { + fn load_conversations(&self) -> Result, StorageError> { let mut stmt = self .db .connection() @@ -223,16 +214,29 @@ impl ChatStorage { let records = stmt .query_map([], |row| { - Ok(ConversationRecord { - local_convo_id: row.get(0)?, - remote_convo_id: row.get(1)?, - convo_type: row.get(2)?, + let local_convo_id: String = row.get(0)?; + let remote_convo_id: String = row.get(1)?; + let convo_type: String = row.get(2)?; + Ok(ConversationMeta { + local_convo_id, + remote_convo_id, + kind: ConversationKind::from(convo_type.as_str()), }) })? .collect::, _>>()?; Ok(records) } + + /// Checks if a conversation exists by its local ID. + fn has_conversation(&self, local_convo_id: &str) -> Result { + let exists: bool = self.db.connection().query_row( + "SELECT EXISTS(SELECT 1 FROM conversations WHERE local_convo_id = ?1)", + params![local_convo_id], + |row| row.get(0), + )?; + Ok(exists) + } } #[cfg(test)] @@ -287,10 +291,18 @@ mod tests { // Save conversations storage - .save_conversation("local_1", "remote_1", "private_v1") + .save_conversation(&ConversationMeta { + local_convo_id: "local_1".into(), + remote_convo_id: "remote_1".into(), + kind: ConversationKind::PrivateV1, + }) .unwrap(); storage - .save_conversation("local_2", "remote_2", "private_v1") + .save_conversation(&ConversationMeta { + local_convo_id: "local_2".into(), + remote_convo_id: "remote_2".into(), + kind: ConversationKind::PrivateV1, + }) .unwrap(); let convos = storage.load_conversations().unwrap(); @@ -302,6 +314,6 @@ mod tests { assert_eq!(convos.len(), 1); assert_eq!(convos[0].local_convo_id, "local_2"); assert_eq!(convos[0].remote_convo_id, "remote_2"); - assert_eq!(convos[0].convo_type, "private_v1"); + assert_eq!(convos[0].kind.as_str(), "private_v1"); } } diff --git a/core/conversations/src/storage/migrations.rs b/core/conversations/src/sqlite/migrations.rs similarity index 100% rename from core/conversations/src/storage/migrations.rs rename to core/conversations/src/sqlite/migrations.rs diff --git a/core/conversations/src/storage/migrations/001_initial_schema.sql b/core/conversations/src/sqlite/migrations/001_initial_schema.sql similarity index 100% rename from core/conversations/src/storage/migrations/001_initial_schema.sql rename to core/conversations/src/sqlite/migrations/001_initial_schema.sql diff --git a/core/conversations/src/storage/types.rs b/core/conversations/src/sqlite/types.rs similarity index 91% rename from core/conversations/src/storage/types.rs rename to core/conversations/src/sqlite/types.rs index d51ac8f..c34f9be 100644 --- a/core/conversations/src/storage/types.rs +++ b/core/conversations/src/sqlite/types.rs @@ -22,13 +22,6 @@ impl From for Identity { } } -#[derive(Debug)] -pub struct ConversationRecord { - pub local_convo_id: String, - pub remote_convo_id: String, - pub convo_type: String, -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/client/src/client.rs b/crates/client/src/client.rs index a26908a..8a85604 100644 --- a/crates/client/src/client.rs +++ b/crates/client/src/client.rs @@ -1,14 +1,18 @@ use libchat::ChatError; +use libchat::ChatStorage; use libchat::Context; +use libchat::StorageConfig; pub struct ChatClient { - ctx: Context, + ctx: Context, } impl ChatClient { pub fn new(name: impl Into) -> Self { + let store = + ChatStorage::new(StorageConfig::InMemory).expect("in-memory storage should not fail"); Self { - ctx: Context::new_with_name(name), + ctx: Context::new_with_name(name, store), } }