diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ad67955 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +# Generated by Cargo +# will have compiled files and executables +debug +target + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# Generated by cargo mutants +# Contains mutation testing data +**/mutants.out*/ + +# RustRover +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ae71fd1 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,9 @@ + +[workspace] +resolver = "3" +members = [ + "conversations" +, "crypto"] +default-members = [ + "conversations" +] diff --git a/conversations/Cargo.toml b/conversations/Cargo.toml new file mode 100644 index 0000000..531fe1a --- /dev/null +++ b/conversations/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "logos-chat" +version = "0.1.0" +edition = "2024" + +[lib] +crate-type = ["staticlib"] + +[dependencies] +thiserror = "2.0.17" + diff --git a/conversations/src/api.rs b/conversations/src/api.rs new file mode 100644 index 0000000..b177807 --- /dev/null +++ b/conversations/src/api.rs @@ -0,0 +1,174 @@ +use core::ffi::c_char; +use std::{ffi::CStr, slice}; + +// Must only contain negative values, values cannot be changed once set. +#[repr(i32)] +pub enum ErrorCode { + BadPtr = -1, + BadConvoId = -2, +} + +use crate::context::Ctx; + +pub type ContextHandle = *mut Ctx; + +/// Creates a new libchat Ctx +/// +/// # Returns +/// Opaque handle to the store. Must be freed with conversation_store_destroy() +#[unsafe(no_mangle)] +pub extern "C" fn create_context() -> ContextHandle { + let store = Box::new(Ctx::new()); + Box::into_raw(store) // Leak the box, return raw pointer +} + +/// Destroys a conversation store and frees its memory +/// +/// # Safety +/// - handle must be a valid pointer from conversation_store_create() +/// - handle must not be used after this call +/// - handle must not be freed twice +#[unsafe(no_mangle)] +pub unsafe extern "C" fn destroy_context(handle: ContextHandle) { + if !handle.is_null() { + unsafe { + let _ = Box::from_raw(handle); // Reconstruct box and drop it + } + } +} + +/// Encrypts/encodes content into payloads. +/// There may be multiple payloads generated from a single content. +/// +/// # Returns +/// Returns the number of payloads created. +/// +/// # Errors +/// Negative numbers symbolize an error has occured. See `ErrorCode` +/// +#[unsafe(no_mangle)] +pub unsafe extern "C" fn generate_payload( + // Input: Context Handle + handle: ContextHandle, + // Input: Conversation_id + conversation_id: *const c_char, + // Input: Content array + content: *const u8, + content_len: usize, + + max_payload_count: usize, + // Output: Addresses + addrs: *const *mut c_char, + addr_max_len: usize, + + // Output: Frame data + payload_buffer_ptrs: *const *mut u8, + payload_buffer_max_len: *const usize, //Single Value + + // Output: Array - Number of bytes written to each payload + output_actual_lengths: *mut usize, +) -> i32 { + if handle.is_null() || content.is_null() || payload_buffer_ptrs.is_null() || addrs.is_null() { + return ErrorCode::BadPtr as i32; + } + + unsafe { + let ctx = &mut *handle; + let content_slice = slice::from_raw_parts(content, content_len); + let payload_ptrs_slice = slice::from_raw_parts(payload_buffer_ptrs, max_payload_count); + let payload_max_len = if !payload_buffer_max_len.is_null() { + *payload_buffer_max_len + } else { + return ErrorCode::BadPtr as i32; + }; + let addrs_slice = slice::from_raw_parts(addrs, max_payload_count); + let actual_lengths_slice = + slice::from_raw_parts_mut(output_actual_lengths, max_payload_count); + + let c_str = CStr::from_ptr(conversation_id); + let id_str = match c_str.to_str() { + Ok(s) => s, + Err(_) => return ErrorCode::BadConvoId as i32, + }; + + // Call ctx.send_content to get payloads + let payloads = ctx.send_content(id_str, content_slice); + + // Check if we have enough output buffers + if payloads.len() > max_payload_count { + return ErrorCode::BadPtr as i32; // Not enough output buffers + } + + // Write each payload to the output buffers + for (i, payload) in payloads.iter().enumerate() { + let payload_ptr = payload_ptrs_slice[i]; + let addr_ptr = addrs_slice[i]; + + // Write payload data + if !payload_ptr.is_null() { + let payload_buf = slice::from_raw_parts_mut(payload_ptr, payload_max_len); + let copy_len = payload.data.len().min(payload_max_len); + payload_buf[..copy_len].copy_from_slice(&payload.data[..copy_len]); + actual_lengths_slice[i] = copy_len; + } else { + return ErrorCode::BadPtr as i32; + } + + // Write delivery address + if !addr_ptr.is_null() { + let addr_bytes = payload.delivery_address.as_bytes(); + let addr_buf = slice::from_raw_parts_mut(addr_ptr as *mut u8, addr_max_len); + let copy_len = addr_bytes.len().min(addr_max_len - 1); + addr_buf[..copy_len].copy_from_slice(&addr_bytes[..copy_len]); + addr_buf[copy_len] = 0; // Null-terminate + } else { + return ErrorCode::BadPtr as i32; + } + } + + payloads.len() as i32 + } +} + +/// Decrypts/decodes payloads into content. +/// A payload may return 1 or 0 contents. +/// +/// # Returns +/// Returns the number of bytes written to content +/// +/// # Errors +/// Negative numbers symbolize an error has occured. See `ErrorCode` +/// +#[unsafe(no_mangle)] +pub unsafe extern "C" fn handle_payload( + // Input: Context handle + handle: ContextHandle, + // Input: Payload data + payload_data: *const u8, + payload_len: usize, + + // Output: Content + content: *mut u8, + content_max_len: usize, +) -> i32 { + if handle.is_null() || payload_data.is_null() || content.is_null() { + return ErrorCode::BadPtr as i32; + } + + unsafe { + let ctx = &mut *handle; + let payload_slice = slice::from_raw_parts(payload_data, payload_len); + let content_buf = slice::from_raw_parts_mut(content, content_max_len); + + // Call ctx.handle_payload to decode the payload + let contents = ctx.handle_payload(payload_slice); + + if let Some(content_data) = contents { + let copy_len = content_data.data.len().min(content_max_len); + content_buf[..copy_len].copy_from_slice(&content_data.data[..copy_len]); + copy_len as i32 + } else { + 0 // No content produced + } + } +} diff --git a/conversations/src/context.rs b/conversations/src/context.rs new file mode 100644 index 0000000..5c0dd11 --- /dev/null +++ b/conversations/src/context.rs @@ -0,0 +1,44 @@ +use crate::conversation::{ConversationId, ConversationIdOwned, ConversationStore, PrivateV1Convo}; + +pub struct PayloadData { + pub delivery_address: String, + pub data: Vec, +} + +pub struct ContentData { + pub conversation_id: String, + pub data: Vec, +} + +pub struct Ctx { + store: ConversationStore, +} + +impl Ctx { + pub fn new() -> Self { + Self { + store: ConversationStore::new(), + } + } + + pub fn create_private_convo(&mut self, _content: &[u8]) -> ConversationIdOwned { + let new_convo = PrivateV1Convo::new(); + self.store.insert(new_convo) + } + + pub fn send_content(&mut self, _convo_id: ConversationId, _content: &[u8]) -> Vec { + // !TODO Replace Mock + vec![PayloadData { + delivery_address: _convo_id.into(), + data: vec![40, 30, 20, 10], + }] + } + + pub fn handle_payload(&mut self, _payload: &[u8]) -> Option { + // !TODO Replace Mock + Some(ContentData { + conversation_id: "convo_id".into(), + data: vec![1, 2, 3, 4, 5, 6], + }) + } +} diff --git a/conversations/src/conversation.rs b/conversations/src/conversation.rs new file mode 100644 index 0000000..9485719 --- /dev/null +++ b/conversations/src/conversation.rs @@ -0,0 +1,67 @@ +use std::collections::HashMap; +use std::fmt::Debug; +use std::sync::Arc; + +pub use crate::errors::ChatError; + +///////////////////////////////////////////////// +// Type Definitions +///////////////////////////////////////////////// + +pub type ConversationId<'a> = &'a str; +pub type ConversationIdOwned = Arc; + +///////////////////////////////////////////////// +// Trait Definitions +///////////////////////////////////////////////// + +pub trait Convo: Debug { + fn id(&self) -> ConversationId; + fn send_frame(&mut self, message: &[u8]) -> Result<(), ChatError>; + fn handle_frame(&mut self, message: &[u8]) -> Result<(), ChatError>; +} + +///////////////////////////////////////////////// +// Structs +///////////////////////////////////////////////// + +pub struct ConversationStore { + conversations: HashMap, Box>, +} + +impl ConversationStore { + pub fn new() -> Self { + Self { + conversations: HashMap::new(), + } + } + + pub fn insert(&mut self, conversation: impl Convo + 'static) -> ConversationIdOwned { + let key: ConversationIdOwned = Arc::from(conversation.id()); + self.conversations + .insert(key.clone(), Box::new(conversation)); + key + } + + pub fn get(&self, id: ConversationId) -> Option<&(dyn Convo + '_)> { + self.conversations.get(id).map(|c| c.as_ref()) + } + + pub fn get_mut(&mut self, id: &str) -> Option<&mut (dyn Convo + '_)> { + Some(self.conversations.get_mut(id)?.as_mut()) + } + + pub fn conversation_ids(&self) -> impl Iterator + '_ { + self.conversations.keys().cloned() + } +} + +///////////////////////////////////////////////// +// Modules +///////////////////////////////////////////////// + +mod group_test; +mod privatev1; + +pub use group_test::GroupTestConvo; +pub use privatev1::PrivateV1Convo; diff --git a/conversations/src/conversation/group_test.rs b/conversations/src/conversation/group_test.rs new file mode 100644 index 0000000..7719ad3 --- /dev/null +++ b/conversations/src/conversation/group_test.rs @@ -0,0 +1,27 @@ +use crate::conversation::{ChatError, ConversationId, Convo}; + +#[derive(Debug)] +pub struct GroupTestConvo {} + +impl GroupTestConvo { + pub fn new() -> Self { + Self {} + } +} + +impl Convo for GroupTestConvo { + fn id(&self) -> ConversationId { + // implementation + "grouptest" + } + + fn send_frame(&mut self, _message: &[u8]) -> Result<(), ChatError> { + // todo!("Not Implemented") + Ok(()) + } + + fn handle_frame(&mut self, _message: &[u8]) -> Result<(), ChatError> { + // todo!("Not Implemented") + Ok(()) + } +} diff --git a/conversations/src/conversation/privatev1.rs b/conversations/src/conversation/privatev1.rs new file mode 100644 index 0000000..15d27aa --- /dev/null +++ b/conversations/src/conversation/privatev1.rs @@ -0,0 +1,25 @@ +use crate::conversation::{ChatError, ConversationId, Convo}; + +#[derive(Debug)] +pub struct PrivateV1Convo {} + +impl PrivateV1Convo { + pub fn new() -> Self { + Self {} + } +} + +impl Convo for PrivateV1Convo { + fn id(&self) -> ConversationId { + // implementation + "private_v1_convo_id" + } + + fn send_frame(&mut self, _message: &[u8]) -> Result<(), ChatError> { + todo!("Not Implemented") + } + + fn handle_frame(&mut self, _message: &[u8]) -> Result<(), ChatError> { + todo!("Not Implemented") + } +} diff --git a/conversations/src/crypto.rs b/conversations/src/crypto.rs new file mode 100644 index 0000000..e69de29 diff --git a/conversations/src/errors.rs b/conversations/src/errors.rs new file mode 100644 index 0000000..098b048 --- /dev/null +++ b/conversations/src/errors.rs @@ -0,0 +1,7 @@ +pub use thiserror::Error; + +#[derive(Error, Debug)] +pub enum ChatError { + #[error("protocol error: {0:?}")] + Protocol(String), +} diff --git a/conversations/src/inbox.rs b/conversations/src/inbox.rs new file mode 100644 index 0000000..662ee3f --- /dev/null +++ b/conversations/src/inbox.rs @@ -0,0 +1,31 @@ +use crate::conversation::{ChatError, ConversationId, Convo}; + +#[derive(Debug)] +pub struct Inbox { + address: String, +} + +impl Inbox { + pub fn new(address: impl Into) -> Self { + Self { + address: address.into(), + } + } +} + +impl Convo for Inbox { + fn id(&self) -> ConversationId { + self.address.as_ref() + } + + fn send_frame(&mut self, _message: &[u8]) -> Result<(), ChatError> { + todo!("Not Implemented") + } + + fn handle_frame(&mut self, message: &[u8]) -> Result<(), ChatError> { + if message.len() == 0 { + return Err(ChatError::Protocol("Example error".into())); + } + todo!("Not Implemented") + } +} diff --git a/conversations/src/lib.rs b/conversations/src/lib.rs new file mode 100644 index 0000000..0f6b57e --- /dev/null +++ b/conversations/src/lib.rs @@ -0,0 +1,153 @@ +mod api; +mod context; +mod conversation; +mod errors; +mod inbox; + +pub use api::*; + +#[cfg(test)] +use conversation::{ConversationStore, GroupTestConvo, PrivateV1Convo}; +use inbox::Inbox; + +mod tests { + + use super::*; + use std::ffi::CString; + + #[test] + fn test_ffi() {} + + #[test] + fn test_process_and_write() { + // Create Context + let ctx = create_context(); + + // Setup conversation_id + let conv_id = CString::new("test_conversation_123").unwrap(); + + // Setup content + let content = b"Hello, World!"; + + // Setup output buffers for addresses (labels) + let addr_max_len = 256; + let mut addr_buffer1: Vec = vec![0; addr_max_len]; + let mut addr_buffer2: Vec = vec![0; addr_max_len]; + + let addr_ptrs: Vec<*mut i8> = vec![ + addr_buffer1.as_mut_ptr() as *mut i8, + addr_buffer2.as_mut_ptr() as *mut i8, + ]; + + // Setup payload buffers + let max_payload_count = 2; + let payload_max_len = 1024; + let mut payload1: Vec = vec![0; payload_max_len]; + let mut payload2: Vec = vec![0; payload_max_len]; + + let payload_ptrs: Vec<*mut u8> = vec![payload1.as_mut_ptr(), payload2.as_mut_ptr()]; + + let payload_max_lens: Vec = vec![payload_max_len, payload_max_len]; + let mut actual_lengths: Vec = vec![0; max_payload_count]; + + // Call the FFI function + let result = unsafe { + generate_payload( + ctx, + conv_id.as_ptr(), + content.as_ptr(), + content.len(), + max_payload_count, + addr_ptrs.as_ptr(), + addr_max_len, + payload_ptrs.as_ptr(), + payload_max_lens.as_ptr(), + actual_lengths.as_mut_ptr(), + ) + }; + + // Verify results + assert_eq!(result, 1, "Function should return 1 on success"); + + // Check that the conversation ID was written to the first label buffer + let written_addr = std::ffi::CStr::from_bytes_until_nul(&addr_buffer1) + .unwrap() + .to_str() + .unwrap(); + + assert_eq!(written_addr, "test_conversation_123"); + + unsafe { + destroy_context(ctx); + } + } + + #[test] + fn test_process_and_write_null_ptr() { + use std::ptr; + // Create Context + let ctx = create_context(); + + let conv_id = CString::new("test").unwrap(); + let content = b"test"; + + // Test with null content pointer + let result = unsafe { + generate_payload( + ctx, + conv_id.as_ptr(), + ptr::null(), + content.len(), + 1, + ptr::null(), + 256, + ptr::null(), + ptr::null(), + ptr::null_mut(), + ) + }; + + unsafe { + destroy_context(ctx); + } + + assert_eq!(result, -1, "Should return ERR_BAD_PTR for null pointer"); + } + + #[test] + fn convo_store_get() { + let mut store: ConversationStore = ConversationStore::new(); + + let new_convo = GroupTestConvo::new(); + let convo_id = store.insert(new_convo); + + let convo = store.get_mut(&convo_id).ok_or_else(|| 0); + convo.unwrap(); + } + + #[test] + fn multi_convo_example() { + let mut store: ConversationStore = ConversationStore::new(); + + let raya = Inbox::new("Raya"); + let saro = PrivateV1Convo::new(); + let pax = GroupTestConvo::new(); + + store.insert(raya); + store.insert(saro); + let convo_id = store.insert(pax); + + for id in store.conversation_ids().collect::>() { + let a = store.get_mut(&id).unwrap(); + a.send_frame(b"test message").unwrap(); + println!("Conversation ID: {} :: {:?}", id, a); + } + + for id in store.conversation_ids().collect::>() { + let a = store.get_mut(&id).unwrap(); + let _ = a.handle_frame(&[0x1, 0x2]); + } + + println!("ID -> {}", store.get(&convo_id).unwrap().id()); + } +}