Skip to main content

a3s_effect/
log.rs

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(&current, 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    /// Ambient constructor for a caller-owned location. The caller owns the
164    /// parent path's authority. Hosts resolving untrusted workspace components
165    /// must instead pass their already validated handle to `from_directory`.
166    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    /// Bind all operations to an already authorized directory handle. This
190    /// activation constructor does not create a directory or resolve an
191    /// ambient path. It tightens permissions through the handle on Unix.
192    pub fn from_directory(dir: Dir) -> Result<Self, ActorError> {
193        let handle = dir.try_clone().map_err(storage_error)?.into_std_file();
194        if !handle.metadata().map_err(storage_error)?.is_dir() {
195            return Err(storage_error("fact log root must be a directory"));
196        }
197        restrict_handle_permissions(&handle, true)?;
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        // A writer-less FIFO blocks the open itself. Refuse before opening;
227        // nonblocking/no-follow options also cover replacement after this gate.
228        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    /// Discard every fact after `seq`, keeping the retained prefix.
304    ///
305    /// The retained prefix is written to a sibling scratch file, flushed, and
306    /// atomically renamed over the thread's log, so a crash leaves either the
307    /// complete old log or the complete truncated one — never a partial line.
308    /// A parse failure or an absent thread removes nothing. Returns how many
309    /// facts were removed; a cut at or beyond the last seq removes nothing and
310    /// leaves the file untouched, so repeating a cut is free.
311    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        // Exclusive creation never truncates an attacker-planted scratch file.
334        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        // The exact handle used to read/validate is also the append handle.
407        // A later path replacement can never redirect this write elsewhere.
408        restrict_handle_permissions(&file.try_clone().map_err(storage_error)?.into_std(), false)?;
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_handle_permissions(file: &fs::File, directory: bool) -> Result<(), ActorError> {
477    #[cfg(unix)]
478    {
479        use std::os::unix::fs::PermissionsExt;
480        let mode = if directory { 0o700 } else { 0o600 };
481        if file.metadata().map_err(storage_error)?.permissions().mode() & 0o777 != mode {
482            file.set_permissions(fs::Permissions::from_mode(mode))
483                .map_err(storage_error)?;
484        }
485    }
486    #[cfg(not(unix))]
487    {
488        let _ = (file, directory);
489    }
490    Ok(())
491}
492
493#[cfg(all(test, unix))]
494mod permission_tests {
495    use super::*;
496
497    #[test]
498    fn fact_log_state_is_owner_only() {
499        use std::os::unix::fs::PermissionsExt;
500        let root = tempfile::tempdir().unwrap();
501        std::fs::set_permissions(root.path(), std::fs::Permissions::from_mode(0o755)).unwrap();
502        let log_dir = root.path().join("effect-log");
503        let log = FileLog::open(&log_dir).unwrap();
504        log.append(
505            "thread",
506            &[NewFact {
507                kind: "user.message".into(),
508                key: "m-1".into(),
509                payload: serde_json::json!({ "text": "hello" }),
510            }],
511            None,
512        )
513        .unwrap();
514        assert_eq!(
515            std::fs::metadata(&log_dir).unwrap().permissions().mode() & 0o777,
516            0o700,
517            "fact log directory must be owner-only"
518        );
519        let file = log_dir.join("thread.jsonl");
520        assert!(file.is_file());
521        assert_eq!(
522            std::fs::metadata(&file).unwrap().permissions().mode() & 0o777,
523            0o600,
524            "fact log file must be owner-only"
525        );
526    }
527}
528
529#[cfg(test)]
530#[path = "log_tests.rs"]
531mod tests;
532
533#[cfg(test)]
534mod security_tests;