chore: merge latest main

This commit is contained in:
kaichaosun 2026-03-27 15:01:50 +08:00
commit 66c2abe6de
No known key found for this signature in database
GPG Key ID: 223E0F992F4F03BF
68 changed files with 177 additions and 154 deletions

7
Cargo.lock generated
View File

@ -119,6 +119,13 @@ dependencies = [
"zeroize",
]
[[package]]
name = "client"
version = "0.1.0"
dependencies = [
"libchat",
]
[[package]]
name = "const-oid"
version = "0.9.6"

View File

@ -3,15 +3,17 @@
resolver = "3"
members = [
"conversations",
"crypto",
"double-ratchets",
"storage",
"core/conversations",
"core/crypto",
"core/double-ratchets",
"core/storage",
"crates/client",
]
[workspace.dependencies]
blake2 = "0.10"
storage = { path = "storage" }
libchat = { path = "core/conversations" }
storage = { path = "core/storage" }
# Panicking across FFI boundaries is UB; abort is the correct strategy for a
# C FFI library.

View File

@ -1 +0,0 @@
pub mod utils;

View File

@ -1,8 +0,0 @@
use safer_ffi::prelude::*;
#[derive_ReprC]
#[repr(C)]
pub struct CResult<T: ReprC, Err: ReprC> {
pub ok: Option<T>,
pub err: Option<Err>,
}

View File

@ -1,9 +0,0 @@
-- Initial schema for chat storage
-- Migration: 001_initial_schema
-- Identity table (single row)
CREATE TABLE IF NOT EXISTS identity (
id INTEGER PRIMARY KEY CHECK (id = 1),
name TEXT NOT NULL,
secret_key BLOB NOT NULL
);

View File

@ -1,7 +0,0 @@
-- Ephemeral keys for inbox handshakes
-- Migration: 002_ephemeral_keys
CREATE TABLE IF NOT EXISTS ephemeral_keys (
public_key_hex TEXT PRIMARY KEY,
secret_key BLOB NOT NULL
);

View File

@ -1,9 +0,0 @@
-- Conversations metadata
-- Migration: 003_conversations
CREATE TABLE IF NOT EXISTS conversations (
local_convo_id TEXT PRIMARY KEY,
remote_convo_id TEXT NOT NULL,
convo_type TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);

View File

@ -1,7 +0,0 @@
//! Storage module for persisting chat state.
mod db;
mod migrations;
pub(crate) mod types;
pub(crate) use db::ChatStorage;

7
core/README.md Normal file
View File

@ -0,0 +1,7 @@
# Core
Crates in this directory will one day be separated into a separate shared repository.
They could be moved now, but it's desirable to have a monorepo setup at this time.
These crates MUST not depend on any code outside of this folder.

View File

@ -13,12 +13,9 @@ use safer_ffi::{
prelude::{c_slice, repr_c},
};
use storage::StorageConfig;
use crate::{
context::{Context, Introduction},
errors::ChatError,
ffi::utils::CResult,
types::ContentData,
};
@ -57,41 +54,6 @@ pub fn create_context(name: repr_c::String) -> repr_c::Box<ContextHandle> {
Box::new(ContextHandle(Context::new_with_name(&*name))).into()
}
/// Creates a new libchat Context with file-based persistent storage.
///
/// The identity will be loaded from storage if it exists, or created and saved if not.
///
/// # Parameters
/// - name: Friendly name for the identity (used if creating new identity)
/// - db_path: Path to the SQLite database file
/// - db_secret: Secret key for encrypting the database
///
/// # Returns
/// CResult with context handle on success, or error string on failure.
/// On success, the context handle must be freed with `destroy_context()` after usage.
/// On error, the error string must be freed with `destroy_string()` after usage.
#[ffi_export]
pub fn create_context_with_storage(
name: repr_c::String,
db_path: repr_c::String,
db_secret: repr_c::String,
) -> CResult<repr_c::Box<ContextHandle>, repr_c::String> {
let config = StorageConfig::Encrypted {
path: db_path.to_string(),
key: db_secret.to_string(),
};
match Context::open(&*name, config) {
Ok(ctx) => CResult {
ok: Some(Box::new(ContextHandle(ctx)).into()),
err: None,
},
Err(e) => CResult {
ok: None,
err: Some(e.to_string().into()),
},
}
}
/// Returns the friendly name of the contexts installation.
///
#[ffi_export]

