//! Reference store — 32-byte key → 32-byte value mapping (Phase 5b). //! //! Used to map a [`Fingerprint`](crate::cluster::build_cache::Fingerprint) //! to the [`BlobId`](crate::cluster::blob::BlobId) of its cached //! artifact. Deliberately a dumb primitive: no versioning, no CRDT //! semantics — Phase 3 will layer a richer metadata model on top, //! but every real cargo-cache lookup we need in Phase 5 is a single //! key → single value. //! //! # Layout //! //! ```text //! / //! refs//.ref — 32 raw bytes (the value) //! .tmp/ — atomic-rename staging //! ``` //! //! `` is the first two hex chars of the key, keeping directory //! fan-out bounded (256 entries per level). Writes go through //! tempfile + rename so a mid-write crash leaves either a complete //! file or nothing. use anyhow::{bail, Context, Result}; use std::path::{Path, PathBuf}; use tokio::io::AsyncWriteExt; /// A raw 32-byte key. Callers wrap semantically — the store itself /// treats keys as opaque bytes. pub type RefKey = [u8; 32]; /// A raw 32-byte value. pub type RefValue = [u8; 32]; /// Directory-backed reference store. #[derive(Debug, Clone)] pub struct RefStore { root: PathBuf, } impl RefStore { /// Open (create if missing) a ref store rooted at `root`. Creates /// `refs/` and `.tmp/` subdirs. Safe to call on an existing store. pub fn open(root: PathBuf) -> Result { std::fs::create_dir_all(root.join("refs")) .with_context(|| format!("creating refs dir under {}", root.display()))?; std::fs::create_dir_all(root.join(".tmp")) .with_context(|| format!("creating .tmp dir under {}", root.display()))?; Ok(Self { root }) } pub fn root(&self) -> &Path { &self.root } /// Look up a key. `None` when no ref has been set. Errors only on /// filesystem failures other than "not found". pub async fn get(&self, key: &RefKey) -> Result> { let path = self.ref_path(key); match tokio::fs::read(&path).await { Ok(bytes) => { if bytes.len() != 32 { bail!( "ref at {} has wrong length {} (expected 32)", path.display(), bytes.len() ); } let mut out = [0u8; 32]; out.copy_from_slice(&bytes); Ok(Some(out)) } Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), Err(e) => Err(anyhow::Error::from(e)) .with_context(|| format!("reading ref at {}", path.display())), } } /// Set a key → value mapping. Overwrites any existing value — /// callers who need last-writer-wins protection should implement /// it in the layer above (Phase 3 CRDT). Atomic on the filesystem: /// a mid-write crash leaves the previous value intact. pub async fn put(&self, key: &RefKey, value: &RefValue) -> Result<()> { let final_path = self.ref_path(key); if let Some(parent) = final_path.parent() { tokio::fs::create_dir_all(parent).await.with_context(|| { format!("creating ref bucket {}", parent.display()) })?; } self.atomic_write(&final_path, value).await } /// Delete a key. Returns `true` if a ref was removed, `false` if /// no ref existed for the key. pub async fn delete(&self, key: &RefKey) -> Result { let path = self.ref_path(key); match tokio::fs::remove_file(&path).await { Ok(()) => Ok(true), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), Err(e) => Err(anyhow::Error::from(e)), } } /// Whether a value is set for the given key. pub async fn contains(&self, key: &RefKey) -> Result { match tokio::fs::metadata(self.ref_path(key)).await { Ok(_) => Ok(true), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), Err(e) => Err(anyhow::Error::from(e)), } } fn ref_path(&self, key: &RefKey) -> PathBuf { let hex = hex32(key); self.root .join("refs") .join(&hex[..2]) .join(format!("{hex}.ref")) } async fn atomic_write(&self, final_path: &Path, bytes: &[u8]) -> Result<()> { let tmp_dir = self.root.join(".tmp"); let tmp_name = format!( "{}.{}", std::process::id(), RANDOM_SUFFIX.fetch_add(1, std::sync::atomic::Ordering::Relaxed) ); let tmp_path = tmp_dir.join(tmp_name); { let mut f = tokio::fs::File::create(&tmp_path).await.with_context(|| { format!("creating tmp ref {}", tmp_path.display()) })?; f.write_all(bytes).await?; f.sync_all().await?; } tokio::fs::rename(&tmp_path, final_path) .await .with_context(|| { format!( "renaming {} → {}", tmp_path.display(), final_path.display() ) })?; Ok(()) } } static RANDOM_SUFFIX: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); fn hex32(bytes: &[u8; 32]) -> String { let mut out = String::with_capacity(64); for b in bytes { out.push_str(&format!("{b:02x}")); } out } #[cfg(test)] mod tests { use super::*; fn open() -> (tempfile::TempDir, RefStore) { let tmp = tempfile::TempDir::new().unwrap(); let store = RefStore::open(tmp.path().to_path_buf()).unwrap(); (tmp, store) } #[tokio::test] async fn open_creates_layout() { let (tmp, store) = open(); assert!(tmp.path().join("refs").is_dir()); assert!(tmp.path().join(".tmp").is_dir()); assert_eq!(store.root(), tmp.path()); } #[tokio::test] async fn get_returns_none_for_missing() { let (_tmp, store) = open(); let key = [0u8; 32]; assert_eq!(store.get(&key).await.unwrap(), None); assert!(!store.contains(&key).await.unwrap()); } #[tokio::test] async fn put_and_get_round_trip() { let (_tmp, store) = open(); let key = [0x11u8; 32]; let value = [0x22u8; 32]; store.put(&key, &value).await.unwrap(); assert_eq!(store.get(&key).await.unwrap(), Some(value)); assert!(store.contains(&key).await.unwrap()); } #[tokio::test] async fn put_overwrites_prior_value() { let (_tmp, store) = open(); let key = [0xaau8; 32]; let v1 = [0x01u8; 32]; let v2 = [0x02u8; 32]; store.put(&key, &v1).await.unwrap(); store.put(&key, &v2).await.unwrap(); assert_eq!(store.get(&key).await.unwrap(), Some(v2)); } #[tokio::test] async fn delete_removes_ref_and_reports() { let (_tmp, store) = open(); let key = [0x55u8; 32]; let value = [0x66u8; 32]; store.put(&key, &value).await.unwrap(); assert!(store.delete(&key).await.unwrap()); assert_eq!(store.get(&key).await.unwrap(), None); // Second delete: returns false, not an error. assert!(!store.delete(&key).await.unwrap()); } #[tokio::test] async fn distinct_keys_produce_distinct_files() { // Verify the on-disk layout by inspection — different keys // land in different bucket dirs (proves the fan-out heuristic). let (tmp, store) = open(); let k1 = [0xffu8; 32]; let k2 = [0x00u8; 32]; store.put(&k1, &[0u8; 32]).await.unwrap(); store.put(&k2, &[0u8; 32]).await.unwrap(); assert!(tmp.path().join("refs/ff").exists()); assert!(tmp.path().join("refs/00").exists()); } #[tokio::test] async fn rejects_wrong_length_on_disk() { // Simulate corruption / hand-tampering: a file at the ref // path exists but isn't 32 bytes. Read must surface an error, // not silently return garbage. let (tmp, store) = open(); let key = [0x7fu8; 32]; let path = store.ref_path(&key); std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(&path, b"only 4b").unwrap(); let err = store.get(&key).await.unwrap_err().to_string(); assert!(err.contains("wrong length"), "unexpected: {err}"); } }