Skip to main content

nysiris_chat_history/
lib.rs

1#![warn(missing_docs)]
2//! Encrypted, append-only local chat history.
3//!
4//! One journal per conversation, named by a BLAKE2b hash of the conversation
5//! id. Each record is a length-prefixed sealed blob; the journal filename is
6//! bound into the AEAD associated data so records cannot be moved between
7//! conversations. No SQLite and no plaintext message bytes on disk.
8
9use std::collections::HashSet;
10use std::fs::OpenOptions;
11use std::io::{Read, Write};
12use std::path::{Path, PathBuf};
13
14use blake2::{digest::consts::U32, Blake2b, Digest};
15use nysiris_core::error::{CoreError, Result};
16use nysiris_core::message::ChatMessage;
17use nysiris_core::sealed_file::{open_bytes, seal_bytes};
18
19type Blake2b256 = Blake2b<U32>;
20
21const MAX_RECORD_BYTES: usize = 1_000_000;
22
23/// Encrypted append-only message store for one application data directory.
24#[derive(Debug)]
25pub struct History {
26    dir: PathBuf,
27    key: [u8; 32],
28    messages: Vec<ChatMessage>,
29    ids: HashSet<String>,
30}
31
32impl History {
33    /// Open every journal in `dir`, decrypting with `key`.
34    ///
35    /// Missing directories are created. A journal that cannot be decrypted or
36    /// parsed fails the whole open rather than being skipped, so truncation and
37    /// tampering are visible.
38    pub fn open(dir: impl AsRef<Path>, key: [u8; 32]) -> Result<Self> {
39        let dir = dir.as_ref().to_path_buf();
40        std::fs::create_dir_all(&dir)?;
41
42        let mut messages = Vec::new();
43        let mut ids = HashSet::new();
44        for entry in std::fs::read_dir(&dir)? {
45            let entry = entry?;
46            let path = entry.path();
47            if path.extension().and_then(|e| e.to_str()) != Some("log") {
48                continue;
49            }
50            let loaded = load_file(&path, &key)?;
51            for message in loaded {
52                if ids.insert(message.msg_id.clone()) {
53                    messages.push(message);
54                }
55            }
56        }
57        messages.sort_by(|a, b| a.at.cmp(&b.at).then_with(|| a.msg_id.cmp(&b.msg_id)));
58        Ok(Self {
59            dir,
60            key,
61            messages,
62            ids,
63        })
64    }
65
66    /// Directory holding the journals.
67    pub fn dir(&self) -> &Path {
68        &self.dir
69    }
70
71    /// All messages, oldest first.
72    pub fn all(&self) -> &[ChatMessage] {
73        &self.messages
74    }
75
76    /// Number of stored messages.
77    pub fn count(&self) -> usize {
78        self.messages.len()
79    }
80
81    /// Whether a message id is already stored.
82    pub fn contains(&self, msg_id: &str) -> bool {
83        self.ids.contains(&msg_id.to_lowercase())
84    }
85
86    /// Append a message. Returns `false` when the id is already present.
87    pub fn append(&mut self, message: &ChatMessage) -> Result<bool> {
88        let msg_id = message.msg_id.to_lowercase();
89        if self.ids.contains(&msg_id) {
90            return Ok(false);
91        }
92        let name = file_name(&message.conversation_id);
93        let path = self.dir.join(&name);
94        append_record(&path, &self.key, name.as_bytes(), message)?;
95        self.ids.insert(msg_id);
96        self.messages.push(message.clone());
97        self.messages
98            .sort_by(|a, b| a.at.cmp(&b.at).then_with(|| a.msg_id.cmp(&b.msg_id)));
99        Ok(true)
100    }
101
102    /// Newest-last messages for one conversation.
103    pub fn list(&self, conversation_id: &str, limit: usize) -> Vec<ChatMessage> {
104        let mut rows: Vec<_> = self
105            .messages
106            .iter()
107            .filter(|m| m.conversation_id == conversation_id)
108            .cloned()
109            .collect();
110        if limit > 0 && rows.len() > limit {
111            rows = rows.split_off(rows.len() - limit);
112        }
113        rows
114    }
115
116    /// Delete every journal and clear the in-memory view.
117    pub fn clear_all(&mut self) -> Result<()> {
118        for entry in std::fs::read_dir(&self.dir)? {
119            let path = entry?.path();
120            if path.extension().and_then(|e| e.to_str()) == Some("log") {
121                std::fs::remove_file(path)?;
122            }
123        }
124        self.messages.clear();
125        self.ids.clear();
126        Ok(())
127    }
128
129    /// Delete one conversation's journal and forget its messages.
130    pub fn clear_conversation(&mut self, conversation_id: &str) -> Result<()> {
131        let path = self.dir.join(file_name(conversation_id));
132        if path.exists() {
133            std::fs::remove_file(path)?;
134        }
135        self.messages
136            .retain(|m| m.conversation_id != conversation_id);
137        self.ids = self.messages.iter().map(|m| m.msg_id.clone()).collect();
138        Ok(())
139    }
140}
141
142fn file_name(conversation_id: &str) -> String {
143    let mut hasher = Blake2b256::new();
144    hasher.update(b"nysiris-chat-history/v1/");
145    hasher.update(conversation_id.as_bytes());
146    let digest = hasher.finalize();
147    format!("{}.log", hex::encode(&digest[..16]))
148}
149
150/// Early development builds used an 8-byte hash. Accept those journals so a
151/// local history survives the widening to 128 bits.
152fn legacy_file_name(conversation_id: &str) -> String {
153    let mut hasher = Blake2b256::new();
154    hasher.update(b"nysiris-chat-history/v1/");
155    hasher.update(conversation_id.as_bytes());
156    let digest = hasher.finalize();
157    format!("{}.log", hex::encode(&digest[..8]))
158}
159
160fn append_record(path: &Path, key: &[u8; 32], aad: &[u8], message: &ChatMessage) -> Result<()> {
161    let record = serde_json::to_vec(message)?;
162    if record.len() > MAX_RECORD_BYTES {
163        return Err(CoreError::History("record is too large".into()));
164    }
165    let sealed = seal_bytes(key, aad, &record)?;
166    let mut file = OpenOptions::new().create(true).append(true).open(path)?;
167    #[cfg(unix)]
168    {
169        use std::os::unix::fs::PermissionsExt;
170        let _ = file.set_permissions(std::fs::Permissions::from_mode(0o600));
171    }
172    file.write_all(&(sealed.len() as u32).to_be_bytes())?;
173    file.write_all(&sealed)?;
174    file.flush()?;
175    file.sync_data()?;
176    Ok(())
177}
178
179fn load_file(path: &Path, key: &[u8; 32]) -> Result<Vec<ChatMessage>> {
180    let name = path
181        .file_name()
182        .and_then(|n| n.to_str())
183        .ok_or_else(|| CoreError::History("history file has no name".into()))?;
184    let aad = name.as_bytes();
185    let mut bytes = Vec::new();
186    std::fs::File::open(path)?.read_to_end(&mut bytes)?;
187
188    let mut offset = 0usize;
189    let mut out = Vec::new();
190    let mut truncated_at: Option<usize> = None;
191    while offset < bytes.len() {
192        if bytes.len() - offset < 4 {
193            truncated_at = Some(offset);
194            break;
195        }
196        let len = u32::from_be_bytes([
197            bytes[offset],
198            bytes[offset + 1],
199            bytes[offset + 2],
200            bytes[offset + 3],
201        ]) as usize;
202        let record_start = offset;
203        offset += 4;
204        if len == 0 || len > MAX_RECORD_BYTES {
205            return Err(CoreError::History(format!("bad record length in {name}")));
206        }
207        if bytes.len() - offset < len {
208            // A crash between the length header and the payload leaves a
209            // truncated tail. Keep every complete record before it.
210            truncated_at = Some(record_start);
211            break;
212        }
213        let plain = open_bytes(key, aad, &bytes[offset..offset + len])?;
214        offset += len;
215        let message: ChatMessage = serde_json::from_slice(&plain)?;
216        if file_name(&message.conversation_id) != name
217            && legacy_file_name(&message.conversation_id) != name
218        {
219            return Err(CoreError::History(format!(
220                "record belongs to another conversation in {name}"
221            )));
222        }
223        out.push(message);
224    }
225
226    if let Some(valid_len) = truncated_at {
227        quarantine_tail(path, &bytes[valid_len..], valid_len)?;
228    }
229    Ok(out)
230}
231
232fn quarantine_tail(path: &Path, tail: &[u8], valid_len: usize) -> Result<()> {
233    let corrupt = path.with_extension("corrupt");
234    let mut file = OpenOptions::new()
235        .create(true)
236        .append(true)
237        .open(&corrupt)?;
238    #[cfg(unix)]
239    {
240        use std::os::unix::fs::PermissionsExt;
241        let _ = file.set_permissions(std::fs::Permissions::from_mode(0o600));
242    }
243    file.write_all(tail)?;
244    file.flush()?;
245    file.sync_data()?;
246    drop(file);
247
248    let journal = OpenOptions::new().write(true).open(path)?;
249    journal.set_len(valid_len as u64)?;
250    journal.sync_all()?;
251    Ok(())
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257    use nysiris_core::message::{ChatMessage, Direction, MessageState};
258    use tempfile::TempDir;
259
260    fn message(id: &str, text: &str) -> ChatMessage {
261        ChatMessage {
262            msg_id: id.to_string(),
263            conversation_id: "dm:aabb".into(),
264            from: "aa".repeat(32),
265            to: "bb".repeat(32),
266            body: text.into(),
267            at: 1,
268            direction: Direction::Incoming,
269            state: MessageState::Sent,
270        }
271    }
272
273    #[test]
274    fn history_persists_encrypted_and_dedupes() {
275        let dir = TempDir::new().unwrap();
276        let key = [7u8; 32];
277        let mut history = History::open(dir.path(), key).unwrap();
278        assert!(history.append(&message("01", "hello")).unwrap());
279        assert!(!history.append(&message("01", "hello")).unwrap());
280        assert!(history.append(&message("02", "world")).unwrap());
281
282        let raw = std::fs::read(history.dir().join(file_name("dm:aabb"))).unwrap();
283        assert!(!raw.windows(5).any(|w| w == b"hello"));
284        assert!(!raw.windows(5).any(|w| w == b"world"));
285
286        let reopened = History::open(dir.path(), key).unwrap();
287        let rows = reopened.list("dm:aabb", 0);
288        assert_eq!(rows.len(), 2);
289        assert_eq!(rows[1].body, "world");
290    }
291
292    #[test]
293    fn wrong_key_fails_closed() {
294        let dir = TempDir::new().unwrap();
295        let mut history = History::open(dir.path(), [7u8; 32]).unwrap();
296        history.append(&message("01", "hello")).unwrap();
297        assert!(History::open(dir.path(), [8u8; 32]).is_err());
298    }
299
300    #[test]
301    fn truncated_tail_is_quarantined_and_complete_records_survive() {
302        let dir = TempDir::new().unwrap();
303        let key = [7u8; 32];
304        let mut history = History::open(dir.path(), key).unwrap();
305        history.append(&message("01", "hello")).unwrap();
306        history.append(&message("02", "world")).unwrap();
307
308        let path = dir.path().join(file_name("dm:aabb"));
309        let valid_len = std::fs::metadata(&path).unwrap().len();
310        let mut file = OpenOptions::new().append(true).open(&path).unwrap();
311        file.write_all(&[0u8, 0u8]).unwrap();
312        file.sync_all().unwrap();
313
314        let reopened = History::open(dir.path(), key).unwrap();
315        let rows = reopened.list("dm:aabb", 0);
316        assert_eq!(rows.len(), 2);
317        assert_eq!(rows[1].body, "world");
318        assert_eq!(std::fs::metadata(&path).unwrap().len(), valid_len);
319        assert!(path.with_extension("corrupt").is_file());
320    }
321}