diff --git a/crates/ledger-store/src/blob.rs b/crates/ledger-store/src/blob.rs new file mode 100644 index 0000000..fae1460 --- /dev/null +++ b/crates/ledger-store/src/blob.rs @@ -0,0 +1,78 @@ +//! The blob store: content-addressed, immutable bytes. +//! +//! Files are keyed by the SHA-256 of their exact bytes and laid out sharded +//! by hash prefix: `///`. Reads re-hash and compare, +//! so corruption is detected, never served. Writes are atomic (temp file + +//! rename) and fsync'd. Blobs are never modified or deleted by the +//! application. + +use crate::error::StoreError; +use crate::journal::sha256_hex; +use std::fs; +use std::path::{Path, PathBuf}; + +/// Content-addressed file store rooted at `root`. +pub struct BlobStore { + root: PathBuf, +} + +impl BlobStore { + /// Open (creating) the blob store at `root`. + pub fn open(root: &Path) -> Result { + fs::create_dir_all(root)?; + Ok(Self { + root: root.to_path_buf(), + }) + } + + fn path_for(&self, sha_hex: &str) -> PathBuf { + self.root + .join(&sha_hex[0..2]) + .join(&sha_hex[2..4]) + .join(sha_hex) + } + + /// Store bytes. Returns `(sha256_hex, deduplicated)`. Identical bytes + /// store once: the second put is a no-op that reports `deduplicated`. + pub fn put(&self, bytes: &[u8]) -> Result<(String, bool), StoreError> { + let sha = sha256_hex(bytes); + let path = self.path_for(&sha); + if path.exists() { + return Ok((sha, true)); + } + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + // Atomic write: temp file in the same directory, fsync, rename. + let tmp = path.with_extension("tmp"); + { + let mut f = fs::File::create(&tmp)?; + use std::io::Write; + f.write_all(bytes)?; + f.sync_all()?; + } + fs::rename(&tmp, &path)?; + // Fsync the directory so the rename itself is durable. + let dir = fs::File::open(path.parent().unwrap())?; + dir.sync_all()?; + Ok((sha, false)) + } + + /// Read bytes, verifying the SHA-256. A mismatch is corruption, and it + /// is reported — never served. + pub fn get(&self, sha_hex: &str) -> Result, StoreError> { + let sha_hex = sha_hex.to_lowercase(); + let bytes = fs::read(self.path_for(&sha_hex))?; + let actual = sha256_hex(&bytes); + if actual != sha_hex { + return Err(StoreError::CorruptBlob(sha_hex)); + } + Ok(bytes) + } + + /// True when the blob exists (content not verified; use [`Self::get`] + /// for a verifying read). + pub fn exists(&self, sha_hex: &str) -> bool { + self.path_for(&sha_hex.to_lowercase()).exists() + } +}