mirror of
https://github.com/logos-messaging/libchat.git
synced 2026-04-01 09:04:03 +00:00
chore: sqlite module
This commit is contained in:
parent
7b61afd7f8
commit
130578b956
@ -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<ChatStorage>);
|
||||
|
||||
/// 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<ContextHandle> {
|
||||
// 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.
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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<String, ChatError> {
|
||||
pub fn extract_ephemeral_key_hex(enc_payload: &EncryptedPayload) -> Result<String, ChatError> {
|
||||
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]
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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<Option<Identity>, StorageError> {
|
||||
fn load_identity(&self) -> Result<Option<Identity>, 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<Option<PrivateKey>, 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<bool, StorageError> {
|
||||
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<Option<ConversationMeta>, 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<Option<ConversationRecord>, 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<Vec<ConversationRecord>, StorageError> {
|
||||
fn load_conversations(&self) -> Result<Vec<ConversationMeta>, 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::<Result<Vec<_>, _>>()?;
|
||||
|
||||
Ok(records)
|
||||
}
|
||||
|
||||
/// Checks if a conversation exists by its local ID.
|
||||
fn has_conversation(&self, local_convo_id: &str) -> Result<bool, StorageError> {
|
||||
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");
|
||||
}
|
||||
}
|
||||
@ -22,13 +22,6 @@ impl From<IdentityRecord> 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::*;
|
||||
@ -1,14 +1,18 @@
|
||||
use libchat::ChatError;
|
||||
use libchat::ChatStorage;
|
||||
use libchat::Context;
|
||||
use libchat::StorageConfig;
|
||||
|
||||
pub struct ChatClient {
|
||||
ctx: Context,
|
||||
ctx: Context<ChatStorage>,
|
||||
}
|
||||
|
||||
impl ChatClient {
|
||||
pub fn new(name: impl Into<String>) -> 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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user