Skip to main content

hh_core/
blob.rs

1//! Content-addressed, zstd-compressed blob store (SRS §4.1, FR-1.4, NFR-4).
2//!
3//! Blobs are keyed by BLAKE3 hash and stored at `blobs/<hash[0..2]>/<hash>.zst`.
4//! The `blobs` table holds a refcount so that deleting a session can garbage-
5//! collect blobs no longer referenced by any event. Files and directories are
6//! created with `0600`/`0700` permissions per NFR-4.
7
8use crate::error::{BlobError, Result, StorageError};
9use std::fs;
10use std::path::{Path, PathBuf};
11use zstd::stream::{decode_all, encode_all};
12
13/// Compression level for zstd. 3 is fast and compact; Halfhand stores file
14/// snapshots and MCP payloads, not archives — level 3 is a good default.
15const ZSTD_LEVEL: i32 = 3;
16
17/// BLAKE3 hex length (64 chars).
18const HASH_HEX_LEN: usize = 64;
19
20/// True if `hash` is a well-formed BLAKE3 hex digest: exactly
21/// [`HASH_HEX_LEN`] ASCII hex characters. Rejected up front so a malformed
22/// hash (e.g. from a corrupted DB row) can never reach [`BlobStore::blob_path`]'s
23/// byte slice — a multi-byte UTF-8 character at byte offset 2 would panic
24/// there — and can never smuggle a path separator or `..` into the on-disk
25/// path via `{hash}.zst`.
26fn is_valid_hash(hash: &str) -> bool {
27    hash.len() == HASH_HEX_LEN && hash.bytes().all(|b| b.is_ascii_hexdigit())
28}
29
30/// A content-addressed blob store backed by a directory on disk and a
31/// `blobs` table in SQLite for refcounting.
32///
33/// When constructed via [`BlobStore::with_redactor`], UTF-8 content is passed
34/// through the redaction engine *before hashing and compression* — the
35/// record-time enforcement point of docs/redaction-design.md. Because the
36/// content is redacted before hashing, the returned hash is the hash of what
37/// is actually stored and content-addressing stays internally consistent.
38pub struct BlobStore {
39    blobs_dir: PathBuf,
40    redactor: Option<std::sync::Arc<crate::redact::Detectors>>,
41}
42
43/// The outcome of storing a blob: the hash and the new refcount.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct PutOutcome {
46    /// BLAKE3 hex of the stored content.
47    pub hash: String,
48    /// The size of the uncompressed content in bytes.
49    pub size: u64,
50}
51
52impl BlobStore {
53    /// Create a new [`BlobStore`] rooted at `blobs_dir`. The directory itself
54    /// is created lazily on first write with `0700` permissions.
55    pub fn new(blobs_dir: PathBuf) -> Self {
56        Self {
57            blobs_dir,
58            redactor: None,
59        }
60    }
61
62    /// Create a [`BlobStore`] whose [`Self::put`] redacts UTF-8 content
63    /// before storing it (record-time redaction, `[redaction] at_record`).
64    pub fn with_redactor(
65        blobs_dir: PathBuf,
66        redactor: std::sync::Arc<crate::redact::Detectors>,
67    ) -> Self {
68        Self {
69            blobs_dir,
70            redactor: Some(redactor),
71        }
72    }
73
74    /// Return the on-disk path for a given hash.
75    pub(crate) fn blob_path(&self, hash: &str) -> PathBuf {
76        let prefix = &hash[..2];
77        self.blobs_dir.join(prefix).join(format!("{hash}.zst"))
78    }
79
80    /// Compute the BLAKE3 hex hash of `content` without storing it (FR-1.4:
81    /// binary files record hashes even when content storage is skipped).
82    /// Centralized here so the recorder does not depend on `blake3` directly.
83    #[must_use]
84    pub fn hash(content: &[u8]) -> String {
85        blake3::hash(content).to_hex().to_string()
86    }
87
88    /// Store `content`, compressed with zstd, keyed by its BLAKE3 hash. Returns
89    /// the hash *of what was stored*. If the blob already exists on disk,
90    /// content is not rewritten (content-addressing makes it identical). The
91    /// `blobs` table refcount is bumped by the store's writer when an event
92    /// references this hash.
93    ///
94    /// With a redactor attached ([`Self::with_redactor`]), UTF-8 content is
95    /// redacted first; the returned hash/size then describe the redacted
96    /// content, so callers must reference the outcome's hash, never a hash
97    /// they computed over the original bytes.
98    pub fn put(&self, content: &[u8]) -> Result<PutOutcome> {
99        if let Some(redactor) = &self.redactor {
100            if let Some(redacted) = redactor.redact_bytes(content) {
101                return self.put_raw(&redacted.bytes);
102            }
103        }
104        self.put_raw(content)
105    }
106
107    /// The unconditional store path behind [`Self::put`] (no redaction).
108    fn put_raw(&self, content: &[u8]) -> Result<PutOutcome> {
109        let hash = blake3::hash(content).to_hex().to_string();
110        let size = u64::try_from(content.len()).unwrap_or(u64::MAX);
111        let path = self.blob_path(&hash);
112        if !path.exists() {
113            Self::write_blob(&path, content)?;
114        }
115        Ok(PutOutcome { hash, size })
116    }
117
118    /// Read a blob by hash, decompress it, and return the bytes.
119    pub fn get(&self, hash: &str) -> Result<Vec<u8>> {
120        if !is_valid_hash(hash) {
121            return Err(BlobError::HashMismatch {
122                expected: format!("{HASH_HEX_LEN}-char blake3 hex"),
123                actual: hash.to_string(),
124            }
125            .into());
126        }
127        let path = self.blob_path(hash);
128        let compressed = match fs::read(&path) {
129            Ok(b) => b,
130            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
131                return Err(StorageError::MissingBlob(hash.to_string()).into());
132            }
133            Err(e) => {
134                return Err(BlobError::Io { path, source: e }.into());
135            }
136        };
137        let decompressed =
138            decode_all(&compressed[..]).map_err(|e| BlobError::Zstd(e.to_string()))?;
139        // Verify content hash to detect corruption.
140        let actual = blake3::hash(&decompressed).to_hex().to_string();
141        if actual != hash {
142            return Err(BlobError::HashMismatch {
143                expected: hash.to_string(),
144                actual,
145            }
146            .into());
147        }
148        Ok(decompressed)
149    }
150
151    fn write_blob(path: &Path, content: &[u8]) -> Result<()> {
152        let parent = path.parent().ok_or_else(|| BlobError::Io {
153            path: path.to_path_buf(),
154            source: std::io::Error::new(
155                std::io::ErrorKind::InvalidInput,
156                "blob path has no parent",
157            ),
158        })?;
159        // 0700 on the shard directory (NFR-4).
160        create_dir_secure(parent)?;
161        // Compress to a buffer, then write atomically with 0600 perms (NFR-4).
162        let compressed =
163            encode_all(content, ZSTD_LEVEL).map_err(|e| BlobError::Zstd(e.to_string()))?;
164        write_file_secure(path, &compressed)
165    }
166
167    /// Delete a blob from disk if its refcount has dropped to zero. Returns
168    /// `true` if a file was removed. The caller must have already decremented
169    /// the refcount in the DB; this is the GC step.
170    pub fn remove_if_unreferenced(&self, hash: &str, refcount: i64) -> Result<bool> {
171        if refcount > 0 {
172            return Ok(false);
173        }
174        if !is_valid_hash(hash) {
175            return Err(BlobError::HashMismatch {
176                expected: format!("{HASH_HEX_LEN}-char blake3 hex"),
177                actual: hash.to_string(),
178            }
179            .into());
180        }
181        let path = self.blob_path(hash);
182        match fs::remove_file(&path) {
183            Ok(()) => {
184                // Best-effort cleanup of the now-empty shard dir.
185                if let Some(shard) = path.parent() {
186                    let _ = fs::remove_dir(shard);
187                }
188                Ok(true)
189            }
190            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
191            Err(e) => Err(BlobError::Io { path, source: e }.into()),
192        }
193    }
194
195    /// Securely delete a blob file: best-effort overwrite with zeros + fsync,
196    /// then unlink (docs/redaction-design.md I2). Returns `true` if a file
197    /// was removed. The overwrite raises the bar against recovery of the
198    /// plaintext from the filesystem; it is *not* a forensic guarantee on
199    /// journaling/copy-on-write filesystems or wear-leveled SSDs (documented
200    /// in docs/redaction.md). The caller must have already removed the
201    /// blob's row / confirmed the refcount is zero.
202    pub fn shred(&self, hash: &str) -> Result<bool> {
203        if !is_valid_hash(hash) {
204            return Err(BlobError::HashMismatch {
205                expected: format!("{HASH_HEX_LEN}-char blake3 hex"),
206                actual: hash.to_string(),
207            }
208            .into());
209        }
210        let path = self.blob_path(hash);
211        let len = match fs::metadata(&path) {
212            Ok(m) => m.len(),
213            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(false),
214            Err(e) => return Err(BlobError::Io { path, source: e }.into()),
215        };
216        // Overwrite in place (open without truncate so the same extents are
217        // rewritten where the filesystem allows it), then fsync before unlink.
218        {
219            use std::io::Write;
220            let mut f = fs::OpenOptions::new()
221                .write(true)
222                .open(&path)
223                .map_err(|e| BlobError::Io {
224                    path: path.clone(),
225                    source: e,
226                })?;
227            let zeros = vec![0u8; usize::try_from(len).unwrap_or(0)];
228            f.write_all(&zeros).map_err(|e| BlobError::Io {
229                path: path.clone(),
230                source: e,
231            })?;
232            f.sync_all().map_err(|e| BlobError::Io {
233                path: path.clone(),
234                source: e,
235            })?;
236        }
237        match fs::remove_file(&path) {
238            Ok(()) => {
239                if let Some(shard) = path.parent() {
240                    let _ = fs::remove_dir(shard);
241                }
242                Ok(true)
243            }
244            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
245            Err(e) => Err(BlobError::Io { path, source: e }.into()),
246        }
247    }
248
249    /// The root directory of the store.
250    pub fn root(&self) -> &Path {
251        &self.blobs_dir
252    }
253
254    /// Enumerate the BLAKE3 hex hashes of every blob file currently on disk,
255    /// by walking `blobs/<2-char shard>/<64-hex>.zst`. Used by
256    /// [`crate::store::Store::prune_orphan_blobs`] and
257    /// [`crate::store::Store::store_stats`] for orphan detection and footprint
258    /// accounting (Area 3). Returns hashes in arbitrary directory order;
259    /// defensively skips entries whose names are not a well-formed
260    /// `<hash>.zst`, so a stray file in the blobs dir never panics.
261    pub fn iter_hashes(&self) -> Result<Vec<String>> {
262        let mut out = Vec::new();
263        if !self.blobs_dir.exists() {
264            return Ok(out);
265        }
266        for shard in fs::read_dir(&self.blobs_dir).map_err(|e| BlobError::Io {
267            path: self.blobs_dir.clone(),
268            source: e,
269        })? {
270            let shard = shard.map_err(|e| BlobError::Io {
271                path: self.blobs_dir.clone(),
272                source: e,
273            })?;
274            if !shard.file_type().is_ok_and(|t| t.is_dir()) {
275                continue;
276            }
277            let shard_path = shard.path();
278            for entry in fs::read_dir(&shard_path).map_err(|e| BlobError::Io {
279                path: shard_path.clone(),
280                source: e,
281            })? {
282                let entry = entry.map_err(|e| BlobError::Io {
283                    path: shard_path.clone(),
284                    source: e,
285                })?;
286                if let Some(hash) = entry.file_name().to_string_lossy().strip_suffix(".zst") {
287                    if is_valid_hash(hash) {
288                        out.push(hash.to_string());
289                    }
290                }
291            }
292        }
293        Ok(out)
294    }
295}
296
297/// Create a directory with `0700` permissions (NFR-4). On Unix the mode is set
298/// explicitly; on non-Unix the default umask applies (best-effort, SRS §2.2).
299fn create_dir_secure(path: &Path) -> Result<()> {
300    #[cfg(unix)]
301    {
302        use std::os::unix::fs::DirBuilderExt;
303        std::fs::DirBuilder::new()
304            .recursive(true)
305            .mode(0o700)
306            .create(path)
307            .map_err(|e| BlobError::Io {
308                path: path.to_path_buf(),
309                source: e,
310            })?;
311    }
312    #[cfg(not(unix))]
313    {
314        std::fs::create_dir_all(path).map_err(|e| BlobError::Io {
315            path: path.to_path_buf(),
316            source: e,
317        })?;
318    }
319    Ok(())
320}
321
322/// Write `bytes` to `path` with `0600` permissions (NFR-4), using a temp file +
323/// rename for atomicity. Truncates any existing file.
324fn write_file_secure(path: &Path, bytes: &[u8]) -> Result<()> {
325    let tmp = path.with_extension("zst.tmp");
326    {
327        #[cfg(unix)]
328        {
329            use std::io::Write;
330            use std::os::unix::fs::OpenOptionsExt;
331            let mut f = std::fs::OpenOptions::new()
332                .write(true)
333                .create_new(true)
334                .mode(0o600)
335                .open(&tmp)
336                .map_err(|e| BlobError::Io {
337                    path: tmp.clone(),
338                    source: e,
339                })?;
340            f.write_all(bytes).map_err(|e| BlobError::Io {
341                path: tmp.clone(),
342                source: e,
343            })?;
344            f.sync_all().map_err(|e| BlobError::Io {
345                path: tmp.clone(),
346                source: e,
347            })?;
348        }
349        #[cfg(not(unix))]
350        {
351            use std::io::Write;
352            let mut f = std::fs::File::create(&tmp).map_err(|e| BlobError::Io {
353                path: tmp.clone(),
354                source: e,
355            })?;
356            f.write_all(bytes).map_err(|e| BlobError::Io {
357                path: tmp.clone(),
358                source: e,
359            })?;
360            f.sync_all().map_err(|e| BlobError::Io {
361                path: tmp.clone(),
362                source: e,
363            })?;
364        }
365    }
366    fs::rename(&tmp, path).map_err(|e| BlobError::Io {
367        path: path.to_path_buf(),
368        source: e,
369    })?;
370    // Ensure the renamed file keeps 0600 even if it replaced an existing one.
371    #[cfg(unix)]
372    {
373        use std::os::unix::fs::PermissionsExt;
374        let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o600));
375    }
376    Ok(())
377}
378
379/// Fuzz-only entry points into the blob store's decompression/validation path
380/// (`cargo fuzz` target `blob_decompress`). Gated behind the `fuzzing` feature
381/// so it never widens the crate's normal public API.
382#[cfg(feature = "fuzzing")]
383pub mod fuzzing {
384    use super::{BlobStore, HASH_HEX_LEN};
385    use std::sync::OnceLock;
386
387    fn store() -> &'static BlobStore {
388        static STORE: OnceLock<BlobStore> = OnceLock::new();
389        STORE.get_or_init(|| {
390            let dir = std::env::temp_dir().join(format!("hh-fuzz-blob-{}", std::process::id()));
391            BlobStore::new(dir)
392        })
393    }
394
395    /// Fuzz [`BlobStore::get`] against an arbitrary (attacker-controlled-shaped)
396    /// hash string. Must only ever return `Ok`/`Err`, never panic — this is the
397    /// guard `is_valid_hash` closes (a malformed hash used to reach `blob_path`'s
398    /// unchecked byte slice).
399    pub fn fuzz_get_arbitrary_hash(hash: &str) {
400        let _ = store().get(hash);
401    }
402
403    /// Fuzz the zstd-decompression + BLAKE3-verification path: write arbitrary
404    /// bytes directly to a fixed, well-formed hash's on-disk location (bypassing
405    /// `put()`'s content-addressing, which would only ever write valid zstd) and
406    /// call `get`, which must never panic regardless of whether the bytes are a
407    /// valid, truncated, or hostile zstd frame.
408    pub fn fuzz_decompress(bytes: &[u8]) {
409        let s = store();
410        let hash = "0".repeat(HASH_HEX_LEN);
411        let path = s.blob_path(&hash);
412        if let Some(parent) = path.parent() {
413            let _ = std::fs::create_dir_all(parent);
414        }
415        if std::fs::write(&path, bytes).is_ok() {
416            let _ = s.get(&hash);
417        }
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424    use tempfile::TempDir;
425
426    fn store() -> (TempDir, BlobStore) {
427        let tmp = TempDir::new().unwrap();
428        let s = BlobStore::new(tmp.path().join("blobs"));
429        (tmp, s)
430    }
431
432    #[test]
433    fn put_get_roundtrip_small() {
434        let (_tmp, s) = store();
435        let content = b"hello halfhand blobs";
436        let out = s.put(content).unwrap();
437        assert_eq!(out.size, content.len() as u64);
438        // On-disk layout: blobs/<h2>/<hash>.zst
439        let p = s.blob_path(&out.hash);
440        assert!(p.exists());
441        assert!(p.starts_with(s.root()));
442        assert_eq!(p.parent().unwrap().file_name().unwrap().len(), 2);
443        let got = s.get(&out.hash).unwrap();
444        assert_eq!(got, content);
445    }
446
447    #[test]
448    fn put_get_roundtrip_compressible() {
449        let (_tmp, s) = store();
450        // Highly compressible content to exercise zstd.
451        let content = "a".repeat(64 * 1024).into_bytes();
452        let out = s.put(&content).unwrap();
453        let on_disk = fs::read(s.blob_path(&out.hash)).unwrap();
454        assert!(
455            on_disk.len() < content.len(),
456            "zstd should compress repetitive input"
457        );
458        let got = s.get(&out.hash).unwrap();
459        assert_eq!(got, content);
460    }
461
462    #[test]
463    fn put_is_idempotent_on_disk() {
464        let (_tmp, s) = store();
465        let out1 = s.put(b"same content").unwrap();
466        let out2 = s.put(b"same content").unwrap();
467        assert_eq!(out1.hash, out2.hash);
468        // Only one file on disk.
469        assert_eq!(fs::read_dir(s.root()).unwrap().count(), 1);
470    }
471
472    #[test]
473    fn get_missing_blob_errors() {
474        let (_tmp, s) = store();
475        let h = "a".repeat(64);
476        let err = s.get(&h).unwrap_err();
477        assert!(matches!(
478            err,
479            crate::Error::Storage(StorageError::MissingBlob(_))
480        ));
481    }
482
483    #[test]
484    fn get_detects_corruption() {
485        let (_tmp, s) = store();
486        let out = s.put(b"original content").unwrap();
487        let path = s.blob_path(&out.hash);
488        // Overwrite the compressed file with valid zstd of different content.
489        let bad = zstd::stream::encode_all(b"different content".as_ref(), 3).unwrap();
490        fs::write(&path, &bad).unwrap();
491        let err = s.get(&out.hash).unwrap_err();
492        assert!(matches!(
493            err,
494            crate::Error::Blob(BlobError::HashMismatch { .. })
495        ));
496    }
497
498    #[test]
499    fn remove_deletes_when_unreferenced() {
500        let (_tmp, s) = store();
501        let out = s.put(b"to be deleted").unwrap();
502        let path = s.blob_path(&out.hash);
503        assert!(path.exists());
504        assert!(s.remove_if_unreferenced(&out.hash, 0).unwrap());
505        assert!(!path.exists());
506        // Second call is a no-op (already gone).
507        assert!(!s.remove_if_unreferenced(&out.hash, 0).unwrap());
508    }
509
510    #[test]
511    fn remove_keeps_when_still_referenced() {
512        let (_tmp, s) = store();
513        let out = s.put(b"still referenced").unwrap();
514        let path = s.blob_path(&out.hash);
515        assert!(!s.remove_if_unreferenced(&out.hash, 1).unwrap());
516        assert!(path.exists());
517    }
518
519    #[test]
520    fn put_with_redactor_stores_redacted_content_under_its_own_hash() {
521        let tmp = TempDir::new().unwrap();
522        let redactor = std::sync::Arc::new(
523            crate::redact::Detectors::new(&crate::config::RedactionConfig::default()).unwrap(),
524        );
525        let s = BlobStore::with_redactor(tmp.path().join("blobs"), redactor);
526        let secret = "AKIAIOSFODNN7EXAMPLE";
527        let content = format!("creds: {secret}\n");
528        let out = s.put(content.as_bytes()).unwrap();
529        // The returned hash addresses the *redacted* content.
530        assert_ne!(out.hash, BlobStore::hash(content.as_bytes()));
531        let stored = s.get(&out.hash).unwrap();
532        let text = String::from_utf8(stored).unwrap();
533        assert!(!text.contains(secret), "secret must not hit disk: {text}");
534        assert!(text.contains("{{REDACTED:aws-access-key-id:"));
535        assert_eq!(out.size, text.len() as u64);
536        // Clean and binary content pass through untouched.
537        let clean = s.put(b"nothing sensitive").unwrap();
538        assert_eq!(clean.hash, BlobStore::hash(b"nothing sensitive"));
539        let binary = [0u8, 1, 2, 255];
540        let bin_out = s.put(&binary).unwrap();
541        assert_eq!(bin_out.hash, BlobStore::hash(&binary));
542    }
543
544    #[test]
545    fn shred_overwrites_then_removes() {
546        let (_tmp, s) = store();
547        let out = s.put(b"secret to shred").unwrap();
548        let path = s.blob_path(&out.hash);
549        assert!(path.exists());
550        assert!(s.shred(&out.hash).unwrap());
551        assert!(!path.exists());
552        // Second call: already gone.
553        assert!(!s.shred(&out.hash).unwrap());
554        // Malformed hash is an error, never a panic or path traversal.
555        assert!(s.shred("../../etc/passwd").is_err());
556    }
557
558    #[cfg(unix)]
559    #[test]
560    fn blob_files_are_0600_and_dirs_0700() {
561        use std::os::unix::fs::PermissionsExt;
562        let (_tmp, s) = store();
563        let out = s.put(b"secret bytes").unwrap();
564        let path = s.blob_path(&out.hash);
565        let mode = fs::metadata(&path).unwrap().permissions().mode();
566        assert_eq!(mode & 0o777, 0o600);
567        let shard = path.parent().unwrap();
568        let dmode = fs::metadata(shard).unwrap().permissions().mode();
569        assert_eq!(dmode & 0o777, 0o700);
570    }
571}