View File

@ -186,11 +186,9 @@ impl Context {
/// Persists a conversation's metadata and ratchet state to DB.
fn persist_convo(&mut self, convo: &dyn Convo) -> ConversationIdOwned {
let _ = self.storage.save_conversation(
convo.id(),
&convo.remote_id(),
convo.convo_type(),
);
let _ = self
.storage
.save_conversation(convo.id(), &convo.remote_id(), convo.convo_type());
let _ = convo.save_ratchet_state(&mut self.ratchet_storage);
Arc::from(convo.id())
}

View File

@ -0,0 +1,41 @@
use crate::{
conversation::{ChatError, ConversationId, Convo, Id},
proto::EncryptedPayload,
types::{AddressedEncryptedPayload, ContentData},
};
#[derive(Debug)]
pub struct GroupTestConvo {}
impl GroupTestConvo {
pub fn new() -> Self {
Self {}
}
}
impl Id for GroupTestConvo {
fn id(&self) -> ConversationId<'_> {
// implementation
"grouptest"
}
}
impl Convo for GroupTestConvo {
fn send_message(
&mut self,
_content: &[u8],
) -> Result<Vec<AddressedEncryptedPayload>, ChatError> {
Ok(vec![])
}
fn handle_frame(
&mut self,
_encoded_payload: EncryptedPayload,
) -> Result<Option<ContentData>, ChatError> {
Ok(None)
}
fn remote_id(&self) -> String {
self.id().to_string()
}
}

View File

