1use serde::{Deserialize, Serialize};
20use std::fs;
21use std::io::{self, Read as _, Write as _};
22use std::path::{Path, PathBuf};
23
24#[derive(Debug, thiserror::Error)]
25pub enum StoreError {
26 #[error("io: {0}")]
27 Io(#[from] io::Error),
28 #[error("malformed key: {0}")]
29 BadKey(String),
30 #[error("metadata decode failed: {0}")]
31 Meta(#[from] serde_json::Error),
32 #[error(
33 "integrity check failed for key {key}: stored payload hash {actual} != expected {expected}"
34 )]
35 Integrity {
36 key: String,
37 expected: String,
38 actual: String,
39 },
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
45pub struct Key(pub String);
46
47impl Key {
48 pub fn from_bytes(bytes: &[u8]) -> Self {
49 Key(blake3::hash(bytes).to_hex().to_string())
50 }
51
52 pub fn as_str(&self) -> &str {
53 &self.0
54 }
55
56 fn validate(&self) -> Result<(), StoreError> {
57 if self.0.len() != 64 || !self.0.chars().all(|c| c.is_ascii_hexdigit()) {
58 return Err(StoreError::BadKey(self.0.clone()));
59 }
60 Ok(())
61 }
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct PayloadMeta {
66 pub payload_hash: String,
70 pub bytes: u64,
72 pub tool_kind: String,
76 #[serde(default)]
83 pub file_roots: Vec<FileRootSerde>,
84 #[serde(default)]
93 pub upstream_keys: Vec<String>,
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97pub struct FileRootSerde {
98 pub path: String,
99 pub expected_hash: String,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub struct Payload {
104 pub bytes: Vec<u8>,
105 pub meta: PayloadMeta,
106}
107
108pub trait Store: Send + Sync {
115 fn persist_with_upstreams(
116 &self,
117 key: &Key,
118 bytes: &[u8],
119 tool_kind: &str,
120 file_roots: Vec<FileRootSerde>,
121 upstream_keys: Vec<String>,
122 ) -> Result<(), StoreError>;
123
124 fn lookup(&self, key: &Key) -> Result<Option<Payload>, StoreError>;
125
126 fn remove(&self, key: &Key) -> Result<(), StoreError>;
127
128 fn total_bytes(&self) -> Result<u64, StoreError>;
129
130 fn evict_to_cap(&self, cap_bytes: u64) -> Result<usize, StoreError>;
131
132 fn iter_meta(&self) -> Result<Vec<(Key, PayloadMeta)>, StoreError>;
133
134 fn contains(&self, key: &Key) -> bool;
135
136 fn persist(
137 &self,
138 key: &Key,
139 bytes: &[u8],
140 tool_kind: &str,
141 file_roots: Vec<FileRootSerde>,
142 ) -> Result<(), StoreError> {
143 self.persist_with_upstreams(key, bytes, tool_kind, file_roots, Vec::new())
144 }
145}
146
147#[derive(Debug)]
148pub struct FileStore {
149 root: PathBuf,
150}
151
152impl FileStore {
153 pub fn open(root: impl Into<PathBuf>) -> Result<Self, StoreError> {
154 let root = root.into();
155 fs::create_dir_all(&root)?;
156 Ok(Self { root })
157 }
158
159 pub fn root(&self) -> &Path {
160 &self.root
161 }
162
163 fn shard_dir(&self, key: &Key) -> PathBuf {
164 self.root.join(&key.0[..2])
165 }
166
167 fn payload_path(&self, key: &Key) -> PathBuf {
168 self.shard_dir(key).join(format!("{}.payload", key.0))
169 }
170
171 fn meta_path(&self, key: &Key) -> PathBuf {
172 self.shard_dir(key).join(format!("{}.meta.json", key.0))
173 }
174
175 pub fn persist(
179 &self,
180 key: &Key,
181 bytes: &[u8],
182 tool_kind: &str,
183 file_roots: Vec<FileRootSerde>,
184 ) -> Result<(), StoreError> {
185 self.persist_with_upstreams(key, bytes, tool_kind, file_roots, Vec::new())
186 }
187
188 pub fn persist_with_upstreams(
194 &self,
195 key: &Key,
196 bytes: &[u8],
197 tool_kind: &str,
198 file_roots: Vec<FileRootSerde>,
199 upstream_keys: Vec<String>,
200 ) -> Result<(), StoreError> {
201 key.validate()?;
202 fs::create_dir_all(self.shard_dir(key))?;
203
204 let payload_hash = blake3::hash(bytes).to_hex().to_string();
205 let meta = PayloadMeta {
206 payload_hash,
207 bytes: bytes.len() as u64,
208 tool_kind: tool_kind.to_string(),
209 file_roots,
210 upstream_keys,
211 };
212
213 write_atomic(&self.payload_path(key), bytes)?;
214 let meta_bytes = serde_json::to_vec(&meta)?;
215 write_atomic(&self.meta_path(key), &meta_bytes)?;
216 Ok(())
217 }
218
219 pub fn remove(&self, key: &Key) -> Result<(), StoreError> {
225 key.validate()?;
226 let pp = self.payload_path(key);
227 let mp = self.meta_path(key);
228 if pp.exists() {
229 fs::remove_file(&pp)?;
230 }
231 if mp.exists() {
232 fs::remove_file(&mp)?;
233 }
234 Ok(())
235 }
236
237 pub fn total_bytes(&self) -> Result<u64, StoreError> {
243 let mut total: u64 = 0;
244 let entries = match fs::read_dir(&self.root) {
245 Ok(e) => e,
246 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(0),
247 Err(e) => return Err(e.into()),
248 };
249 for shard in entries.flatten() {
250 let shard_path = shard.path();
251 if !shard_path.is_dir() {
252 continue;
253 }
254 for entry in fs::read_dir(&shard_path)?.flatten() {
255 #[allow(clippy::collapsible_if)]
256 if let Ok(md) = entry.metadata() {
257 if md.is_file() {
258 total = total.saturating_add(md.len());
259 }
260 }
261 }
262 }
263 Ok(total)
264 }
265
266 pub fn evict_to_cap(&self, cap_bytes: u64) -> Result<usize, StoreError> {
280 let mut current = self.total_bytes()?;
281 if current <= cap_bytes {
282 return Ok(0);
283 }
284 let entries = self.entry_stats()?;
285
286 let mut dropped = 0usize;
287 for (_, key, size) in entries {
288 if current <= cap_bytes {
289 break;
290 }
291 if self.remove(&key).is_ok() {
292 current = current.saturating_sub(size);
293 dropped += 1;
294 }
295 }
296 Ok(dropped)
297 }
298
299 pub fn entry_stats(&self) -> Result<Vec<(std::time::SystemTime, Key, u64)>, StoreError> {
308 let mut entries: Vec<(std::time::SystemTime, Key, u64)> = Vec::new();
309 let dir = match fs::read_dir(&self.root) {
310 Ok(e) => e,
311 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(entries),
312 Err(e) => return Err(e.into()),
313 };
314 for shard in dir.flatten() {
315 let shard_path = shard.path();
316 if !shard_path.is_dir() {
317 continue;
318 }
319 for entry in fs::read_dir(&shard_path)?.flatten() {
320 let p = entry.path();
321 let name = match p.file_name().and_then(|n| n.to_str()) {
322 Some(s) => s.to_string(),
323 None => continue,
324 };
325 if let Some(stem) = name.strip_suffix(".payload") {
326 let key = Key(stem.to_string());
327 if key.validate().is_err() {
328 continue;
329 }
330 let md = entry.metadata()?;
331 let payload_len = md.len();
332 let meta_len = fs::metadata(self.meta_path(&key))
333 .map(|m| m.len())
334 .unwrap_or(0);
335 let mtime = md.modified().unwrap_or(std::time::UNIX_EPOCH);
336 entries.push((mtime, key, payload_len + meta_len));
337 } else if let Some(stem) = name.strip_suffix(".meta.json") {
338 let key = Key(stem.to_string());
339 if key.validate().is_err() || self.payload_path(&key).exists() {
340 continue;
341 }
342 let md = entry.metadata()?;
343 let mtime = md.modified().unwrap_or(std::time::UNIX_EPOCH);
344 entries.push((mtime, key, md.len()));
345 }
346 }
347 }
348 entries.sort_by(|(ta, ka, _), (tb, kb, _)| ta.cmp(tb).then_with(|| ka.0.cmp(&kb.0)));
349 Ok(entries)
350 }
351
352 pub fn iter_meta(&self) -> Result<Vec<(Key, PayloadMeta)>, StoreError> {
358 let mut out = Vec::new();
359 let entries = match fs::read_dir(&self.root) {
360 Ok(e) => e,
361 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(out),
362 Err(e) => return Err(e.into()),
363 };
364 for shard in entries.flatten() {
365 let shard_path = shard.path();
366 if !shard_path.is_dir() {
367 continue;
368 }
369 for entry in fs::read_dir(&shard_path)?.flatten() {
370 let p = entry.path();
371 let name = match p.file_name().and_then(|n| n.to_str()) {
372 Some(s) if s.ends_with(".meta.json") => s.to_string(),
373 _ => continue,
374 };
375 let key_hex = name.trim_end_matches(".meta.json").to_string();
376 let key = Key(key_hex);
377 if key.validate().is_err() {
378 continue;
379 }
380 let meta: PayloadMeta = match fs::read(&p)
381 .ok()
382 .and_then(|b| serde_json::from_slice(&b).ok())
383 {
384 Some(m) => m,
385 None => continue,
386 };
387 out.push((key, meta));
388 }
389 }
390 Ok(out)
391 }
392
393 pub fn lookup(&self, key: &Key) -> Result<Option<Payload>, StoreError> {
399 key.validate()?;
400 let pp = self.payload_path(key);
401 let mp = self.meta_path(key);
402 let mut bytes = Vec::new();
407 match fs::File::open(&pp).and_then(|mut f| f.read_to_end(&mut bytes)) {
408 Ok(_) => {}
409 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
410 Err(e) => return Err(e.into()),
411 }
412 let meta_bytes = match fs::read(&mp) {
413 Ok(b) => b,
414 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
415 Err(e) => return Err(e.into()),
416 };
417 let meta: PayloadMeta = serde_json::from_slice(&meta_bytes)?;
418
419 let actual = blake3::hash(&bytes).to_hex().to_string();
420 if actual != meta.payload_hash {
421 return Err(StoreError::Integrity {
422 key: key.0.clone(),
423 expected: meta.payload_hash.clone(),
424 actual,
425 });
426 }
427 Ok(Some(Payload { bytes, meta }))
428 }
429
430 pub fn contains(&self, key: &Key) -> bool {
434 key.validate().is_ok() && self.payload_path(key).exists() && self.meta_path(key).exists()
435 }
436}
437
438impl Store for FileStore {
439 fn persist_with_upstreams(
440 &self,
441 key: &Key,
442 bytes: &[u8],
443 tool_kind: &str,
444 file_roots: Vec<FileRootSerde>,
445 upstream_keys: Vec<String>,
446 ) -> Result<(), StoreError> {
447 FileStore::persist_with_upstreams(self, key, bytes, tool_kind, file_roots, upstream_keys)
448 }
449
450 fn lookup(&self, key: &Key) -> Result<Option<Payload>, StoreError> {
451 FileStore::lookup(self, key)
452 }
453
454 fn remove(&self, key: &Key) -> Result<(), StoreError> {
455 FileStore::remove(self, key)
456 }
457
458 fn total_bytes(&self) -> Result<u64, StoreError> {
459 FileStore::total_bytes(self)
460 }
461
462 fn evict_to_cap(&self, cap_bytes: u64) -> Result<usize, StoreError> {
463 FileStore::evict_to_cap(self, cap_bytes)
464 }
465
466 fn iter_meta(&self) -> Result<Vec<(Key, PayloadMeta)>, StoreError> {
467 FileStore::iter_meta(self)
468 }
469
470 fn contains(&self, key: &Key) -> bool {
471 FileStore::contains(self, key)
472 }
473}
474
475fn write_atomic(target: &Path, bytes: &[u8]) -> io::Result<()> {
476 let parent = target
481 .parent()
482 .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "target has no parent"))?;
483 let mut guard = TempGuard::create_in(parent)?;
484 guard.file.write_all(bytes)?;
485 guard.file.flush()?;
486 guard.persist(target)
487}
488
489struct TempGuard {
496 path: PathBuf,
497 file: fs::File,
498 armed: bool,
499}
500
501impl TempGuard {
502 fn create_in(dir: &Path) -> io::Result<Self> {
503 use std::sync::atomic::{AtomicU64, Ordering};
504 static COUNTER: AtomicU64 = AtomicU64::new(0);
505 let n = COUNTER.fetch_add(1, Ordering::Relaxed);
506 let pid = std::process::id();
507 let path = dir.join(format!(".verdant-tmp-{pid}-{n}"));
508 let file = fs::OpenOptions::new()
509 .write(true)
510 .create_new(true)
511 .open(&path)?;
512 Ok(Self {
513 path,
514 file,
515 armed: true,
516 })
517 }
518
519 fn persist(mut self, target: &Path) -> io::Result<()> {
520 self.armed = false;
521 fs::rename(&self.path, target)
522 }
523}
524
525impl Drop for TempGuard {
526 fn drop(&mut self) {
527 if self.armed {
528 let _ = fs::remove_file(&self.path);
529 }
530 }
531}
532
533#[cfg(test)]
534mod tests {
535 use super::*;
536 use tempfile::TempDir;
537
538 fn store() -> (TempDir, FileStore) {
539 let dir = TempDir::new().unwrap();
540 let s = FileStore::open(dir.path().to_path_buf()).unwrap();
541 (dir, s)
542 }
543
544 #[test]
545 fn persist_then_lookup_roundtrip() {
546 let (_d, s) = store();
547 let k = Key::from_bytes(b"input-1");
548 s.persist(&k, b"hello world", "read", vec![]).unwrap();
549 let p = s.lookup(&k).unwrap().expect("must exist");
550 assert_eq!(p.bytes, b"hello world");
551 assert_eq!(p.meta.tool_kind, "read");
552 assert_eq!(p.meta.bytes, 11);
553 }
554
555 #[test]
556 fn lookup_missing_returns_none() {
557 let (_d, s) = store();
558 let k = Key::from_bytes(b"never-written");
559 assert!(s.lookup(&k).unwrap().is_none());
560 }
561
562 #[test]
563 fn integrity_violation_detected() {
564 let (_d, s) = store();
565 let k = Key::from_bytes(b"input-2");
566 s.persist(&k, b"trusted", "read", vec![]).unwrap();
567 let pp = s.root.join(&k.0[..2]).join(format!("{}.payload", k.0));
571 fs::write(&pp, b"tampered").unwrap();
572 let err = s.lookup(&k).expect_err("integrity must fire");
573 assert!(matches!(err, StoreError::Integrity { .. }));
574 }
575
576 #[test]
577 fn partial_write_only_meta_returns_none() {
578 let (_d, s) = store();
584 let k = Key::from_bytes(b"input-3");
585 fs::create_dir_all(s.root.join(&k.0[..2])).unwrap();
587 let mp = s.root.join(&k.0[..2]).join(format!("{}.meta.json", k.0));
588 fs::write(
589 &mp,
590 serde_json::to_vec(&PayloadMeta {
591 payload_hash: blake3::hash(b"orphan").to_hex().to_string(),
592 bytes: 6,
593 tool_kind: "read".into(),
594 file_roots: vec![],
595 upstream_keys: vec![],
596 })
597 .unwrap(),
598 )
599 .unwrap();
600 assert!(s.lookup(&k).unwrap().is_none());
601 }
602
603 #[test]
604 fn lookup_orphan_missing_payload_returns_none_not_err() {
605 let (_d, s) = store();
609 let k = Key::from_bytes(b"orphan-meta");
610 s.persist(&k, b"payload bytes", "read", vec![]).unwrap();
611 fs::remove_file(s.payload_path(&k)).unwrap();
612 assert!(
613 matches!(s.lookup(&k), Ok(None)),
614 "payload-missing/meta-present must be Ok(None)"
615 );
616 }
617
618 #[test]
619 fn lookup_orphan_missing_meta_returns_none_not_err() {
620 let (_d, s) = store();
621 let k = Key::from_bytes(b"orphan-payload");
622 s.persist(&k, b"payload bytes", "read", vec![]).unwrap();
623 fs::remove_file(s.meta_path(&k)).unwrap();
624 assert!(
625 matches!(s.lookup(&k), Ok(None)),
626 "meta-missing/payload-present must be Ok(None)"
627 );
628 }
629
630 #[test]
631 fn evict_reclaims_meta_only_orphans() {
632 let (_d, s) = store();
636 let healthy = Key::from_bytes(b"healthy");
637 s.persist(&healthy, &[b'h'; 4096], "read", vec![]).unwrap();
638
639 let orphan = Key::from_bytes(b"orphan-entry");
640 fs::create_dir_all(s.shard_dir(&orphan)).unwrap();
641 let orphan_meta = serde_json::to_vec(&PayloadMeta {
642 payload_hash: blake3::hash(b"gone").to_hex().to_string(),
643 bytes: 4,
644 tool_kind: "read".into(),
645 file_roots: vec![],
646 upstream_keys: vec![],
647 })
648 .unwrap();
649 fs::write(s.meta_path(&orphan), &orphan_meta).unwrap();
650
651 let dropped = s.evict_to_cap(0).unwrap();
652 assert!(dropped >= 2, "both healthy entry and orphan must drop");
653 assert!(
654 !s.meta_path(&orphan).exists(),
655 "meta-only orphan must be removed"
656 );
657 assert_eq!(s.total_bytes().unwrap(), 0);
658 }
659
660 #[test]
661 fn evict_order_is_deterministic_for_equal_mtimes() {
662 let fixed = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
669 let dir = TempDir::new().unwrap();
670 let s = FileStore::open(dir.path().to_path_buf()).unwrap();
671 let mut keys: Vec<Key> = (0..8)
672 .map(|i| Key::from_bytes(format!("dk{i}").as_bytes()))
673 .collect();
674 for k in &keys {
675 s.persist(k, &[b'x'; 4096], "read", vec![]).unwrap();
676 fs::File::options()
677 .write(true)
678 .open(s.payload_path(k))
679 .unwrap()
680 .set_modified(fixed)
681 .unwrap();
682 }
683 let before = s.total_bytes().unwrap();
684 let dropped = s.evict_to_cap(before / 2).unwrap();
685 assert!(dropped > 0, "eviction must drop at least one entry");
686
687 let evicted: std::collections::HashSet<String> = keys
688 .iter()
689 .filter(|k| s.lookup(k).unwrap().is_none())
690 .map(|k| k.0.clone())
691 .collect();
692 keys.sort_by(|a, b| a.0.cmp(&b.0));
693 let expected: std::collections::HashSet<String> = keys
694 .iter()
695 .take(evicted.len())
696 .map(|k| k.0.clone())
697 .collect();
698 assert_eq!(
699 evicted, expected,
700 "with equal mtimes the lowest store keys must be the deterministic victims"
701 );
702 }
703
704 #[test]
705 fn total_bytes_sums_payloads_and_meta() {
706 let (_d, s) = store();
707 assert_eq!(s.total_bytes().unwrap(), 0, "fresh store is zero bytes");
708 let k = Key::from_bytes(b"size-test");
709 s.persist(&k, &[b'x'; 1024], "read", vec![]).unwrap();
710 let bytes = s.total_bytes().unwrap();
711 assert!(bytes >= 1024, "payload alone is ≥1024, got {bytes}");
712 }
713
714 #[test]
715 fn evict_to_cap_drops_oldest_first() {
716 let (_d, s) = store();
717 let keys: Vec<Key> = (0..4)
720 .map(|i| Key::from_bytes(format!("k{i}").as_bytes()))
721 .collect();
722 for (i, k) in keys.iter().enumerate() {
723 s.persist(k, &[b'A' + i as u8; 4096], "read", vec![])
724 .unwrap();
725 std::thread::sleep(std::time::Duration::from_millis(20));
726 }
727 let before = s.total_bytes().unwrap();
728 assert!(before >= 4 * 4096);
729
730 let cap = before / 2;
732 let dropped = s.evict_to_cap(cap).unwrap();
733 assert!(dropped >= 1, "should drop at least one entry");
734 let after = s.total_bytes().unwrap();
735 assert!(
736 after <= cap,
737 "after eviction must fit cap; got {after}/{cap}"
738 );
739
740 assert!(s.lookup(&keys[0]).unwrap().is_none(), "oldest must evict");
743 assert!(s.lookup(&keys[3]).unwrap().is_some(), "newest must survive");
744 }
745
746 #[test]
747 fn evict_below_cap_is_noop() {
748 let (_d, s) = store();
749 let k = Key::from_bytes(b"small");
750 s.persist(&k, b"tiny", "read", vec![]).unwrap();
751 let dropped = s.evict_to_cap(u64::MAX).unwrap();
752 assert_eq!(dropped, 0);
753 assert!(s.lookup(&k).unwrap().is_some());
754 }
755
756 #[test]
757 fn malformed_key_rejected() {
758 let (_d, s) = store();
759 let bad = Key("not-hex".to_string());
760 assert!(s.persist(&bad, b"x", "read", vec![]).is_err());
761 assert!(s.lookup(&bad).is_err());
762 }
763
764 #[test]
765 fn shard_dirs_distribute_keys() {
766 let (_d, s) = store();
767 for i in 0..16u8 {
768 let k = Key::from_bytes(&[i, i, i]);
769 s.persist(&k, &[i], "read", vec![]).unwrap();
770 }
771 let mut shards = std::collections::HashSet::new();
776 for entry in fs::read_dir(s.root()).unwrap() {
777 let e = entry.unwrap();
778 if e.path().is_dir() {
779 shards.insert(e.file_name().to_string_lossy().to_string());
780 }
781 }
782 assert!(shards.len() >= 4, "shards = {shards:?}");
783 }
784
785 #[test]
786 fn contains_only_when_complete() {
787 let (_d, s) = store();
788 let k = Key::from_bytes(b"x");
789 assert!(!s.contains(&k));
790 s.persist(&k, b"y", "read", vec![]).unwrap();
791 assert!(s.contains(&k));
792 }
793}