From e6b350a1d48ca0490b7a3447bb8d0a4235514b76 Mon Sep 17 00:00:00 2001 From: Doan Luu Duc Tai Date: Fri, 30 Oct 2026 21:39:57 -0500 Subject: Implement experimental file-based encrypted private chat Add fixed-suite Rust/WASI identity files, signed challenge authentication, ciphertext-only SQLite storage, a minimal LibreJS-compatible interface, and consistent backup snapshots. Keep production unchanged and document the local workflow, limits, source distribution, and browser recovery tests. --- src/archive.rs | 48 +++++++++++++++++++++++++++++++++++++++++++++++- src/main.rs | 16 ++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) (limited to 'src') diff --git a/src/archive.rs b/src/archive.rs index e7e6bc5..03ec643 100644 --- a/src/archive.rs +++ b/src/archive.rs @@ -103,6 +103,25 @@ fn snapshot(project: &Project, records: &[Value], stage: &Path) -> Result<()> { resume } +fn snapshot_chat(root: &Path, stage: &Path) -> Result<()> { + let source = root.join("var/data/chat/chat.sqlite"); + if !source.is_file() { + return Ok(()); + } + let directory = stage.join("var/data/chat"); + files::private_dir(&directory)?; + let database = + rusqlite::Connection::open_with_flags(source, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)?; + database.busy_timeout(std::time::Duration::from_secs(5))?; + let destination = directory.join("chat.sqlite"); + database.backup("main", &destination, None::)?; + fs::set_permissions( + destination, + std::os::unix::fs::PermissionsExt::from_mode(0o600), + )?; + Ok(()) +} + fn snapshot_files(project: &Project, records: &[Value], stage: &Path) -> Result<()> { files::copy_source(&project.root, stage)?; files::copy_tree(&project.root.join(".env"), &stage.join(".env"))?; @@ -112,13 +131,14 @@ fn snapshot_files(project: &Project, records: &[Value], stage: &Path) -> Result< let data = project.root.join("var/data"); for item in fs::read_dir(&data)? { let entry = item?; - if entry.file_name() != "caddy" { + if entry.file_name() != "caddy" && entry.file_name() != "chat" { files::copy_tree( &entry.path(), &stage.join("var/data").join(entry.file_name()), )?; } } + snapshot_chat(&project.root, stage)?; let caddy = records .iter() .find(|record| { @@ -364,6 +384,32 @@ mod tests { use crate::files; use crate::project::{Mode, Project}; + #[test] + fn chat_snapshot_is_consistent_and_private() -> Result<()> { + let workspace = tempfile::tempdir()?; + let root = workspace.path().join("source"); + let stage = workspace.path().join("snapshot"); + files::private_dir(&root.join("var/data/chat"))?; + let connection = rusqlite::Connection::open(root.join("var/data/chat/chat.sqlite"))?; + connection.execute_batch( + "CREATE TABLE messages (body TEXT); INSERT INTO messages VALUES ('ciphertext-before');", + )?; + let transaction = connection.unchecked_transaction()?; + transaction.execute("INSERT INTO messages VALUES ('uncommitted')", [])?; + super::snapshot_chat(&root, &stage)?; + let snapshot = stage.join("var/data/chat/chat.sqlite"); + let restored = rusqlite::Connection::open(&snapshot)?; + let count: i64 = + restored.query_row("SELECT COUNT(*) FROM messages", [], |row| row.get(0))?; + assert_eq!(count, 1); + assert_eq!( + std::os::unix::fs::PermissionsExt::mode(&fs::metadata(snapshot)?.permissions()) & 0o777, + 0o600 + ); + transaction.rollback()?; + Ok(()) + } + #[test] fn restore_is_local_and_refuses_an_existing_destination() -> Result<()> { let workspace = tempfile::tempdir()?; diff --git a/src/main.rs b/src/main.rs index beba5b0..08c88f5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -49,6 +49,11 @@ struct Cli { #[derive(Debug, Subcommand)] enum Action { + #[command(about = "Run the experimental local chat or initialize its single .env")] + Chat { + #[arg(long)] + init: bool, + }, #[command(about = "Start the local development services")] Dev { #[arg(long, default_value_t = 8080)] @@ -127,6 +132,16 @@ fn execute(arguments: Cli) -> Result<()> { return archive::restore(&absolute(archive)?, &absolute(destination)?); } let project = Project::discover(arguments.root.as_deref())?; + if let Action::Chat { init } = arguments.command { + let mut command = project.command("cargo")?; + command + .args(["run", "--locked", "-p", "stack-chat-server", "--", "--root"]) + .arg(&project.root); + if init { + command.arg("--init"); + } + return process::run(&mut command); + } let _operation = project.lock()?; match arguments.command { Action::Dev { port } => { @@ -186,6 +201,7 @@ fn execute(arguments: Cli) -> Result<()> { Action::Activate { archive } => release::activate(&absolute(&archive)?, &project), Action::Check => check(&project), Action::Licenses => licenses(&project), + Action::Chat { .. } => anyhow::bail!("Chat action was not handled"), Action::Restore { .. } => anyhow::bail!("Restore action was not handled"), } } -- cgit v1.2.3