@ -3,7 +3,6 @@ mod context;
mod conversation;
mod crypto;
mod errors;
mod ffi;
mod identity;
mod inbox;
mod proto;
@ -12,6 +11,8 @@ mod types;
mod utils;
pub use api::*;
pub use context::{Context, Introduction};
pub use errors::ChatError;
#[cfg(test)]
mod tests {

View File

@ -1,12 +1,16 @@
//! Chat-specific storage implementation.
mod migrations;
mod types;
use crypto::PrivateKey;
use storage::{RusqliteError, SqliteDb, StorageConfig, StorageError, params};
use zeroize::Zeroize;
use super::migrations;
use super::types::{ConversationRecord, IdentityRecord};
use crate::crypto::PrivateKey;
use crate::identity::Identity;
use crate::{
identity::Identity,
storage::types::{ConversationRecord, IdentityRecord},
};
/// Chat-specific storage operations.
///
@ -48,6 +52,46 @@ impl ChatStorage {
Ok(())
}
/// 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> {
let mut stmt = self
.db
.connection()
.prepare("SELECT name, secret_key FROM identity WHERE id = 1")?;
let result = stmt.query_row([], |row| {
let name: String = row.get(0)?;
let secret_key: Vec<u8> = row.get(1)?;
Ok((name, secret_key))
});
match result {
Ok((name, mut secret_key_vec)) => {
let bytes: Result<[u8; 32], _> = secret_key_vec.as_slice().try_into();
let bytes = match bytes {
Ok(b) => b,
Err(_) => {
secret_key_vec.zeroize();
return Err(StorageError::InvalidData(
"Invalid secret key length".into(),
));
}
};
secret_key_vec.zeroize();
let record = IdentityRecord {
name,
secret_key: bytes,
};
Ok(Some(Identity::from(record)))
}
Err(RusqliteError::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
}
// ==================== Ephemeral Key Operations ====================
/// Saves an ephemeral key pair to storage.
@ -162,9 +206,10 @@ impl ChatStorage {
/// Loads all conversation records.
pub fn load_conversations(&self) -> Result<Vec<ConversationRecord>, StorageError> {
let mut stmt = self.db.connection().prepare(
"SELECT local_convo_id, remote_convo_id, convo_type FROM conversations",
)?;
let mut stmt = self
.db
.connection()
.prepare("SELECT local_convo_id, remote_convo_id, convo_type FROM conversations")?;
let records = stmt
.query_map([], |row| {
@ -187,48 +232,6 @@ impl ChatStorage {
)?;
Ok(())
}
// ==================== Identity Operations (continued) ====================
/// 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> {
let mut stmt = self
.db
.connection()
.prepare("SELECT name, secret_key FROM identity WHERE id = 1")?;
let result = stmt.query_row([], |row| {
let name: String = row.get(0)?;
let secret_key: Vec<u8> = row.get(1)?;
Ok((name, secret_key))
});
match result {
Ok((name, mut secret_key_vec)) => {
let bytes: Result<[u8; 32], _> = secret_key_vec.as_slice().try_into();
let bytes = match bytes {
Ok(b) => b,
Err(_) => {
secret_key_vec.zeroize();
return Err(StorageError::InvalidData(
"Invalid secret key length".into(),
));
}
};
secret_key_vec.zeroize();
let record = IdentityRecord {
name,
secret_key: bytes,
};
Ok(Some(Identity::from(record)))
}
Err(RusqliteError::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e.into()),
}
}
}
#[cfg(test)]

View File

@ -7,20 +7,10 @@ use storage::{Connection, StorageError};
/// Embeds and returns all migration SQL files in order.
pub fn get_migrations() -> Vec<(&'static str, &'static str)> {
vec![
(
"001_initial_schema",
include_str!("migrations/001_initial_schema.sql"),
),
(
"002_ephemeral_keys",
include_str!("migrations/002_ephemeral_keys.sql"),
),
(
"003_conversations",
include_str!("migrations/003_conversations.sql"),
),
]
vec![(
"001_initial_schema",
include_str!("migrations/001_initial_schema.sql"),
)]
}
/// Applies all migrations to the database.

View File

@ -0,0 +1,23 @@
-- Initial schema for chat storage
-- Migration: 001_initial_schema
-- Identity table (single row)
CREATE TABLE IF NOT EXISTS identity (
id INTEGER PRIMARY KEY CHECK (id = 1),
name TEXT NOT NULL,
secret_key BLOB NOT NULL
);
-- Ephemeral keys for inbox handshakes
CREATE TABLE IF NOT EXISTS ephemeral_keys (
public_key_hex TEXT PRIMARY KEY,
secret_key BLOB NOT NULL
);
-- Conversations metadata
CREATE TABLE IF NOT EXISTS conversations (
local_convo_id TEXT PRIMARY KEY,
remote_convo_id TEXT NOT NULL,
convo_type TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
);

View File

@ -22,7 +22,6 @@ impl From<IdentityRecord> for Identity {
}
}
/// Record for storing conversation metadata.
#[derive(Debug)]
pub struct ConversationRecord {
pub local_convo_id: String,

10
crates/client/Cargo.toml Normal file
View File

@ -0,0 +1,10 @@
[package]
name = "client"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["rlib"]
[dependencies]
libchat = { workspace = true }

View File

@ -0,0 +1,18 @@
use libchat::ChatError;
use libchat::Context;
pub struct ChatClient {
ctx: Context,
}
impl ChatClient {
pub fn new(name: impl Into<String>) -> Self {
Self {
ctx: Context::new_with_name(name),
}
}
pub fn create_bundle(&mut self) -> Result<Vec<u8>, ChatError> {
self.ctx.create_intro_bundle()
}
}

3
crates/client/src/lib.rs Normal file
View File

@ -0,0 +1,3 @@
mod client;
pub use client::ChatClient;