1use serde::{Deserialize, Serialize};
21use std::fs;
22use std::hash::RandomState;
23use std::hash::{BuildHasher, Hasher};
24use std::io::{self, Read as _, Write as _};
25use std::path::{Path, PathBuf};
26use std::sync::atomic::{AtomicU64, Ordering};
27use std::sync::OnceLock;
28use std::time::{SystemTime, UNIX_EPOCH};
29
30#[derive(Debug, thiserror::Error)]
31pub enum StoreError {
32 #[error("io: {0}")]
33 Io(#[from] io::Error),
34 #[error("malformed key: {0}")]
35 BadKey(String),
36 #[error("metadata decode failed: {0}")]
37 Meta(#[from] serde_json::Error),
38 #[error(
39 "integrity check failed for key {key}: stored payload hash {actual} != expected {expected}"
40 )]
41 Integrity {
42 key: String,
43 expected: String,
44 actual: String,
45 },
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
51pub struct Key(pub String);
52
53impl Key {
54 pub fn from_bytes(bytes: &[u8]) -> Self {
55 Key(blake3::hash(bytes).to_hex().to_string())
56 }
57
58 pub fn as_str(&self) -> &str {
59 &self.0
60 }
61
62 fn validate(&self) -> Result<(), StoreError> {
63 if self.0.len() != 64 || !self.0.chars().all(|c| c.is_ascii_hexdigit()) {
64 return Err(StoreError::BadKey(self.0.clone()));
65 }
66 Ok(())
67 }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
71pub struct PayloadMeta {
72 pub payload_hash: String,
76 pub bytes: u64,
78 pub tool_kind: String,
82 #[serde(default)]
89 pub file_roots: Vec<FileRootSerde>,
90 #[serde(default)]
99 pub upstream_keys: Vec<String>,
100}
101
102#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
103pub struct FileRootSerde {
104 pub path: String,
105 pub expected_hash: String,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct Payload {
110 pub bytes: Vec<u8>,
111 pub meta: PayloadMeta,
112}
113
114pub trait Store: Send + Sync {
121 fn persist_with_upstreams(
122 &self,
123 key: &Key,
124 bytes: &[u8],
125 tool_kind: &str,
126 file_roots: Vec<FileRootSerde>,
127 upstream_keys: Vec<String>,
128 ) -> Result<(), StoreError>;
129
130 fn lookup(&self, key: &Key) -> Result<Option<Payload>, StoreError>;
131
132 fn remove(&self, key: &Key) -> Result<(), StoreError>;
133
134 fn total_bytes(&self) -> Result<u64, StoreError>;
135
136 fn evict_to_cap(&self, cap_bytes: u64) -> Result<usize, StoreError>;
137
138 fn iter_meta(&self) -> Result<Vec<(Key, PayloadMeta)>, StoreError>;
139
140 fn contains(&self, key: &Key) -> bool;
141
142 fn persist(
143 &self,
144 key: &Key,
145 bytes: &[u8],
146 tool_kind: &str,
147 file_roots: Vec<FileRootSerde>,
148 ) -> Result<(), StoreError> {
149 self.persist_with_upstreams(key, bytes, tool_kind, file_roots, Vec::new())
150 }
151}
152
153#[derive(Debug)]
154pub struct FileStore {
155 root: PathBuf,
156}
157
158impl FileStore {
159 pub fn open(root: impl Into<PathBuf>) -> Result<Self, StoreError> {
160 let root = root.into();
161 fs::create_dir_all(&root)?;
162 Ok(Self { root })
163 }
164
165 pub fn root(&self) -> &Path {
166 &self.root
167 }
168
169 fn shard_dir(&self, key: &Key) -> PathBuf {
170 self.root.join(&key.0[..2])
171 }
172
173 fn payload_path(&self, key: &Key) -> PathBuf {
174 self.shard_dir(key).join(format!("{}.payload", key.0))
175 }
176
177 fn meta_path(&self, key: &Key) -> PathBuf {
178 self.shard_dir(key).join(format!("{}.meta.json", key.0))
179 }
180
181 pub fn persist(
185 &self,
186 key: &Key,
187 bytes: &[u8],
188 tool_kind: &str,
189 file_roots: Vec<FileRootSerde>,
190 ) -> Result<(), StoreError> {
191 self.persist_with_upstreams(key, bytes, tool_kind, file_roots, Vec::new())
192 }
193
194 pub fn persist_with_upstreams(
200 &self,
201 key: &Key,
202 bytes: &[u8],
203 tool_kind: &str,
204 file_roots: Vec<FileRootSerde>,
205 upstream_keys: Vec<String>,
206 ) -> Result<(), StoreError> {
207 key.validate()?;
208 let shard = self.shard_dir(key);
209 fs::create_dir_all(&shard)?;
210
211 let payload_hash = blake3::hash(bytes).to_hex().to_string();
212 let meta = PayloadMeta {
213 payload_hash,
214 bytes: bytes.len() as u64,
215 tool_kind: tool_kind.to_string(),
216 file_roots,
217 upstream_keys,
218 };
219 let meta_bytes = serde_json::to_vec(&meta)?;
220
221 let payload_tmp = TempGuard::write_in(&shard, bytes)?;
222 let meta_tmp = TempGuard::write_in(&shard, &meta_bytes)?;
223 let _lock = lock_shard(&shard, LockMode::Exclusive)?
224 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "shard directory vanished"))?;
225 payload_tmp.persist(&self.payload_path(key))?;
226 meta_tmp.persist(&self.meta_path(key))?;
227 Ok(())
228 }
229
230 pub fn remove(&self, key: &Key) -> Result<(), StoreError> {
236 key.validate()?;
237 let Some(_lock) = lock_shard(&self.shard_dir(key), LockMode::Exclusive)? else {
238 return Ok(());
239 };
240 remove_if_present(&self.payload_path(key))?;
241 remove_if_present(&self.meta_path(key))?;
242 Ok(())
243 }
244
245 pub fn total_bytes(&self) -> Result<u64, StoreError> {
251 let mut total: u64 = 0;
252 let entries = match fs::read_dir(&self.root) {
253 Ok(e) => e,
254 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(0),
255 Err(e) => return Err(e.into()),
256 };
257 for shard in entries.flatten() {
258 let shard_path = shard.path();
259 if !shard_path.is_dir() {
260 continue;
261 }
262 for entry in fs::read_dir(&shard_path)?.flatten() {
263 #[allow(clippy::collapsible_if)]
264 if let Ok(md) = entry.metadata() {
265 if md.is_file() {
266 total = total.saturating_add(md.len());
267 }
268 }
269 }
270 }
271 Ok(total)
272 }
273
274 pub fn evict_to_cap(&self, cap_bytes: u64) -> Result<usize, StoreError> {
288 let mut current = self.total_bytes()?;
289 if current <= cap_bytes {
290 return Ok(0);
291 }
292 let entries = self.entry_stats()?;
293
294 let mut dropped = 0usize;
295 for (_, key, size) in entries {
296 if current <= cap_bytes {
297 break;
298 }
299 if self.remove(&key).is_ok() {
300 current = current.saturating_sub(size);
301 dropped += 1;
302 }
303 }
304 Ok(dropped)
305 }
306
307 pub fn entry_stats(&self) -> Result<Vec<(std::time::SystemTime, Key, u64)>, StoreError> {
316 let mut entries: Vec<(std::time::SystemTime, Key, u64)> = Vec::new();
317 let dir = match fs::read_dir(&self.root) {
318 Ok(e) => e,
319 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(entries),
320 Err(e) => return Err(e.into()),
321 };
322 for shard in dir.flatten() {
323 let shard_path = shard.path();
324 if !shard_path.is_dir() {
325 continue;
326 }
327 for entry in fs::read_dir(&shard_path)?.flatten() {
328 let p = entry.path();
329 let name = match p.file_name().and_then(|n| n.to_str()) {
330 Some(s) => s.to_string(),
331 None => continue,
332 };
333 if let Some(stem) = name.strip_suffix(".payload") {
334 let key = Key(stem.to_string());
335 if key.validate().is_err() {
336 continue;
337 }
338 let md = entry.metadata()?;
339 let payload_len = md.len();
340 let meta_len = fs::metadata(self.meta_path(&key))
341 .map(|m| m.len())
342 .unwrap_or(0);
343 let mtime = md.modified().unwrap_or(std::time::UNIX_EPOCH);
344 entries.push((mtime, key, payload_len + meta_len));
345 } else if let Some(stem) = name.strip_suffix(".meta.json") {
346 let key = Key(stem.to_string());
347 if key.validate().is_err() || self.payload_path(&key).exists() {
348 continue;
349 }
350 let md = entry.metadata()?;
351 let mtime = md.modified().unwrap_or(std::time::UNIX_EPOCH);
352 entries.push((mtime, key, md.len()));
353 }
354 }
355 }
356 entries.sort_by(|(ta, ka, _), (tb, kb, _)| ta.cmp(tb).then_with(|| ka.0.cmp(&kb.0)));
357 Ok(entries)
358 }
359
360 pub fn iter_meta(&self) -> Result<Vec<(Key, PayloadMeta)>, StoreError> {
366 let mut out = Vec::new();
367 let entries = match fs::read_dir(&self.root) {
368 Ok(e) => e,
369 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(out),
370 Err(e) => return Err(e.into()),
371 };
372 for shard in entries.flatten() {
373 let shard_path = shard.path();
374 if !shard_path.is_dir() {
375 continue;
376 }
377 for entry in fs::read_dir(&shard_path)?.flatten() {
378 let p = entry.path();
379 let name = match p.file_name().and_then(|n| n.to_str()) {
380 Some(s) if s.ends_with(".meta.json") => s.to_string(),
381 _ => continue,
382 };
383 let key_hex = name.trim_end_matches(".meta.json").to_string();
384 let key = Key(key_hex);
385 if key.validate().is_err() {
386 continue;
387 }
388 let meta: PayloadMeta = match fs::read(&p)
389 .ok()
390 .and_then(|b| serde_json::from_slice(&b).ok())
391 {
392 Some(m) => m,
393 None => continue,
394 };
395 out.push((key, meta));
396 }
397 }
398 Ok(out)
399 }
400
401 pub fn lookup(&self, key: &Key) -> Result<Option<Payload>, StoreError> {
407 key.validate()?;
408 let Some(_lock) = lock_shard(&self.shard_dir(key), LockMode::Shared)? else {
409 return Ok(None);
410 };
411 let pp = self.payload_path(key);
412 let mp = self.meta_path(key);
413 let mut bytes = Vec::new();
417 match fs::File::open(&pp).and_then(|mut f| f.read_to_end(&mut bytes)) {
418 Ok(_) => {}
419 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
420 Err(e) => return Err(e.into()),
421 }
422 let meta_bytes = match fs::read(&mp) {
423 Ok(b) => b,
424 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
425 Err(e) => return Err(e.into()),
426 };
427 let meta: PayloadMeta = serde_json::from_slice(&meta_bytes)?;
428
429 let actual = blake3::hash(&bytes).to_hex().to_string();
430 if actual != meta.payload_hash {
431 return Err(StoreError::Integrity {
432 key: key.0.clone(),
433 expected: meta.payload_hash.clone(),
434 actual,
435 });
436 }
437 Ok(Some(Payload { bytes, meta }))
438 }
439
440 pub fn contains(&self, key: &Key) -> bool {
444 key.validate().is_ok() && self.payload_path(key).exists() && self.meta_path(key).exists()
445 }
446}
447
448impl Store for FileStore {
449 fn persist_with_upstreams(
450 &self,
451 key: &Key,
452 bytes: &[u8],
453 tool_kind: &str,
454 file_roots: Vec<FileRootSerde>,
455 upstream_keys: Vec<String>,
456 ) -> Result<(), StoreError> {
457 FileStore::persist_with_upstreams(self, key, bytes, tool_kind, file_roots, upstream_keys)
458 }
459
460 fn lookup(&self, key: &Key) -> Result<Option<Payload>, StoreError> {
461 FileStore::lookup(self, key)
462 }
463
464 fn remove(&self, key: &Key) -> Result<(), StoreError> {
465 FileStore::remove(self, key)
466 }
467
468 fn total_bytes(&self) -> Result<u64, StoreError> {
469 FileStore::total_bytes(self)
470 }
471
472 fn evict_to_cap(&self, cap_bytes: u64) -> Result<usize, StoreError> {
473 FileStore::evict_to_cap(self, cap_bytes)
474 }
475
476 fn iter_meta(&self) -> Result<Vec<(Key, PayloadMeta)>, StoreError> {
477 FileStore::iter_meta(self)
478 }
479
480 fn contains(&self, key: &Key) -> bool {
481 FileStore::contains(self, key)
482 }
483}
484
485const SHARD_LOCK: &str = ".lock";
491
492enum LockMode {
493 Shared,
494 Exclusive,
495}
496
497fn lock_shard(shard: &Path, mode: LockMode) -> io::Result<Option<fs::File>> {
503 let path = shard.join(SHARD_LOCK);
504 let existing = match mode {
505 LockMode::Shared => fs::File::open(&path),
506 LockMode::Exclusive => fs::OpenOptions::new().write(true).open(&path),
507 };
508 let file = match existing {
509 Ok(f) => f,
510 Err(e) if e.kind() == io::ErrorKind::NotFound => match fs::OpenOptions::new()
511 .write(true)
512 .create(true)
513 .truncate(false)
514 .open(&path)
515 {
516 Ok(f) => f,
517 Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(None),
518 Err(e) => return Err(e),
519 },
520 Err(e) => return Err(e),
521 };
522 match mode {
523 LockMode::Shared => file.lock_shared()?,
524 LockMode::Exclusive => file.lock()?,
525 }
526 Ok(Some(file))
527}
528
529fn remove_if_present(path: &Path) -> io::Result<()> {
530 match fs::remove_file(path) {
531 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()),
532 other => other,
533 }
534}
535
536const TEMP_SEED_ENV: &str = "VERDANT_TEST_TEMP_SEED";
538const TEMP_NAME_ATTEMPTS: u64 = 8;
539
540fn temp_name_component(attempt: u64) -> u64 {
543 static SEED: OnceLock<Option<u64>> = OnceLock::new();
544 let seed = *SEED.get_or_init(|| {
545 std::env::var(TEMP_SEED_ENV)
546 .ok()
547 .and_then(|s| s.parse().ok())
548 });
549 if let Some(seed) = seed {
550 return seed.wrapping_add(attempt);
551 }
552 static COUNTER: AtomicU64 = AtomicU64::new(0);
553 let nanos = SystemTime::now()
554 .duration_since(UNIX_EPOCH)
555 .map(|d| d.as_nanos())
556 .unwrap_or(0);
557 let mut hasher = RandomState::new().build_hasher();
558 hasher.write_u64(COUNTER.fetch_add(1, Ordering::Relaxed));
559 hasher.write_u128(nanos);
560 hasher.finish()
561}
562
563struct TempGuard {
566 path: PathBuf,
567 file: fs::File,
568 armed: bool,
569}
570
571impl TempGuard {
572 fn create_in(dir: &Path) -> io::Result<Self> {
573 let pid = std::process::id();
574 for attempt in 0..TEMP_NAME_ATTEMPTS {
575 let path = dir.join(format!(
576 ".verdant-tmp-{pid}-{:016x}",
577 temp_name_component(attempt)
578 ));
579 match fs::OpenOptions::new()
580 .write(true)
581 .create_new(true)
582 .open(&path)
583 {
584 Ok(file) => {
585 return Ok(Self {
586 path,
587 file,
588 armed: true,
589 })
590 }
591 Err(e) if e.kind() == io::ErrorKind::AlreadyExists => continue,
592 Err(e) => return Err(e),
593 }
594 }
595 Err(io::Error::new(
596 io::ErrorKind::AlreadyExists,
597 format!(
598 "no free temp name in {} after {TEMP_NAME_ATTEMPTS} attempts",
599 dir.display()
600 ),
601 ))
602 }
603
604 fn write_in(dir: &Path, bytes: &[u8]) -> io::Result<Self> {
607 let mut guard = Self::create_in(dir)?;
608 guard.file.write_all(bytes)?;
609 guard.file.flush()?;
610 Ok(guard)
611 }
612
613 fn persist(mut self, target: &Path) -> io::Result<()> {
614 fs::rename(&self.path, target)?;
615 self.armed = false;
616 Ok(())
617 }
618}
619
620impl Drop for TempGuard {
621 fn drop(&mut self) {
622 if self.armed {
623 let _ = fs::remove_file(&self.path);
624 }
625 }
626}
627
628#[cfg(test)]
629mod tests {
630 use super::*;
631 use tempfile::TempDir;
632
633 fn store() -> (TempDir, FileStore) {
634 let dir = TempDir::new().unwrap();
635 let s = FileStore::open(dir.path().to_path_buf()).unwrap();
636 (dir, s)
637 }
638
639 #[test]
640 fn persist_then_lookup_roundtrip() {
641 let (_d, s) = store();
642 let k = Key::from_bytes(b"input-1");
643 s.persist(&k, b"hello world", "read", vec![]).unwrap();
644 let p = s.lookup(&k).unwrap().expect("must exist");
645 assert_eq!(p.bytes, b"hello world");
646 assert_eq!(p.meta.tool_kind, "read");
647 assert_eq!(p.meta.bytes, 11);
648 }
649
650 #[test]
651 fn lookup_missing_returns_none() {
652 let (_d, s) = store();
653 let k = Key::from_bytes(b"never-written");
654 assert!(s.lookup(&k).unwrap().is_none());
655 }
656
657 #[test]
658 fn integrity_violation_detected() {
659 let (_d, s) = store();
660 let k = Key::from_bytes(b"input-2");
661 s.persist(&k, b"trusted", "read", vec![]).unwrap();
662 let pp = s.root.join(&k.0[..2]).join(format!("{}.payload", k.0));
666 fs::write(&pp, b"tampered").unwrap();
667 let err = s.lookup(&k).expect_err("integrity must fire");
668 assert!(matches!(err, StoreError::Integrity { .. }));
669 }
670
671 #[test]
672 fn partial_write_only_meta_returns_none() {
673 let (_d, s) = store();
679 let k = Key::from_bytes(b"input-3");
680 fs::create_dir_all(s.root.join(&k.0[..2])).unwrap();
682 let mp = s.root.join(&k.0[..2]).join(format!("{}.meta.json", k.0));
683 fs::write(
684 &mp,
685 serde_json::to_vec(&PayloadMeta {
686 payload_hash: blake3::hash(b"orphan").to_hex().to_string(),
687 bytes: 6,
688 tool_kind: "read".into(),
689 file_roots: vec![],
690 upstream_keys: vec![],
691 })
692 .unwrap(),
693 )
694 .unwrap();
695 assert!(s.lookup(&k).unwrap().is_none());
696 }
697
698 #[test]
699 fn lookup_orphan_missing_payload_returns_none_not_err() {
700 let (_d, s) = store();
704 let k = Key::from_bytes(b"orphan-meta");
705 s.persist(&k, b"payload bytes", "read", vec![]).unwrap();
706 fs::remove_file(s.payload_path(&k)).unwrap();
707 assert!(
708 matches!(s.lookup(&k), Ok(None)),
709 "payload-missing/meta-present must be Ok(None)"
710 );
711 }
712
713 #[test]
714 fn lookup_orphan_missing_meta_returns_none_not_err() {
715 let (_d, s) = store();
716 let k = Key::from_bytes(b"orphan-payload");
717 s.persist(&k, b"payload bytes", "read", vec![]).unwrap();
718 fs::remove_file(s.meta_path(&k)).unwrap();
719 assert!(
720 matches!(s.lookup(&k), Ok(None)),
721 "meta-missing/payload-present must be Ok(None)"
722 );
723 }
724
725 #[test]
726 fn evict_reclaims_meta_only_orphans() {
727 let (_d, s) = store();
731 let healthy = Key::from_bytes(b"healthy");
732 s.persist(&healthy, &[b'h'; 4096], "read", vec![]).unwrap();
733
734 let orphan = Key::from_bytes(b"orphan-entry");
735 fs::create_dir_all(s.shard_dir(&orphan)).unwrap();
736 let orphan_meta = serde_json::to_vec(&PayloadMeta {
737 payload_hash: blake3::hash(b"gone").to_hex().to_string(),
738 bytes: 4,
739 tool_kind: "read".into(),
740 file_roots: vec![],
741 upstream_keys: vec![],
742 })
743 .unwrap();
744 fs::write(s.meta_path(&orphan), &orphan_meta).unwrap();
745
746 let dropped = s.evict_to_cap(0).unwrap();
747 assert!(dropped >= 2, "both healthy entry and orphan must drop");
748 assert!(
749 !s.meta_path(&orphan).exists(),
750 "meta-only orphan must be removed"
751 );
752 assert_eq!(s.total_bytes().unwrap(), 0);
753 }
754
755 #[test]
756 fn evict_order_is_deterministic_for_equal_mtimes() {
757 let fixed = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000);
764 let dir = TempDir::new().unwrap();
765 let s = FileStore::open(dir.path().to_path_buf()).unwrap();
766 let mut keys: Vec<Key> = (0..8)
767 .map(|i| Key::from_bytes(format!("dk{i}").as_bytes()))
768 .collect();
769 for k in &keys {
770 s.persist(k, &[b'x'; 4096], "read", vec![]).unwrap();
771 fs::File::options()
772 .write(true)
773 .open(s.payload_path(k))
774 .unwrap()
775 .set_modified(fixed)
776 .unwrap();
777 }
778 let before = s.total_bytes().unwrap();
779 let dropped = s.evict_to_cap(before / 2).unwrap();
780 assert!(dropped > 0, "eviction must drop at least one entry");
781
782 let evicted: std::collections::HashSet<String> = keys
783 .iter()
784 .filter(|k| s.lookup(k).unwrap().is_none())
785 .map(|k| k.0.clone())
786 .collect();
787 keys.sort_by(|a, b| a.0.cmp(&b.0));
788 let expected: std::collections::HashSet<String> = keys
789 .iter()
790 .take(evicted.len())
791 .map(|k| k.0.clone())
792 .collect();
793 assert_eq!(
794 evicted, expected,
795 "with equal mtimes the lowest store keys must be the deterministic victims"
796 );
797 }
798
799 #[test]
800 fn total_bytes_sums_payloads_and_meta() {
801 let (_d, s) = store();
802 assert_eq!(s.total_bytes().unwrap(), 0, "fresh store is zero bytes");
803 let k = Key::from_bytes(b"size-test");
804 s.persist(&k, &[b'x'; 1024], "read", vec![]).unwrap();
805 let bytes = s.total_bytes().unwrap();
806 assert!(bytes >= 1024, "payload alone is ≥1024, got {bytes}");
807 }
808
809 #[test]
810 fn evict_to_cap_drops_oldest_first() {
811 let (_d, s) = store();
812 let keys: Vec<Key> = (0..4)
815 .map(|i| Key::from_bytes(format!("k{i}").as_bytes()))
816 .collect();
817 for (i, k) in keys.iter().enumerate() {
818 s.persist(k, &[b'A' + i as u8; 4096], "read", vec![])
819 .unwrap();
820 std::thread::sleep(std::time::Duration::from_millis(20));
821 }
822 let before = s.total_bytes().unwrap();
823 assert!(before >= 4 * 4096);
824
825 let cap = before / 2;
827 let dropped = s.evict_to_cap(cap).unwrap();
828 assert!(dropped >= 1, "should drop at least one entry");
829 let after = s.total_bytes().unwrap();
830 assert!(
831 after <= cap,
832 "after eviction must fit cap; got {after}/{cap}"
833 );
834
835 assert!(s.lookup(&keys[0]).unwrap().is_none(), "oldest must evict");
838 assert!(s.lookup(&keys[3]).unwrap().is_some(), "newest must survive");
839 }
840
841 #[test]
842 fn evict_below_cap_is_noop() {
843 let (_d, s) = store();
844 let k = Key::from_bytes(b"small");
845 s.persist(&k, b"tiny", "read", vec![]).unwrap();
846 let dropped = s.evict_to_cap(u64::MAX).unwrap();
847 assert_eq!(dropped, 0);
848 assert!(s.lookup(&k).unwrap().is_some());
849 }
850
851 #[test]
852 fn malformed_key_rejected() {
853 let (_d, s) = store();
854 let bad = Key("not-hex".to_string());
855 assert!(s.persist(&bad, b"x", "read", vec![]).is_err());
856 assert!(s.lookup(&bad).is_err());
857 }
858
859 #[test]
860 fn shard_dirs_distribute_keys() {
861 let (_d, s) = store();
862 for i in 0..16u8 {
863 let k = Key::from_bytes(&[i, i, i]);
864 s.persist(&k, &[i], "read", vec![]).unwrap();
865 }
866 let mut shards = std::collections::HashSet::new();
871 for entry in fs::read_dir(s.root()).unwrap() {
872 let e = entry.unwrap();
873 if e.path().is_dir() {
874 shards.insert(e.file_name().to_string_lossy().to_string());
875 }
876 }
877 assert!(shards.len() >= 4, "shards = {shards:?}");
878 }
879
880 fn hold_shard_lock(s: &FileStore, k: &Key) -> fs::File {
881 let lock = fs::File::open(s.shard_dir(k).join(SHARD_LOCK)).unwrap();
882 lock.lock().unwrap();
883 lock
884 }
885
886 #[test]
887 fn persist_waits_for_the_shard_lock_holder() {
888 let (_d, s) = store();
889 let s = std::sync::Arc::new(s);
890 let k = Key::from_bytes(b"locked");
891 s.persist(&k, b"first", "read", vec![]).unwrap();
892 let held = hold_shard_lock(&s, &k);
893
894 let (tx, rx) = std::sync::mpsc::channel();
895 let writer = {
896 let (s, k) = (s.clone(), k.clone());
897 std::thread::spawn(move || {
898 s.persist(&k, b"second", "read", vec![]).unwrap();
899 tx.send(()).unwrap();
900 })
901 };
902 assert!(
903 rx.recv_timeout(std::time::Duration::from_millis(300))
904 .is_err(),
905 "persist must wait while another descriptor holds the shard lock"
906 );
907 assert_eq!(fs::read(s.payload_path(&k)).unwrap(), b"first");
908 drop(held);
909 rx.recv_timeout(std::time::Duration::from_secs(10))
910 .expect("persist must finish once the lock is released");
911 writer.join().unwrap();
912 assert_eq!(s.lookup(&k).unwrap().unwrap().bytes, b"second");
913 }
914
915 #[test]
916 fn shard_lock_is_free_when_each_call_returns() {
917 let (_d, s) = store();
918 let k = Key::from_bytes(b"innermost");
919 let try_exclusive = |s: &FileStore| {
920 fs::File::open(s.shard_dir(&k).join(SHARD_LOCK))
921 .unwrap()
922 .try_lock()
923 .expect("no store call may return holding its shard lock");
924 };
925 s.persist(&k, b"x", "read", vec![]).unwrap();
926 try_exclusive(&s);
927 assert!(s.lookup(&k).unwrap().is_some());
928 try_exclusive(&s);
929 s.remove(&k).unwrap();
930 try_exclusive(&s);
931 assert!(s.lookup(&k).unwrap().is_none());
932 }
933
934 #[test]
935 fn lookup_and_remove_do_not_create_missing_shards() {
936 let (_d, s) = store();
937 let k = Key::from_bytes(b"absent");
938 assert!(s.lookup(&k).unwrap().is_none());
939 s.remove(&k).unwrap();
940 assert!(!s.shard_dir(&k).exists());
941 }
942
943 #[test]
944 fn lookup_needs_no_write_access_to_the_lock_file() {
945 use std::os::unix::fs::PermissionsExt as _;
946 let (_d, s) = store();
947 let k = Key::from_bytes(b"read-only-lock");
948 s.persist(&k, b"payload", "read", vec![]).unwrap();
949 let lock = s.shard_dir(&k).join(SHARD_LOCK);
950 fs::set_permissions(&lock, fs::Permissions::from_mode(0o444)).unwrap();
951 assert_eq!(s.lookup(&k).unwrap().unwrap().bytes, b"payload");
952 }
953
954 #[test]
955 fn lock_file_is_not_an_entry() {
956 let (_d, s) = store();
957 let k = Key::from_bytes(b"with-lock");
958 s.persist(&k, b"payload", "read", vec![]).unwrap();
959 assert!(s.shard_dir(&k).join(SHARD_LOCK).exists());
960 assert_eq!(s.iter_meta().unwrap().len(), 1);
961 assert_eq!(s.entry_stats().unwrap().len(), 1);
962 s.remove(&k).unwrap();
963 assert_eq!(s.total_bytes().unwrap(), 0);
964 }
965
966 #[test]
967 fn contains_only_when_complete() {
968 let (_d, s) = store();
969 let k = Key::from_bytes(b"x");
970 assert!(!s.contains(&k));
971 s.persist(&k, b"y", "read", vec![]).unwrap();
972 assert!(s.contains(&k));
973 }
974}