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},
};
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.

View File

@ -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();

View File

@ -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]

View File

@ -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 {

View File

@ -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");
}
}

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)]
mod tests {
use super::*;

View File

@ -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),
}
}