aboutsummaryrefslogtreecommitdiff
path: root/src/archive.rs
diff options
context:
space:
mode:
authorDoan Luu Duc Tai <doan@luuduct.ai>2026-10-30 21:39:57 -0500
committerDoan Luu Duc Tai <doan@luuduct.ai>2026-10-30 21:39:57 -0500
commit556eaa133e9cfe7f4c8e542708119fdfd311357e (patch)
tree57ae2a9c5455f335e8c34b293f4f13975cb3d09e /src/archive.rs
parent143880db086c617b843340ca7023a879507761f8 (diff)
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.
Diffstat (limited to 'src/archive.rs')
-rw-r--r--src/archive.rs48
1 files changed, 47 insertions, 1 deletions
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::<fn(rusqlite::backup::Progress)>)?;
+ 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| {
@@ -365,6 +385,32 @@ mod tests {
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()?;
let source = workspace.path().join("source");