khive_storage/blob.rs
1//! Blob storage capability — content-addressed binary object CRUD.
2//!
3//! `BlobStore` is the trait family added by khive#292: bytes that do not
4//! belong inside the primary SQLite database (source PDFs, images, large
5//! opaque payloads) are stored by a dedicated backend and referenced from
6//! the graph by an opaque [`ContentRef`]. Per ADR-005's "zero
7//! implementations" constraint, this module defines the contract only — the
8//! first backend (filesystem, BLAKE3-addressed) lives in `khive-db`.
9
10use std::collections::HashSet;
11
12use async_trait::async_trait;
13use serde::{Deserialize, Serialize};
14
15use crate::capability::StorageCapability;
16use crate::error::StorageError;
17use crate::types::StorageResult;
18
19/// Number of hex characters in a BLAKE3-256 digest (32 bytes -> 64 hex chars).
20const CONTENT_REF_HEX_LEN: usize = 64;
21
22/// An opaque, content-addressed reference to a stored blob.
23///
24/// Backed by a lowercase-hex BLAKE3 digest of the blob's bytes: identical
25/// content always produces the same `ContentRef`, so storing the same bytes
26/// twice is a no-op after the first write. Callers must treat the value as
27/// opaque — the backend, not the caller, decides how a `ContentRef` maps to
28/// physical storage.
29///
30/// `Deserialize` is hand-written (below) to reject any string that is not 64
31/// lowercase hex characters — a naive derive would let an unvalidated value
32/// panic later in `shard_path`'s slicing.
33/// See `crates/khive-storage/docs/api/blob-store.md` for the full rationale.
34#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize)]
35#[serde(transparent)]
36pub struct ContentRef(String);
37
38impl<'de> Deserialize<'de> for ContentRef {
39 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
40 where
41 D: serde::Deserializer<'de>,
42 {
43 let raw = String::deserialize(deserializer)?;
44 ContentRef::from_hex(raw).map_err(serde::de::Error::custom)
45 }
46}
47
48impl ContentRef {
49 /// Parse a `ContentRef` from a caller-supplied hex string.
50 ///
51 /// Rejects anything that is not exactly 64 lowercase hex characters.
52 /// Uppercase is rejected (not normalized) to keep one canonical string
53 /// form per digest — see `docs/api/blob-store.md`.
54 pub fn from_hex(hex: impl Into<String>) -> Result<Self, String> {
55 let hex = hex.into();
56 if hex.len() != CONTENT_REF_HEX_LEN {
57 return Err(format!(
58 "content_ref must be {CONTENT_REF_HEX_LEN} hex characters, got length {} ({hex:?})",
59 hex.len()
60 ));
61 }
62 if !hex
63 .bytes()
64 .all(|b| b.is_ascii_digit() || (b.is_ascii_lowercase() && b.is_ascii_hexdigit()))
65 {
66 return Err(format!(
67 "content_ref must be lowercase hex (0-9, a-f), got {hex:?}"
68 ));
69 }
70 Ok(Self(hex))
71 }
72
73 /// Construct a `ContentRef` directly from a BLAKE3 digest's raw bytes.
74 pub fn from_digest_bytes(digest: &[u8; 32]) -> Self {
75 Self(hex_encode(digest))
76 }
77
78 /// Borrow the underlying hex string.
79 pub fn as_str(&self) -> &str {
80 &self.0
81 }
82}
83
84impl std::fmt::Display for ContentRef {
85 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86 f.write_str(&self.0)
87 }
88}
89
90impl AsRef<str> for ContentRef {
91 fn as_ref(&self) -> &str {
92 &self.0
93 }
94}
95
96fn hex_encode(bytes: &[u8]) -> String {
97 const HEX: &[u8; 16] = b"0123456789abcdef";
98 let mut out = String::with_capacity(bytes.len() * 2);
99 for &b in bytes {
100 out.push(HEX[(b >> 4) as usize] as char);
101 out.push(HEX[(b & 0x0f) as usize] as char);
102 }
103 out
104}
105
106/// Configuration for [`BlobStore::orphan_sweep`].
107///
108/// `live_refs` is a point-in-time snapshot the caller assembles (this trait
109/// has no visibility into SQL substrates — ADR-005 constraint 4), not a live
110/// query. See [`BlobStore::orphan_sweep`] for the concurrency hazard this
111/// implies, and `crates/khive-storage/docs/api/blob-store.md` for the full
112/// rationale.
113#[derive(Clone, Debug, Default, Serialize, Deserialize)]
114pub struct BlobOrphanSweepConfig {
115 /// Content refs currently referenced by at least one live row somewhere
116 /// in the system, as of when the caller assembled this set. Anything
117 /// this backend stores that is NOT in this set is treated as orphaned
118 /// and deleted (or reported, in `dry_run` mode) — including a
119 /// `content_ref` that becomes live after this snapshot was taken.
120 pub live_refs: HashSet<ContentRef>,
121 /// When `true`, report what would be deleted without deleting anything.
122 pub dry_run: bool,
123}
124
125/// Result of a [`BlobStore::orphan_sweep`] call.
126#[derive(Clone, Debug, Default, Serialize, Deserialize)]
127pub struct BlobOrphanSweepResult {
128 /// Total objects examined in this backend.
129 pub scanned: u64,
130 /// Objects actually deleted (always 0 when `dry_run = true`).
131 pub deleted: u64,
132 /// Objects that are orphaned (would be deleted whether or not `dry_run`
133 /// is set — populated in both modes so a dry run reports the same count
134 /// a real run would delete).
135 pub would_delete: u64,
136}
137
138/// Content-addressed binary object CRUD.
139///
140/// Every method is backend-agnostic: the filesystem backend
141/// (`khive-db::stores::blob::FsBlobStore`) is the first implementation, and
142/// any future backend (object storage, a different CAS layout) implements
143/// the same operations. Per ADR-005 constraint 4, a `BlobStore` instance
144/// talks to exactly one backend.
145// `Debug` is a supertrait so boot-path tests can distinguish which concrete
146// backend was installed behind `Arc<dyn BlobStore>` via `format!("{:?}", ..)`
147// without adding a downcast/type-name method to the production surface.
148#[async_trait]
149pub trait BlobStore: Send + Sync + std::fmt::Debug + 'static {
150 /// Store `bytes`, returning the content-addressed reference under which
151 /// they are now retrievable. Storing byte-identical content more than
152 /// once returns the same `ContentRef` and does not re-write the object.
153 async fn put(&self, bytes: Vec<u8>) -> StorageResult<ContentRef>;
154
155 /// Fetch the bytes stored under `content_ref`.
156 ///
157 /// Returns `StorageError::NotFound` (capability `Blob`) if no object
158 /// exists for this reference.
159 async fn get(&self, content_ref: &ContentRef) -> StorageResult<Vec<u8>>;
160
161 /// Whether an object currently exists for `content_ref`.
162 async fn exists(&self, content_ref: &ContentRef) -> StorageResult<bool>;
163
164 /// The size in bytes of the object stored under `content_ref`, without
165 /// hydrating its bytes.
166 ///
167 /// Returns `Ok(None)` when no object exists for this reference — this is
168 /// the existence check and the size read in one call, so a caller never
169 /// pays for a full read just to answer "does this exist and how big is
170 /// it". On the filesystem backend this maps to a file metadata stat; on
171 /// an object-storage backend it maps to a `HEAD Object` request.
172 async fn size(&self, content_ref: &ContentRef) -> StorageResult<Option<u64>>;
173
174 /// Remove the object stored under `content_ref`.
175 ///
176 /// Returns `true` when an object was actually removed, `false` when
177 /// none existed — deleting an absent object is not an error.
178 ///
179 /// # Safety / concurrency hazard (ADR-111 §8, amended)
180 ///
181 /// Unconditional physical removal with **no coordination against any
182 /// entity that might reference `content_ref`**. Safe to call only when
183 /// the caller has independently quiesced whatever writer could attach a
184 /// new `content_ref` to an entity for the duration of the call — this
185 /// trait does not detect or prevent a race. Offline-maintenance-only.
186 /// See `crates/khive-storage/docs/api/blob-store.md`.
187 async fn delete(&self, content_ref: &ContentRef) -> StorageResult<bool>;
188
189 /// Enumerate every object this backend holds and delete (or, in
190 /// `dry_run` mode, report) those absent from `config.live_refs`.
191 /// Operator-side GC path (khive#292 deliverable 5) — admin-only, not an
192 /// MCP verb. Default returns `StorageError::Unsupported`; the filesystem
193 /// backend overrides it with a real directory walk.
194 ///
195 /// # Safety / concurrency hazard (ADR-111 §8, amended)
196 ///
197 /// `config.live_refs` is a **snapshot**; a `content_ref` that becomes
198 /// newly live between the snapshot and the sweep is deleted anyway.
199 /// **Callers MUST quiesce entity writes** for the duration of
200 /// snapshot-plus-sweep. See `crates/khive-storage/docs/api/blob-store.md`
201 /// for the race repro and the tracked transactional-sweep follow-up
202 /// (khive#924).
203 async fn orphan_sweep(
204 &self,
205 config: &BlobOrphanSweepConfig,
206 ) -> StorageResult<BlobOrphanSweepResult> {
207 let _ = config;
208 Err(StorageError::Unsupported {
209 capability: StorageCapability::Blob,
210 operation: "orphan_sweep".into(),
211 message: "this backend does not support orphan sweep".into(),
212 })
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219
220 #[test]
221 fn from_hex_accepts_valid_lowercase_digest() {
222 let hex = "a".repeat(64);
223 let cref = ContentRef::from_hex(hex.clone()).unwrap();
224 assert_eq!(cref.as_str(), hex);
225 assert_eq!(cref.to_string(), hex);
226 }
227
228 #[test]
229 fn from_hex_rejects_short_string() {
230 let err = ContentRef::from_hex("abc").unwrap_err();
231 assert!(
232 err.contains("64"),
233 "error must mention expected length: {err}"
234 );
235 }
236
237 #[test]
238 fn from_hex_rejects_long_string() {
239 let err = ContentRef::from_hex("a".repeat(65)).unwrap_err();
240 assert!(
241 err.contains("64"),
242 "error must mention expected length: {err}"
243 );
244 }
245
246 #[test]
247 fn from_hex_rejects_uppercase() {
248 let err = ContentRef::from_hex("A".repeat(64)).unwrap_err();
249 assert!(
250 err.contains("lowercase"),
251 "error must mention lowercase requirement: {err}"
252 );
253 }
254
255 #[test]
256 fn from_hex_rejects_non_hex_characters() {
257 let mut hex = "a".repeat(63);
258 hex.push('z');
259 let err = ContentRef::from_hex(hex).unwrap_err();
260 assert!(
261 err.contains("lowercase hex"),
262 "error must mention hex requirement: {err}"
263 );
264 }
265
266 #[test]
267 fn from_digest_bytes_matches_known_blake3_hash() {
268 // BLAKE3("") -> af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262
269 let hash = blake3_hash_of_empty();
270 let cref = ContentRef::from_digest_bytes(&hash);
271 assert_eq!(
272 cref.as_str(),
273 "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262"
274 );
275 }
276
277 // hand-rolled BLAKE3("") vector (see docs/api/blob-store.md)
278 fn blake3_hash_of_empty() -> [u8; 32] {
279 let hex = "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262";
280 let mut out = [0u8; 32];
281 for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
282 let s = std::str::from_utf8(chunk).unwrap();
283 out[i] = u8::from_str_radix(s, 16).unwrap();
284 }
285 out
286 }
287
288 #[test]
289 fn deserialize_accepts_a_valid_lowercase_digest() {
290 let hex = "d".repeat(64);
291 let json = serde_json::to_string(&hex).unwrap();
292 let cref: ContentRef = serde_json::from_str(&json).unwrap();
293 assert_eq!(cref.as_str(), hex);
294 }
295
296 #[test]
297 fn deserialize_rejects_short_string() {
298 let err = serde_json::from_str::<ContentRef>("\"x\"").unwrap_err();
299 assert!(
300 err.to_string().contains("64"),
301 "deserialize error must mention the expected length: {err}"
302 );
303 }
304
305 #[test]
306 fn deserialize_rejects_uppercase() {
307 let hex = "A".repeat(64);
308 let json = serde_json::to_string(&hex).unwrap();
309 let err = serde_json::from_str::<ContentRef>(&json).unwrap_err();
310 assert!(
311 err.to_string().contains("lowercase"),
312 "deserialize error must mention the lowercase requirement: {err}"
313 );
314 }
315
316 #[test]
317 fn deserialize_rejects_non_hex_characters() {
318 let mut hex = "a".repeat(63);
319 hex.push('z');
320 let json = serde_json::to_string(&hex).unwrap();
321 let err = serde_json::from_str::<ContentRef>(&json).unwrap_err();
322 assert!(
323 err.to_string().contains("lowercase hex"),
324 "deserialize error must mention the hex requirement: {err}"
325 );
326 }
327
328 #[test]
329 fn content_ref_equality_and_hash_are_string_based() {
330 let a = ContentRef::from_hex("b".repeat(64)).unwrap();
331 let b = ContentRef::from_hex("b".repeat(64)).unwrap();
332 let c = ContentRef::from_hex("c".repeat(64)).unwrap();
333 assert_eq!(a, b);
334 assert_ne!(a, c);
335
336 use std::collections::HashSet;
337 let mut set = HashSet::new();
338 set.insert(a.clone());
339 assert!(set.contains(&b));
340 assert!(!set.contains(&c));
341 }
342}