1use std::collections::{HashMap, HashSet};
2use std::fs;
3use std::io::{self, Read, Write};
4use std::path::Path;
5use std::sync::atomic::{AtomicU64, Ordering};
6use std::sync::{Arc, Mutex, Weak};
7
8use cap_fs_ext::{DirExt, FollowSymlinks, MetadataExt, OpenOptionsFollowExt, OpenOptionsSyncExt};
9use cap_std::fs::{Dir, File, Metadata, OpenOptions};
10
11use crate::error::ActorError;
12use crate::fact::{parse_fact_json, Fact, NewFact};
13
14fn valid_thread(thread_id: &str) -> Result<(), ActorError> {
15 let ok = !thread_id.is_empty()
16 && thread_id.len() <= 128
17 && thread_id
18 .chars()
19 .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '.' | ':' | '-'));
20 if ok {
21 Ok(())
22 } else {
23 Err(ActorError::BadThreadId {
24 thread_id: thread_id.to_string(),
25 })
26 }
27}
28
29fn same_payload(stored: &Fact, incoming: &NewFact) -> bool {
30 stored.kind == incoming.kind && stored.payload == incoming.payload
31}
32
33fn commit(
34 current: &[Fact],
35 incoming: &[NewFact],
36 cause: Option<&str>,
37) -> Result<Vec<Fact>, ActorError> {
38 if incoming.is_empty() {
39 return Ok(current.to_vec());
40 }
41 if incoming
42 .iter()
43 .any(|item| item.kind.is_empty() || item.key.is_empty())
44 {
45 return Err(ActorError::Schema("type and key are required".into()));
46 }
47 let mut keys = HashSet::new();
48 if let Some(duplicate) = incoming.iter().find(|item| !keys.insert(&item.key)) {
49 return Err(ActorError::DuplicateFact {
50 key: duplicate.key.clone(),
51 });
52 }
53 let already = incoming.iter().all(|item| {
54 current
55 .iter()
56 .any(|stored| stored.key == item.key && same_payload(stored, item))
57 });
58 if already {
59 return Ok(current.to_vec());
60 }
61 if let Some(conflict) = incoming
62 .iter()
63 .find(|item| current.iter().any(|stored| stored.key == item.key))
64 {
65 return Err(ActorError::DuplicateFact {
66 key: conflict.key.clone(),
67 });
68 }
69 let base_seq = current.last().map(|fact| fact.seq).unwrap_or(0);
70 let mut next = current.to_vec();
71 for (index, item) in incoming.iter().enumerate() {
72 let seq = u64::try_from(index)
73 .ok()
74 .and_then(|index| index.checked_add(1))
75 .and_then(|offset| base_seq.checked_add(offset))
76 .ok_or_else(|| ActorError::Schema("fact sequence overflow".into()))?;
77 next.push(Fact {
78 seq,
79 kind: item.kind.clone(),
80 key: item.key.clone(),
81 cause: cause.map(str::to_string),
82 payload: item.payload.clone(),
83 });
84 }
85 Ok(next)
86}
87
88pub trait LogStore: Send + Sync {
89 fn read(&self, thread_id: &str) -> Result<Vec<Fact>, ActorError>;
90 fn append(
91 &self,
92 thread_id: &str,
93 facts: &[NewFact],
94 cause: Option<&str>,
95 ) -> Result<Vec<Fact>, ActorError>;
96}
97
98#[derive(Default)]
99pub struct MemoryLog {
100 threads: Mutex<HashMap<String, Vec<Fact>>>,
101}
102
103impl MemoryLog {
104 pub fn new() -> Self {
105 Self::default()
106 }
107}
108
109impl LogStore for MemoryLog {
110 fn read(&self, thread_id: &str) -> Result<Vec<Fact>, ActorError> {
111 valid_thread(thread_id)?;
112 let threads = self
113 .threads
114 .lock()
115 .unwrap_or_else(|poison| poison.into_inner());
116 Ok(threads.get(thread_id).cloned().unwrap_or_default())
117 }
118
119 fn append(
120 &self,
121 thread_id: &str,
122 facts: &[NewFact],
123 cause: Option<&str>,
124 ) -> Result<Vec<Fact>, ActorError> {
125 valid_thread(thread_id)?;
126 let mut threads = self
127 .threads
128 .lock()
129 .unwrap_or_else(|poison| poison.into_inner());
130 let current = threads.get(thread_id).cloned().unwrap_or_default();
131 let next = commit(¤t, facts, cause)?;
132 threads.insert(thread_id.to_string(), next.clone());
133 Ok(next)
134 }
135}
136
137pub struct FileLog {
138 dir: Dir,
139 lock: Mutex<()>,
140 writer: Arc<Mutex<Weak<fencing::LockedLogFile>>>,
141}
142
143impl std::fmt::Debug for FileLog {
144 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145 f.debug_struct("FileLog").finish_non_exhaustive()
146 }
147}
148
149mod fencing;
150pub use fencing::{FileLogRunOwnerFence, FileLogWriterFence};
151
152const MAX_LOG_BYTES: u64 = 16 * 1024 * 1024;
153const MAX_LOG_FACTS: usize = 100_000;
154
155struct OpenedLog {
156 file: File,
157 metadata: Metadata,
158 facts: Vec<Fact>,
159 needs_separator: bool,
160}
161
162impl FileLog {
163 pub fn open(dir: impl AsRef<Path>) -> Result<Self, ActorError> {
167 let path = std::path::absolute(dir).map_err(storage_error)?;
168 match fs::symlink_metadata(&path) {
169 Ok(metadata) if !metadata.is_dir() || metadata.is_symlink() => {
170 return Err(storage_error("fact log root must be a directory"));
171 }
172 Ok(_) => {}
173 Err(error) if error.kind() == io::ErrorKind::NotFound => {
174 fs::create_dir_all(&path).map_err(storage_error)?;
175 }
176 Err(error) => return Err(storage_error(error)),
177 }
178 let name = path
179 .file_name()
180 .ok_or_else(|| storage_error("fact log root needs a directory name"))?;
181 let parent = path
182 .parent()
183 .ok_or_else(|| storage_error("fact log root needs a parent"))?;
184 let parent =
185 Dir::open_ambient_dir(parent, cap_std::ambient_authority()).map_err(storage_error)?;
186 Self::from_directory(parent.open_dir_nofollow(name).map_err(storage_error)?)
187 }
188
189 pub fn from_directory(dir: Dir) -> Result<Self, ActorError> {
193 let metadata = dir.dir_metadata().map_err(storage_error)?;
194 if !metadata.is_dir() {
195 return Err(storage_error("fact log root must be a directory"));
196 }
197 restrict_directory_permissions(&dir, &metadata)?;
198 Ok(Self {
199 dir,
200 lock: Mutex::new(()),
201 writer: Arc::new(Mutex::new(Weak::new())),
202 })
203 }
204
205 fn name(thread_id: &str) -> String {
206 format!("{thread_id}.jsonl")
207 }
208
209 fn tmp_name(thread_id: &str) -> String {
210 static REWRITES: AtomicU64 = AtomicU64::new(0);
211 let n = REWRITES.fetch_add(1, Ordering::Relaxed);
212 format!("{thread_id}.jsonl.tmp-{}-{n}", std::process::id())
213 }
214
215 fn read_opened(
216 &self,
217 thread_id: &str,
218 writable: bool,
219 ) -> Result<Option<OpenedLog>, ActorError> {
220 let name = Self::name(thread_id);
221 let before = match self.dir.symlink_metadata(&name) {
222 Ok(metadata) => metadata,
223 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
224 Err(error) => return Err(storage_error(error)),
225 };
226 validate_regular(&before)?;
229 let mut opened = self.open_regular(&name, writable)?;
230 same_generation(&before, &opened.metadata)?;
231 let mut raw = Vec::new();
232 (&opened.file)
233 .take(MAX_LOG_BYTES + 1)
234 .read_to_end(&mut raw)
235 .map_err(storage_error)?;
236 if raw.len() as u64 > MAX_LOG_BYTES || raw.len() as u64 != opened.metadata.len() {
237 return Err(storage_error("fact log changed or exceeded its byte limit"));
238 }
239 same_generation(
240 &opened.metadata,
241 &opened.file.metadata().map_err(storage_error)?,
242 )?;
243 let text = std::str::from_utf8(&raw).map_err(storage_error)?;
244 let mut keys = HashSet::new();
245 for line in text.lines().filter(|line| !line.is_empty()) {
246 if opened.facts.len() == MAX_LOG_FACTS {
247 return Err(storage_error("fact log fact-count limit exceeded"));
248 }
249 let fact = parse_fact_json(line)?;
250 if fact.seq != opened.facts.len() as u64 + 1 || !keys.insert(fact.key.clone()) {
251 return Err(storage_error("invalid fact log sequence or duplicate key"));
252 }
253 opened.facts.push(fact);
254 }
255 opened.needs_separator = !raw.is_empty() && !raw.ends_with(b"\n");
256 Ok(Some(opened))
257 }
258
259 fn open_regular(&self, name: &str, writable: bool) -> Result<OpenedLog, ActorError> {
260 let mut options = OpenOptions::new();
261 options
262 .read(true)
263 .append(writable)
264 .follow(FollowSymlinks::No)
265 .nonblock(true);
266 let file = self.dir.open_with(name, &options).map_err(storage_error)?;
267 let metadata = file.metadata().map_err(storage_error)?;
268 validate_regular(&metadata)?;
269 Ok(OpenedLog {
270 file,
271 metadata,
272 facts: Vec::new(),
273 needs_separator: false,
274 })
275 }
276
277 fn check_current(&self, thread_id: &str, opened: &OpenedLog) -> Result<(), ActorError> {
278 same_generation(
279 &opened.metadata,
280 &self
281 .dir
282 .symlink_metadata(Self::name(thread_id))
283 .map_err(storage_error)?,
284 )
285 }
286
287 fn create_new(&self, name: &str) -> Result<File, ActorError> {
288 let mut options = OpenOptions::new();
289 options
290 .read(true)
291 .append(true)
292 .create_new(true)
293 .follow(FollowSymlinks::No)
294 .nonblock(true);
295 #[cfg(unix)]
296 {
297 use cap_std::fs::OpenOptionsExt;
298 options.mode(0o600);
299 }
300 self.dir.open_with(name, &options).map_err(storage_error)
301 }
302
303 pub fn truncate_after(&self, thread_id: &str, seq: u64) -> Result<usize, ActorError> {
312 valid_thread(thread_id)?;
313 let _guard = self
314 .lock
315 .lock()
316 .unwrap_or_else(|poison| poison.into_inner());
317 match self.dir.symlink_metadata(Self::name(thread_id)) {
318 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(0),
319 Err(error) => return Err(storage_error(error)),
320 Ok(_) => {}
321 }
322 let _writer = self.prepare_writer_fence(thread_id)?;
323 let Some(opened) = self.read_opened(thread_id, false)? else {
324 return Ok(0);
325 };
326 let retained = opened.facts.partition_point(|fact| fact.seq <= seq);
327 let removed = opened.facts.len() - retained;
328 if removed == 0 {
329 return Ok(0);
330 }
331 let bytes = encode_facts(&opened.facts[..retained], MAX_LOG_BYTES, false)?;
332 let tmp = Self::tmp_name(thread_id);
333 let mut file = self.create_new(&tmp)?;
335 let rewrite = (|| {
336 file.write_all(&bytes).map_err(storage_error)?;
337 file.sync_all().map_err(storage_error)?;
338 self.check_current(thread_id, &opened)?;
339 self.dir
340 .rename(&tmp, &self.dir, Self::name(thread_id))
341 .map_err(storage_error)
342 })();
343 if rewrite.is_err() {
344 let _ = self.dir.remove_file(&tmp);
345 }
346 rewrite?;
347 Ok(removed)
348 }
349}
350
351impl LogStore for FileLog {
352 fn read(&self, thread_id: &str) -> Result<Vec<Fact>, ActorError> {
353 valid_thread(thread_id)?;
354 let _guard = self
355 .lock
356 .lock()
357 .unwrap_or_else(|poison| poison.into_inner());
358 let _writer = self.existing_writer_fence()?;
359 Ok(self
360 .read_opened(thread_id, false)?
361 .map(|opened| opened.facts)
362 .unwrap_or_default())
363 }
364
365 fn append(
366 &self,
367 thread_id: &str,
368 facts: &[NewFact],
369 cause: Option<&str>,
370 ) -> Result<Vec<Fact>, ActorError> {
371 valid_thread(thread_id)?;
372 let _guard = self
373 .lock
374 .lock()
375 .unwrap_or_else(|poison| poison.into_inner());
376 let _writer = self.prepare_writer_fence(thread_id)?;
377 let opened = self.read_opened(thread_id, true)?;
378 let current = opened
379 .as_ref()
380 .map(|opened| opened.facts.as_slice())
381 .unwrap_or_default();
382 let next = commit(current, facts, cause)?;
383 if next.len() == current.len() {
384 return Ok(next);
385 }
386 if next.len() > MAX_LOG_FACTS {
387 return Err(storage_error("fact log fact-count limit exceeded"));
388 }
389 let length = opened
390 .as_ref()
391 .map(|opened| opened.metadata.len())
392 .unwrap_or(0);
393 let needs_separator = opened.as_ref().is_some_and(|opened| opened.needs_separator);
394 let bytes = encode_facts(
395 &next[current.len()..],
396 MAX_LOG_BYTES - length,
397 needs_separator,
398 )?;
399 let mut file = match opened {
400 Some(opened) => {
401 self.check_current(thread_id, &opened)?;
402 opened.file
403 }
404 None => self.create_new(&Self::name(thread_id))?,
405 };
406 restrict_file_permissions(&file.try_clone().map_err(storage_error)?.into_std())?;
409 file.write_all(&bytes).map_err(storage_error)?;
410 file.sync_all().map_err(storage_error)?;
411 Ok(next)
412 }
413}
414
415fn validate_regular(metadata: &Metadata) -> Result<(), ActorError> {
416 if !metadata.is_file() || metadata.nlink() != 1 {
417 return Err(storage_error("fact log must be a single-link regular file"));
418 }
419 if metadata.len() > MAX_LOG_BYTES {
420 return Err(storage_error("fact log byte limit exceeded"));
421 }
422 Ok(())
423}
424
425fn same_generation(before: &Metadata, after: &Metadata) -> Result<(), ActorError> {
426 validate_regular(after)?;
427 if before.dev() != after.dev()
428 || before.ino() != after.ino()
429 || before.len() != after.len()
430 || before.modified().map_err(storage_error)? != after.modified().map_err(storage_error)?
431 {
432 return Err(storage_error("fact log changed during this operation"));
433 }
434 Ok(())
435}
436
437struct BoundedBuffer {
438 bytes: Vec<u8>,
439 remaining: u64,
440}
441
442impl Write for BoundedBuffer {
443 fn write(&mut self, bytes: &[u8]) -> io::Result<usize> {
444 if bytes.len() as u64 > self.remaining {
445 return Err(io::Error::other("fact log byte limit exceeded"));
446 }
447 self.bytes.extend_from_slice(bytes);
448 self.remaining -= bytes.len() as u64;
449 Ok(bytes.len())
450 }
451
452 fn flush(&mut self) -> io::Result<()> {
453 Ok(())
454 }
455}
456
457fn encode_facts(facts: &[Fact], limit: u64, separator: bool) -> Result<Vec<u8>, ActorError> {
458 let mut buffer = BoundedBuffer {
459 bytes: Vec::new(),
460 remaining: limit,
461 };
462 if separator {
463 buffer.write_all(b"\n").map_err(storage_error)?;
464 }
465 for fact in facts {
466 serde_json::to_writer(&mut buffer, fact).map_err(storage_error)?;
467 buffer.write_all(b"\n").map_err(storage_error)?;
468 }
469 Ok(buffer.bytes)
470}
471
472fn storage_error(error: impl std::fmt::Display) -> ActorError {
473 ActorError::Schema(error.to_string())
474}
475
476fn restrict_file_permissions(file: &fs::File) -> Result<(), ActorError> {
477 #[cfg(unix)]
478 {
479 use std::os::unix::fs::PermissionsExt;
480 if file.metadata().map_err(storage_error)?.permissions().mode() & 0o777 != 0o600 {
481 file.set_permissions(fs::Permissions::from_mode(0o600))
482 .map_err(storage_error)?;
483 }
484 }
485 #[cfg(not(unix))]
486 {
487 let _ = file;
488 }
489 Ok(())
490}
491
492fn restrict_directory_permissions(dir: &Dir, metadata: &Metadata) -> Result<(), ActorError> {
495 #[cfg(unix)]
496 {
497 use cap_std::fs::{Permissions, PermissionsExt};
498 if metadata.permissions().mode() & 0o777 != 0o700 {
499 dir.set_permissions(".", Permissions::from_mode(0o700))
500 .map_err(storage_error)?;
501 }
502 }
503 #[cfg(not(unix))]
504 {
505 let _ = (dir, metadata);
506 }
507 Ok(())
508}
509
510#[cfg(all(test, unix))]
511mod permission_tests {
512 use super::*;
513
514 #[test]
515 fn fact_log_state_is_owner_only() {
516 use std::os::unix::fs::PermissionsExt;
517 let root = tempfile::tempdir().unwrap();
518 std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
519 let log_dir = root.path().join("effect-log");
520 let log = FileLog::open(&log_dir).unwrap();
521 log.append(
522 "thread",
523 &[NewFact {
524 kind: "user.message".into(),
525 key: "m-1".into(),
526 payload: serde_json::json!({ "text": "hello" }),
527 }],
528 None,
529 )
530 .unwrap();
531 assert_eq!(
532 std::fs::metadata(&log_dir).unwrap().permissions().mode() & 0o777,
533 0o700,
534 "fact log directory must be owner-only"
535 );
536 let file = log_dir.join("thread.jsonl");
537 assert!(file.is_file());
538 assert_eq!(
539 std::fs::metadata(&file).unwrap().permissions().mode() & 0o777,
540 0o600,
541 "fact log file must be owner-only"
542 );
543 }
544}
545
546#[cfg(test)]
547#[path = "log_tests.rs"]
548mod tests;
549
550#[cfg(test)]
551mod security_tests;