nysiris_chat_history/
lib.rs1#![warn(missing_docs)]
2use 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#[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 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 pub fn dir(&self) -> &Path {
68 &self.dir
69 }
70
71 pub fn all(&self) -> &[ChatMessage] {
73 &self.messages
74 }
75
76 pub fn count(&self) -> usize {
78 self.messages.len()
79 }
80
81 pub fn contains(&self, msg_id: &str) -> bool {
83 self.ids.contains(&msg_id.to_lowercase())
84 }
85
86 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 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 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 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
150fn 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 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}