Skip to main content

agentd/store/
file.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The local-filesystem store (RFC 0033) — `store.kind: file`. One file per
3//! key, atomic writes, an exclusive instance lock, and traversal closed at the
4//! adapter.
5//!
6//! This is the adapter that lets a laptop satisfy durability without standing
7//! up a coordination backend, so it is the default for a long-lived instance
8//! (RFC 0033 §5). It is deliberately NOT a fleet store: a directory has no
9//! compare-and-set a second process would respect, so instead of pretending,
10//! `open` takes an exclusive `flock` and a second instance fails at startup
11//! with the holder's pid. Finding that out at startup is much cheaper than
12//! finding it out from interleaved runs.
13//!
14//! What it is not: a breach of "agentd runs no code of its own". That rule is
15//! about the AGENT's tools and the trust boundary — there is no `fs` tool, and
16//! this adapter is not reachable by anything the model can call. It is the
17//! runtime's own ledger, on the same side of the boundary as the credential
18//! cache in `auth/cache.rs`, whose directory convention it reuses.
19
20use super::{KeySeq, PutOutcome, Store, StoreError};
21use serde_json::Value;
22use std::fs;
23use std::io;
24use std::path::{Path, PathBuf};
25
26/// The file a key's latest envelope lives in, relative to the root.
27const EXT: &str = "json";
28/// The lock that makes the single-writer property enforced rather than assumed.
29const LOCK: &str = ".lock";
30
31/// `Debug` prints the root only: a store handle appears in test assertions and
32/// in error context, and the lock's file descriptor is noise there.
33#[derive(Debug)]
34pub struct FileStore {
35    root: PathBuf,
36    /// Held for the life of the store: dropping it releases the `flock`.
37    _lock: LockFile,
38}
39
40impl FileStore {
41    /// Open `root`, creating it, and take the exclusive instance lock.
42    pub fn open(root: &Path) -> Result<FileStore, StoreError> {
43        fs::create_dir_all(root)
44            .map_err(|e| StoreError::Io(format!("store dir {}: {e}", root.display())))?;
45        restrict_dir(root);
46        let lock = LockFile::acquire(&root.join(LOCK)).map_err(StoreError::Io)?;
47        Ok(FileStore {
48            root: root.to_path_buf(),
49            _lock: lock,
50        })
51    }
52
53    pub fn root(&self) -> &Path {
54        &self.root
55    }
56
57    /// `<root>/<encoded segment>/…/<encoded id>.json`.
58    ///
59    /// Ids reach this adapter from run ids, task ids and context ids, so a
60    /// traversal is closed HERE rather than assumed to have been closed
61    /// upstream: every segment is percent-encoded, which makes `.`, `..` and a
62    /// separator unrepresentable in the encoded form.
63    fn path_of(&self, key: &str) -> Result<PathBuf, StoreError> {
64        let mut p = self.root.clone();
65        let segs: Vec<&str> = key.split('/').filter(|s| !s.is_empty()).collect();
66        if segs.is_empty() {
67            return Err(StoreError::Mapping("empty store key".into()));
68        }
69        for (i, seg) in segs.iter().enumerate() {
70            if seg.contains('\0') {
71                return Err(StoreError::Mapping("store key contains NUL".into()));
72            }
73            let enc = encode(seg);
74            if i + 1 == segs.len() {
75                p.push(format!("{enc}.{EXT}"));
76            } else {
77                p.push(enc);
78            }
79        }
80        Ok(p)
81    }
82
83    /// Read the envelope at `path`, if the file exists and parses.
84    fn read(&self, path: &Path) -> Result<Option<Value>, StoreError> {
85        match fs::read(path) {
86            Ok(b) => serde_json::from_slice(&b)
87                .map(Some)
88                .map_err(|e| StoreError::Corrupt(format!("{}: {e}", path.display()))),
89            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
90            Err(e) => Err(StoreError::Io(format!("read {}: {e}", path.display()))),
91        }
92    }
93}
94
95impl Store for FileStore {
96    fn put(&self, key: &str, seq: u64, envelope: &Value) -> Result<PutOutcome, StoreError> {
97        let path = self.path_of(key)?;
98        // CAS against what is on disk. Safe without a per-key lock because the
99        // instance lock makes this process the only writer of this root.
100        if let Some(cur) = self.read(&path)?
101            && let Some(l) = cur.get("seq").and_then(Value::as_u64)
102            && seq <= l
103        {
104            return Ok(PutOutcome::Conflict {
105                latest_seq: Some(l),
106            });
107        }
108        if let Some(parent) = path.parent() {
109            fs::create_dir_all(parent)
110                .map_err(|e| StoreError::Io(format!("mkdir {}: {e}", parent.display())))?;
111            // Every level, not just the leaf: `create_dir_all` makes the
112            // intermediate directories with the process umask (0755 by
113            // default), and a key is `<prefix>/<instance>/<kind>/<id>` — three
114            // levels deep. The root's own 0700 hides them today, but a root the
115            // operator points somewhere pre-existing would not, and each level
116            // names an instance and an entity kind.
117            restrict_tree(&self.root, parent);
118        }
119        let body = serde_json::to_vec(envelope)
120            .map_err(|e| StoreError::Mapping(format!("envelope: {e}")))?;
121        write_atomic(&path, &body).map_err(StoreError::Io)?;
122        Ok(PutOutcome::Ok)
123    }
124
125    fn get(&self, key: &str, _seq: Option<u64>) -> Result<Option<Value>, StoreError> {
126        // Latest-only, like the http adapter: a pinned seq reads as the latest.
127        let v = self.read(&self.path_of(key)?)?;
128        // A tombstone (latest state null) reads as absent, per RFC 0025 §3.
129        Ok(v.filter(|v| !v.get("state").is_some_and(Value::is_null)))
130    }
131
132    fn list(&self, prefix: &str) -> Result<Vec<KeySeq>, StoreError> {
133        let mut dir = self.root.clone();
134        for seg in prefix.split('/').filter(|s| !s.is_empty()) {
135            dir.push(encode(seg));
136        }
137        let mut out = Vec::new();
138        walk(&dir, &self.root, &mut out)?;
139        out.sort_by(|a, b| a.key.cmp(&b.key));
140        Ok(out)
141    }
142
143    fn delete(&self, key: &str) -> Result<(), StoreError> {
144        match fs::remove_file(self.path_of(key)?) {
145            Ok(()) => Ok(()),
146            Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
147            Err(e) => Err(StoreError::Io(format!("delete {key}: {e}"))),
148        }
149    }
150
151    fn kind(&self) -> &'static str {
152        "file"
153    }
154}
155
156/// Recursively collect every `*.json` under `dir`, keyed by its path relative
157/// to `root` with the segments decoded back to the original key.
158fn walk(dir: &Path, root: &Path, out: &mut Vec<KeySeq>) -> Result<(), StoreError> {
159    let rd = match fs::read_dir(dir) {
160        Ok(rd) => rd,
161        Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
162        Err(e) => return Err(StoreError::Io(format!("list {}: {e}", dir.display()))),
163    };
164    for ent in rd.flatten() {
165        let p = ent.path();
166        if p.is_dir() {
167            walk(&p, root, out)?;
168        } else if p.extension().is_some_and(|e| e == EXT) {
169            let Ok(rel) = p.strip_prefix(root) else {
170                continue;
171            };
172            let mut segs: Vec<String> = rel
173                .components()
174                .map(|c| decode(&c.as_os_str().to_string_lossy()))
175                .collect();
176            if let Some(last) = segs.last_mut()
177                && let Some(stem) = last.strip_suffix(&format!(".{EXT}"))
178            {
179                *last = stem.to_string();
180            }
181            let seq = fs::read(&p)
182                .ok()
183                .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
184                .and_then(|v| v.get("seq").and_then(Value::as_u64));
185            out.push(KeySeq {
186                key: segs.join("/"),
187                seq,
188            });
189        }
190    }
191    Ok(())
192}
193
194/// Write `body` to `path` so a crash leaves either the old bytes or the new
195/// ones and never a partial file: a temp file in the same directory, fsync'd,
196/// renamed over the target, then the directory itself fsync'd so the rename is
197/// durable too.
198fn write_atomic(path: &Path, body: &[u8]) -> Result<(), String> {
199    use std::io::Write;
200    let dir = path.parent().ok_or_else(|| "no parent dir".to_string())?;
201    let tmp = dir.join(format!(
202        ".{}.tmp.{}",
203        path.file_name().unwrap_or_default().to_string_lossy(),
204        std::process::id()
205    ));
206    {
207        let mut f = fs::File::create(&tmp).map_err(|e| format!("create {}: {e}", tmp.display()))?;
208        restrict_file(&f);
209        f.write_all(body)
210            .map_err(|e| format!("write {}: {e}", tmp.display()))?;
211        f.sync_all()
212            .map_err(|e| format!("fsync {}: {e}", tmp.display()))?;
213    }
214    fs::rename(&tmp, path).map_err(|e| {
215        let _ = fs::remove_file(&tmp);
216        format!("rename into {}: {e}", path.display())
217    })?;
218    // The rename itself is only durable once the directory entry is synced.
219    if let Ok(d) = fs::File::open(dir) {
220        let _ = d.sync_all();
221    }
222    Ok(())
223}
224
225/// `0700` on every directory from `root` (exclusive) down to `leaf` (inclusive).
226fn restrict_tree(root: &Path, leaf: &Path) {
227    let Ok(rel) = leaf.strip_prefix(root) else {
228        restrict_dir(leaf);
229        return;
230    };
231    let mut p = root.to_path_buf();
232    for comp in rel.components() {
233        p.push(comp);
234        restrict_dir(&p);
235    }
236}
237
238/// `0700` on a state directory: it holds conversation content and tool results.
239fn restrict_dir(p: &Path) {
240    #[cfg(unix)]
241    {
242        use std::os::unix::fs::PermissionsExt;
243        let _ = fs::set_permissions(p, fs::Permissions::from_mode(0o700));
244    }
245    #[cfg(not(unix))]
246    let _ = p;
247}
248
249/// `0600` on a state file, set before any bytes are written.
250fn restrict_file(f: &fs::File) {
251    #[cfg(unix)]
252    {
253        use std::os::unix::fs::PermissionsExt;
254        let _ = f.set_permissions(fs::Permissions::from_mode(0o600));
255    }
256    #[cfg(not(unix))]
257    let _ = f;
258}
259
260/// Percent-encode everything outside an unreserved set. `.`/`..`/`/` become
261/// unrepresentable, which is what closes traversal.
262fn encode(seg: &str) -> String {
263    let mut out = String::with_capacity(seg.len());
264    for b in seg.bytes() {
265        let safe = b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_');
266        if safe {
267            out.push(b as char);
268        } else {
269            out.push_str(&format!("%{b:02X}"));
270        }
271    }
272    out
273}
274
275fn decode(seg: &str) -> String {
276    let b = seg.as_bytes();
277    let mut out = Vec::with_capacity(b.len());
278    let mut i = 0;
279    while i < b.len() {
280        if b[i] == b'%' && i + 2 < b.len() {
281            let hex = std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or("");
282            if let Ok(v) = u8::from_str_radix(hex, 16) {
283                out.push(v);
284                i += 3;
285                continue;
286            }
287        }
288        out.push(b[i]);
289        i += 1;
290    }
291    String::from_utf8_lossy(&out).into_owned()
292}
293
294/// An exclusive `flock` held for the life of the store.
295#[derive(Debug)]
296struct LockFile {
297    _file: fs::File,
298}
299
300impl LockFile {
301    fn acquire(path: &Path) -> Result<LockFile, String> {
302        let file = fs::OpenOptions::new()
303            .create(true)
304            .read(true)
305            .write(true)
306            .truncate(false)
307            .open(path)
308            .map_err(|e| format!("lock {}: {e}", path.display()))?;
309        restrict_file(&file);
310        #[cfg(unix)]
311        {
312            use std::io::{Read, Seek, Write};
313            use std::os::unix::io::AsRawFd;
314            // Non-blocking: a held lock must fail fast and say who holds it,
315            // not stall a startup that is never going to succeed.
316            let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
317            if rc != 0 {
318                let mut holder = String::new();
319                let mut f = &file;
320                let _ = f.rewind();
321                let _ = f.read_to_string(&mut holder);
322                let who = holder.trim();
323                let who = if who.is_empty() {
324                    "another process".to_string()
325                } else {
326                    format!("pid {who}")
327                };
328                return Err(format!(
329                    "{} is locked by {who} — another agentd is using this state \
330                     directory; give this instance its own agent.name or store.file.path",
331                    path.parent().unwrap_or(path).display()
332                ));
333            }
334            let mut f = &file;
335            let _ = f.rewind();
336            let _ = f.set_len(0);
337            let _ = write!(f, "{}", std::process::id());
338            let _ = f.flush();
339        }
340        Ok(LockFile { _file: file })
341    }
342}