1use super::{KeySeq, PutOutcome, Store, StoreError};
20use serde_json::Value;
21use std::fs;
22use std::io;
23use std::path::{Path, PathBuf};
24
25const EXT: &str = "json";
27const LOCK: &str = ".lock";
29
30#[derive(Debug)]
33pub struct FileStore {
34 root: PathBuf,
35 seqs: std::sync::Mutex<std::collections::HashMap<String, u64>>,
43 _lock: LockFile,
45}
46
47impl FileStore {
48 pub fn open(root: &Path) -> Result<FileStore, StoreError> {
50 fs::create_dir_all(root)
51 .map_err(|e| StoreError::Io(format!("store dir {}: {e}", root.display())))?;
52 restrict_dir(root);
53 let lock = LockFile::acquire(&root.join(LOCK)).map_err(StoreError::Io)?;
54 Ok(FileStore {
55 root: root.to_path_buf(),
56 seqs: std::sync::Mutex::new(std::collections::HashMap::new()),
57 _lock: lock,
58 })
59 }
60
61 pub fn root(&self) -> &Path {
62 &self.root
63 }
64
65 fn path_of(&self, key: &str) -> Result<PathBuf, StoreError> {
72 let mut p = self.root.clone();
73 let segs: Vec<&str> = key.split('/').filter(|s| !s.is_empty()).collect();
74 if segs.is_empty() {
75 return Err(StoreError::Mapping("empty store key".into()));
76 }
77 for (i, seg) in segs.iter().enumerate() {
78 if seg.contains('\0') {
79 return Err(StoreError::Mapping("store key contains NUL".into()));
80 }
81 let enc = encode(seg);
82 if i + 1 == segs.len() {
83 p.push(format!("{enc}.{EXT}"));
84 } else {
85 p.push(enc);
86 }
87 }
88 Ok(p)
89 }
90
91 fn read(&self, path: &Path) -> Result<Option<Value>, StoreError> {
93 match fs::read(path) {
94 Ok(b) => serde_json::from_slice(&b)
95 .map(Some)
96 .map_err(|e| StoreError::Corrupt(format!("{}: {e}", path.display()))),
97 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
98 Err(e) => Err(StoreError::Io(format!("read {}: {e}", path.display()))),
99 }
100 }
101}
102
103impl Store for FileStore {
104 fn put(&self, key: &str, seq: u64, envelope: &Value) -> Result<PutOutcome, StoreError> {
105 let path = self.path_of(key)?;
106 let cached = self.seqs.lock().expect("seqs").get(key).copied();
110 let latest = match cached {
111 Some(l) => Some(l),
112 None => self
113 .read(&path)?
114 .and_then(|cur| cur.get("seq").and_then(Value::as_u64)),
115 };
116 if let Some(l) = latest
117 && seq <= l
118 {
119 return Ok(PutOutcome::Conflict {
120 latest_seq: Some(l),
121 });
122 }
123 if let Some(parent) = path.parent() {
124 fs::create_dir_all(parent)
125 .map_err(|e| StoreError::Io(format!("mkdir {}: {e}", parent.display())))?;
126 restrict_tree(&self.root, parent);
133 }
134 let body = serde_json::to_vec(envelope)
135 .map_err(|e| StoreError::Mapping(format!("envelope: {e}")))?;
136 write_atomic(&path, &body).map_err(StoreError::Io)?;
137 self.seqs.lock().expect("seqs").insert(key.to_string(), seq);
138 Ok(PutOutcome::Ok)
139 }
140
141 fn get(&self, key: &str, _seq: Option<u64>) -> Result<Option<Value>, StoreError> {
142 let v = self.read(&self.path_of(key)?)?;
144 Ok(v.filter(|v| !v.get("state").is_some_and(Value::is_null)))
147 }
148
149 fn list(&self, prefix: &str) -> Result<Vec<KeySeq>, StoreError> {
150 let mut dir = self.root.clone();
151 for seg in prefix.split('/').filter(|s| !s.is_empty()) {
152 dir.push(encode(seg));
153 }
154 let mut out = Vec::new();
155 walk(&dir, &self.root, &mut out)?;
156 out.sort_by(|a, b| a.key.cmp(&b.key));
157 Ok(out)
158 }
159
160 fn delete(&self, key: &str) -> Result<(), StoreError> {
161 self.seqs.lock().expect("seqs").remove(key);
162 match fs::remove_file(self.path_of(key)?) {
163 Ok(()) => Ok(()),
164 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
165 Err(e) => Err(StoreError::Io(format!("delete {key}: {e}"))),
166 }
167 }
168
169 fn kind(&self) -> &'static str {
170 "file"
171 }
172}
173
174fn walk(dir: &Path, root: &Path, out: &mut Vec<KeySeq>) -> Result<(), StoreError> {
177 let rd = match fs::read_dir(dir) {
178 Ok(rd) => rd,
179 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(()),
180 Err(e) => return Err(StoreError::Io(format!("list {}: {e}", dir.display()))),
181 };
182 for ent in rd.flatten() {
183 let p = ent.path();
184 if p.is_dir() {
185 walk(&p, root, out)?;
186 } else if p.extension().is_some_and(|e| e == EXT) {
187 let Ok(rel) = p.strip_prefix(root) else {
188 continue;
189 };
190 let mut segs: Vec<String> = rel
191 .components()
192 .map(|c| decode(&c.as_os_str().to_string_lossy()))
193 .collect();
194 if let Some(last) = segs.last_mut()
195 && let Some(stem) = last.strip_suffix(&format!(".{EXT}"))
196 {
197 *last = stem.to_string();
198 }
199 let seq = fs::read(&p)
200 .ok()
201 .and_then(|b| serde_json::from_slice::<Value>(&b).ok())
202 .and_then(|v| v.get("seq").and_then(Value::as_u64));
203 out.push(KeySeq {
204 key: segs.join("/"),
205 seq,
206 });
207 }
208 }
209 Ok(())
210}
211
212fn write_atomic(path: &Path, body: &[u8]) -> Result<(), String> {
217 use std::io::Write;
218 let dir = path.parent().ok_or_else(|| "no parent dir".to_string())?;
219 let tmp = dir.join(format!(
220 ".{}.tmp.{}",
221 path.file_name().unwrap_or_default().to_string_lossy(),
222 std::process::id()
223 ));
224 {
225 let mut f = fs::File::create(&tmp).map_err(|e| format!("create {}: {e}", tmp.display()))?;
226 restrict_file(&f);
227 f.write_all(body)
228 .map_err(|e| format!("write {}: {e}", tmp.display()))?;
229 f.sync_all()
230 .map_err(|e| format!("fsync {}: {e}", tmp.display()))?;
231 }
232 fs::rename(&tmp, path).map_err(|e| {
233 let _ = fs::remove_file(&tmp);
234 format!("rename into {}: {e}", path.display())
235 })?;
236 if let Ok(d) = fs::File::open(dir) {
238 let _ = d.sync_all();
239 }
240 Ok(())
241}
242
243fn restrict_tree(root: &Path, leaf: &Path) {
245 let Ok(rel) = leaf.strip_prefix(root) else {
246 restrict_dir(leaf);
247 return;
248 };
249 let mut p = root.to_path_buf();
250 for comp in rel.components() {
251 p.push(comp);
252 restrict_dir(&p);
253 }
254}
255
256fn restrict_dir(p: &Path) {
258 #[cfg(unix)]
259 {
260 use std::os::unix::fs::PermissionsExt;
261 let _ = fs::set_permissions(p, fs::Permissions::from_mode(0o700));
262 }
263 #[cfg(not(unix))]
264 let _ = p;
265}
266
267fn restrict_file(f: &fs::File) {
269 #[cfg(unix)]
270 {
271 use std::os::unix::fs::PermissionsExt;
272 let _ = f.set_permissions(fs::Permissions::from_mode(0o600));
273 }
274 #[cfg(not(unix))]
275 let _ = f;
276}
277
278fn encode(seg: &str) -> String {
281 let mut out = String::with_capacity(seg.len());
282 for b in seg.bytes() {
283 let safe = b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_');
284 if safe {
285 out.push(b as char);
286 } else {
287 out.push_str(&format!("%{b:02X}"));
288 }
289 }
290 out
291}
292
293fn decode(seg: &str) -> String {
294 let b = seg.as_bytes();
295 let mut out = Vec::with_capacity(b.len());
296 let mut i = 0;
297 while i < b.len() {
298 if b[i] == b'%' && i + 2 < b.len() {
299 let hex = std::str::from_utf8(&b[i + 1..i + 3]).unwrap_or("");
300 if let Ok(v) = u8::from_str_radix(hex, 16) {
301 out.push(v);
302 i += 3;
303 continue;
304 }
305 }
306 out.push(b[i]);
307 i += 1;
308 }
309 String::from_utf8_lossy(&out).into_owned()
310}
311
312#[derive(Debug)]
314struct LockFile {
315 _file: fs::File,
316}
317
318impl LockFile {
319 fn acquire(path: &Path) -> Result<LockFile, String> {
320 let file = fs::OpenOptions::new()
321 .create(true)
322 .read(true)
323 .write(true)
324 .truncate(false)
325 .open(path)
326 .map_err(|e| format!("lock {}: {e}", path.display()))?;
327 restrict_file(&file);
328 #[cfg(unix)]
329 {
330 use std::io::{Read, Seek, Write};
331 use std::os::unix::io::AsRawFd;
332 let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
335 if rc != 0 {
336 let mut holder = String::new();
337 let mut f = &file;
338 let _ = f.rewind();
339 let _ = f.read_to_string(&mut holder);
340 let who = holder.trim();
341 let who = if who.is_empty() {
342 "another process".to_string()
343 } else {
344 format!("pid {who}")
345 };
346 return Err(format!(
347 "{} is locked by {who} — another agentd is using this state \
348 directory; give this instance its own agent.name or store.file.path",
349 path.parent().unwrap_or(path).display()
350 ));
351 }
352 let mut f = &file;
353 let _ = f.rewind();
354 let _ = f.set_len(0);
355 let _ = write!(f, "{}", std::process::id());
356 let _ = f.flush();
357 }
358 Ok(LockFile { _file: file })
359 }
360}