chore: sqlite module

This commit is contained in:
kaichaosun 2026-03-27 21:57:13 +08:00
parent 7b61afd7f8
commit 130578b956
No known key found for this signature in database
GPG Key ID: 223E0F992F4F03BF
9 changed files with 118 additions and 97 deletions

View File

@ -13,9 +13,12 @@ use safer_ffi::{
prelude::{c_slice, repr_c}, prelude::{c_slice, repr_c},
}; };
use storage::StorageConfig;
use crate::{ use crate::{
context::{Context, Introduction}, context::{Context, Introduction},
errors::ChatError, errors::ChatError,
sqlite::ChatStorage,
types::ContentData, types::ContentData,
}; };
@ -42,7 +45,7 @@ pub fn is_ok(error: i32) -> bool {
/// Opaque wrapper for Context /// Opaque wrapper for Context
#[derive_ReprC] #[derive_ReprC]
#[repr(opaque)] #[repr(opaque)]
pub struct ContextHandle(pub(crate) Context); pub struct ContextHandle(pub(crate) Context<ChatStorage>);
/// Creates a new libchat Ctx /// Creates a new libchat Ctx
/// ///
@ -51,7 +54,9 @@ pub struct ContextHandle(pub(crate) Context);
#[ffi_export] #[ffi_export]
pub fn create_context(name: repr_c::String) -> repr_c::Box<ContextHandle> { pub fn create_context(name: repr_c::String) -> repr_c::Box<ContextHandle> {
// Deference name to to `str` and then borrow to &str // 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. /// Returns the friendly name of the contexts installation.

View File

@ -10,8 +10,8 @@ use crate::{
identity::Identity, identity::Identity,
inbox::Inbox, inbox::Inbox,
proto::{EncryptedPayload, EnvelopeV1, Message}, proto::{EncryptedPayload, EnvelopeV1, Message},
storage::ChatStorage, sqlite::ChatStorage,
store::{ChatStore, ConversationKind, ConversationMeta}, store::{ChatStore, ConversationKind, ConversationMeta, IdentityStore},
types::{AddressedEnvelope, ContentData}, types::{AddressedEnvelope, ContentData},
}; };
@ -315,7 +315,7 @@ mod mock {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::{context::mock::MockChatStore, store::ConversationStore}; use crate::{context::mock::MockChatStore, sqlite::ChatStorage, store::ConversationStore};
use super::*; use super::*;
@ -402,11 +402,13 @@ mod tests {
.to_string(); .to_string();
let config = StorageConfig::File(db_path); 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(); let bundle1 = ctx1.create_intro_bundle().unwrap();
drop(ctx1); 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 intro = Introduction::try_from(bundle1.as_slice()).unwrap();
let mut bob = Context::new_with_name("bob", MockChatStore::default()); let mut bob = Context::new_with_name("bob", MockChatStore::default());
@ -431,7 +433,8 @@ mod tests {
.to_string(); .to_string();
let config = StorageConfig::File(db_path); 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 mut bob = Context::new_with_name("bob", MockChatStore::default());
let bundle = alice.create_intro_bundle().unwrap(); let bundle = alice.create_intro_bundle().unwrap();
@ -447,7 +450,8 @@ mod tests {
assert_eq!(convos[0].kind.as_str(), "private_v1"); assert_eq!(convos[0].kind.as_str(), "private_v1");
drop(alice); 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(); let convos = alice2.storage.load_conversations().unwrap();
assert_eq!(convos.len(), 1, "conversation metadata should persist"); assert_eq!(convos.len(), 1, "conversation metadata should persist");
} }
@ -463,7 +467,8 @@ mod tests {
let config = StorageConfig::File(db_path); let config = StorageConfig::File(db_path);
// Alice and Bob establish a conversation // 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 mut bob = Context::new_with_name("bob", MockChatStore::default());
let bundle = alice.create_intro_bundle().unwrap(); let bundle = alice.create_intro_bundle().unwrap();
@ -485,7 +490,8 @@ mod tests {
// Drop Alice and reopen - conversation should survive // Drop Alice and reopen - conversation should survive
drop(alice); 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 // Verify conversation was restored
let convo_ids = alice2.list_conversations().unwrap(); let convo_ids = alice2.list_conversations().unwrap();

View File

@ -149,9 +149,7 @@ impl Inbox {
/// Extracts the ephemeral key hex from an incoming encrypted payload /// Extracts the ephemeral key hex from an incoming encrypted payload
/// so the caller can look it up from storage before calling handle_frame. /// so the caller can look it up from storage before calling handle_frame.
pub fn extract_ephemeral_key_hex( pub fn extract_ephemeral_key_hex(enc_payload: &EncryptedPayload) -> Result<String, ChatError> {
enc_payload: &EncryptedPayload,
) -> Result<String, ChatError> {
let Some(proto::Encryption::InboxHandshake(ref handshake)) = enc_payload.encryption else { let Some(proto::Encryption::InboxHandshake(ref handshake)) = enc_payload.encryption else {
let got = format!("{:?}", enc_payload.encryption); let got = format!("{:?}", enc_payload.encryption);
return Err(ChatError::ProtocolExpectation("inboxhandshake", got)); return Err(ChatError::ProtocolExpectation("inboxhandshake", got));
@ -241,7 +239,8 @@ impl Id for Inbox {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::storage::ChatStorage; use crate::sqlite::ChatStorage;
use crate::store::EphemeralKeyStore;
use storage::StorageConfig; use storage::StorageConfig;
#[test] #[test]

View File

@ -6,7 +6,7 @@ mod errors;
mod identity; mod identity;
mod inbox; mod inbox;
mod proto; mod proto;
mod storage; mod sqlite;
mod store; mod store;
mod types; mod types;
mod utils; mod utils;
@ -14,6 +14,8 @@ mod utils;
pub use api::*; pub use api::*;
pub use context::{Context, Introduction}; pub use context::{Context, Introduction};
pub use errors::ChatError; pub use errors::ChatError;
pub use sqlite::ChatStorage;
pub use storage::StorageConfig;
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {

View File

@ -9,7 +9,10 @@ use zeroize::Zeroize;
use crate::{ use crate::{
identity::Identity, identity::Identity,
storage::types::{ConversationRecord, IdentityRecord}, sqlite::types::IdentityRecord,
store::{
ConversationKind, ConversationMeta, ConversationStore, EphemeralKeyStore, IdentityStore,
},
}; };
/// Chat-specific storage operations. /// Chat-specific storage operations.
@ -35,28 +38,14 @@ impl ChatStorage {
Ok(Self { db }) 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. /// Loads the identity if it exists.
/// ///
/// Note: Secret key bytes are zeroized after being copied into IdentityRecord, /// Note: Secret key bytes are zeroized after being copied into IdentityRecord,
/// which handles its own zeroization via ZeroizeOnDrop. /// 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 let mut stmt = self
.db .db
.connection() .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. /// Saves an ephemeral key pair to storage.
pub fn save_ephemeral_key( fn save_ephemeral_key(
&mut self, &mut self,
public_key_hex: &str, public_key_hex: &str,
private_key: &PrivateKey, private_key: &PrivateKey,
@ -111,7 +115,7 @@ impl ChatStorage {
} }
/// Loads a single ephemeral key by its public key hex. /// Loads a single ephemeral key by its public key hex.
pub fn load_ephemeral_key( fn load_ephemeral_key(
&self, &self,
public_key_hex: &str, public_key_hex: &str,
) -> Result<Option<PrivateKey>, StorageError> { ) -> Result<Option<PrivateKey>, StorageError> {
@ -146,43 +150,54 @@ impl ChatStorage {
} }
/// Removes an ephemeral key from storage. /// 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( self.db.connection().execute(
"DELETE FROM ephemeral_keys WHERE public_key_hex = ?1", "DELETE FROM ephemeral_keys WHERE public_key_hex = ?1",
params![public_key_hex], params![public_key_hex],
)?; )?;
Ok(()) Ok(())
} }
}
// ==================== Conversation Operations ==================== impl ConversationStore for ChatStorage {
/// Saves conversation metadata. /// Saves conversation metadata.
pub fn save_conversation( fn save_conversation(&mut self, meta: &ConversationMeta) -> Result<(), StorageError> {
&mut self,
local_convo_id: &str,
remote_convo_id: &str,
convo_type: &str,
) -> Result<(), StorageError> {
self.db.connection().execute( self.db.connection().execute(
"INSERT OR REPLACE INTO conversations (local_convo_id, remote_convo_id, convo_type) VALUES (?1, ?2, ?3)", "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(()) Ok(())
} }
/// Checks if a conversation exists by its local ID. /// Loads a single conversation record by its local ID.
pub fn has_conversation(&self, local_convo_id: &str) -> Result<bool, StorageError> { fn load_conversation(
let exists: bool = self.db.connection().query_row( &self,
"SELECT EXISTS(SELECT 1 FROM conversations WHERE local_convo_id = ?1)", local_convo_id: &str,
params![local_convo_id], ) -> Result<Option<ConversationMeta>, StorageError> {
|row| row.get(0), 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. /// Removes a conversation by its local ID.
#[allow(dead_code)] fn remove_conversation(&mut self, local_convo_id: &str) -> Result<(), StorageError> {
pub fn remove_conversation(&mut self, local_convo_id: &str) -> Result<(), StorageError> {
self.db.connection().execute( self.db.connection().execute(
"DELETE FROM conversations WHERE local_convo_id = ?1", "DELETE FROM conversations WHERE local_convo_id = ?1",
params![local_convo_id], params![local_convo_id],
@ -190,32 +205,8 @@ impl ChatStorage {
Ok(()) 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. /// 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 let mut stmt = self
.db .db
.connection() .connection()
@ -223,16 +214,29 @@ impl ChatStorage {
let records = stmt let records = stmt
.query_map([], |row| { .query_map([], |row| {
Ok(ConversationRecord { let local_convo_id: String = row.get(0)?;
local_convo_id: row.get(0)?, let remote_convo_id: String = row.get(1)?;
remote_convo_id: row.get(1)?, let convo_type: String = row.get(2)?;
convo_type: row.get(2)?, Ok(ConversationMeta {
local_convo_id,
remote_convo_id,
kind: ConversationKind::from(convo_type.as_str()),
}) })
})? })?
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
Ok(records) 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)] #[cfg(test)]
@ -287,10 +291,18 @@ mod tests {
// Save conversations // Save conversations
storage 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(); .unwrap();
storage 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(); .unwrap();
let convos = storage.load_conversations().unwrap(); let convos = storage.load_conversations().unwrap();
@ -302,6 +314,6 @@ mod tests {
assert_eq!(convos.len(), 1); assert_eq!(convos.len(), 1);
assert_eq!(convos[0].local_convo_id, "local_2"); assert_eq!(convos[0].local_convo_id, "local_2");
assert_eq!(convos[0].remote_convo_id, "remote_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");
} }
} }

View File

@ -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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@ -1,14 +1,18 @@
use libchat::ChatError; use libchat::ChatError;
use libchat::ChatStorage;
use libchat::Context; use libchat::Context;
use libchat::StorageConfig;
pub struct ChatClient { pub struct ChatClient {
ctx: Context, ctx: Context<ChatStorage>,
} }
impl ChatClient { impl ChatClient {
pub fn new(name: impl Into<String>) -> Self { pub fn new(name: impl Into<String>) -> Self {
let store =
ChatStorage::new(StorageConfig::InMemory).expect("in-memory storage should not fail");
Self { Self {
ctx: Context::new_with_name(name), ctx: Context::new_with_name(name, store),
} }
} }