Skip to main content

khive_runtime/
blob.rs

1//! Config-driven `BlobStore` selection (ADR-111 Amendment 2).
2//!
3//! `khive-db` cannot parse `khive.toml` itself (it sits below `khive-runtime`
4//! in the crate dependency chain), so the fs-vs-s3 selector lives here, one
5//! layer up, where `KhiveConfig` is already parsed. This is the choke point
6//! every boot path (single- and multi-backend) resolves the configured blob
7//! store through, so the two never drift onto different construction logic.
8
9use std::sync::Arc;
10
11use async_trait::async_trait;
12use khive_db::stores::blob_s3::{S3BlobStore, S3BlobStoreConfig};
13use khive_db::{SqliteError, StorageBackend};
14use khive_storage::{
15    BlobOrphanSweepConfig, BlobOrphanSweepResult, BlobStore, ContentRef, SqlAccess,
16    StorageCapability, StorageError, StorageResult, UploadId,
17};
18
19use crate::engine_config::BlobConfig;
20use crate::{KhiveConfig, RuntimeError, RuntimeResult};
21
22/// Default process-local admission budget for resident verified blob buffers.
23pub const DEFAULT_BLOB_HYDRATION_BYTES: u64 = 4 * khive_storage::MAX_BLOB_WHOLE_BYTES;
24
25/// Runtime-owned bounded blob hydration with weighted raw-byte admission.
26#[derive(Debug)]
27pub struct BlobHydrator {
28    store: Arc<dyn BlobStore>,
29    /// The store as handed in by the caller, before any read-only wrapping.
30    /// Kept so the install seam can recognize a reinstall of the same raw
31    /// store by pointer identity even when `store` is a wrapper around it.
32    raw_store: Arc<dyn BlobStore>,
33    /// True only for hydrators built through [`Self::new_read_only`], whose
34    /// mutators are refused by construction. The runtime's install seam
35    /// requires this on read-only runtimes: `dyn BlobStore` carries no
36    /// downcast hook, so the wrapper cannot be recognized after the fact —
37    /// the guarantee has to travel with the hydrator that made it.
38    read_only: bool,
39    /// True only for hydrators built by
40    /// [`Self::resolve_for_governing_backend`], whose mode was derived from a
41    /// governing backend's own access mode rather than declared by the
42    /// caller. The shared install seam accepts only governed hydrators, so a
43    /// hand-paired writable hydrator cannot ride it onto a read-only
44    /// runtime.
45    governed: bool,
46    admission: Arc<tokio::sync::Semaphore>,
47    budget_bytes: u64,
48}
49
50/// Error from [`BlobHydrator::resolve_for_governing_backend`], split so boot
51/// can distinguish store resolution failures (which fall back to "no blob
52/// configured" when `[storage.blob]` is absent) from budget pairing failures
53/// (always fatal).
54#[derive(Debug)]
55pub enum GovernedBlobError {
56    /// The configured store could not be resolved for the governing mode.
57    Resolve(SqliteError),
58    /// The resolved store could not be paired with the hydration budget.
59    Construct(RuntimeError),
60}
61
62impl std::fmt::Display for GovernedBlobError {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        match self {
65            Self::Resolve(error) => write!(f, "blob store resolution: {error}"),
66            Self::Construct(error) => write!(f, "blob hydrator construction: {error}"),
67        }
68    }
69}
70
71impl std::error::Error for GovernedBlobError {}
72
73impl BlobHydrator {
74    /// Pair the raw store with a budget, refusing every physical mutator:
75    /// the store is wrapped so `put`/`delete`/sweeps error while the bounded
76    /// read surface stays available. This is the only constructor a
77    /// read-only runtime's install seam accepts.
78    pub(crate) fn new_read_only(
79        store: Arc<dyn BlobStore>,
80        budget_bytes: u64,
81    ) -> RuntimeResult<Self> {
82        let mut hydrator =
83            Self::new_inner(wrap_read_only(Arc::clone(&store)), store, budget_bytes)?;
84        hydrator.read_only = true;
85        Ok(hydrator)
86    }
87
88    /// Whether this hydrator refuses mutation by construction.
89    pub(crate) fn enforces_read_only(&self) -> bool {
90        self.read_only
91    }
92
93    /// The store exactly as the caller handed it in, before any wrapping.
94    pub(crate) fn raw_store(&self) -> Arc<dyn BlobStore> {
95        Arc::clone(&self.raw_store)
96    }
97
98    /// Pair a raw store with a budget under an explicitly declared
99    /// blob-runtime mode. `read_only = true` wraps the store so every
100    /// physical mutator refuses while the bounded read surface stays
101    /// available; `false` is [`Self::new`]. Boot paths that decide the blob
102    /// mode from configuration (the blob pack's backend mode, ADR-160 D3)
103    /// construct through this so the decision travels with the hydrator —
104    /// the install seam can then hold hydrator mode against runtime mode
105    /// instead of trusting the caller's pairing.
106    pub fn for_mode(
107        store: Arc<dyn BlobStore>,
108        budget_bytes: u64,
109        read_only: bool,
110    ) -> RuntimeResult<Self> {
111        if read_only {
112            Self::new_read_only(store, budget_bytes)
113        } else {
114            Self::new(store, budget_bytes)
115        }
116    }
117
118    /// Resolve the configured store and pair it with the budget under the
119    /// mode of the backend that GOVERNS blob mutability — the backend the
120    /// blob pack maps to (ADR-160 D3), which on single-backend boots is the
121    /// runtime's own backend.
122    ///
123    /// The mode is derived here from `governing_backend`'s own access mode,
124    /// never accepted as a caller-declared flag, and the hydrator is stamped
125    /// as governed. [`crate::KhiveRuntime::install_shared_blob_hydrator`]
126    /// accepts only governed hydrators.
127    ///
128    /// Trust model (explicit policy): which backend governs blob mutability
129    /// is a deployment-topology assertion made by the host that wires boot
130    /// (the blob pack's backend, ADR-160 D3), and this seam takes the
131    /// caller's word for it. What the derivation defends against is the
132    /// ACCIDENTAL mode mismatch — a hand-paired writable hydrator drifting
133    /// onto a read-only handle through the shared seam. It does not defend
134    /// against an in-process caller who deliberately misdeclares the
135    /// governing backend, because no runtime seam can: any such caller can
136    /// already open the configured blob root directly (the same config and
137    /// store constructors are public) and mutate it without touching a
138    /// runtime handle. Read-only runtime handles are a wrong-wiring guard,
139    /// not an in-process sandbox.
140    pub fn resolve_for_governing_backend(
141        cfg: &KhiveConfig,
142        resolve_backend: &StorageBackend,
143        governing_backend: &StorageBackend,
144        budget_bytes: u64,
145    ) -> Result<Self, GovernedBlobError> {
146        let read_only = governing_backend.is_read_only();
147        let store = resolve_blob_store_for_mode(cfg, resolve_backend, read_only)
148            .map_err(GovernedBlobError::Resolve)?;
149        let mut hydrator =
150            Self::for_mode(store, budget_bytes, read_only).map_err(GovernedBlobError::Construct)?;
151        hydrator.governed = true;
152        Ok(hydrator)
153    }
154
155    /// Whether this hydrator's mode was derived from a governing backend
156    /// (see [`Self::resolve_for_governing_backend`]).
157    pub(crate) fn is_governed(&self) -> bool {
158        self.governed
159    }
160
161    /// Pair one store with one aggregate byte budget.
162    pub fn new(store: Arc<dyn BlobStore>, budget_bytes: u64) -> RuntimeResult<Self> {
163        let raw = Arc::clone(&store);
164        Self::new_inner(store, raw, budget_bytes)
165    }
166
167    fn new_inner(
168        store: Arc<dyn BlobStore>,
169        raw_store: Arc<dyn BlobStore>,
170        budget_bytes: u64,
171    ) -> RuntimeResult<Self> {
172        if budget_bytes < khive_storage::MAX_BLOB_WHOLE_BYTES {
173            return Err(RuntimeError::InvalidInput(format!(
174                "blob hydration budget must be at least {} bytes, got {budget_bytes}",
175                khive_storage::MAX_BLOB_WHOLE_BYTES
176            )));
177        }
178        let permits = usize::try_from(budget_bytes).map_err(|_| {
179            RuntimeError::InvalidInput(format!(
180                "blob hydration budget {budget_bytes} does not fit this platform"
181            ))
182        })?;
183        if permits > tokio::sync::Semaphore::MAX_PERMITS {
184            return Err(RuntimeError::InvalidInput(format!(
185                "blob hydration budget {budget_bytes} exceeds the runtime maximum {}",
186                tokio::sync::Semaphore::MAX_PERMITS
187            )));
188        }
189        Ok(Self {
190            store,
191            raw_store,
192            read_only: false,
193            governed: false,
194            admission: Arc::new(tokio::sync::Semaphore::new(permits)),
195            budget_bytes,
196        })
197    }
198
199    /// Return the resolved aggregate admission budget.
200    pub fn budget_bytes(&self) -> u64 {
201        self.budget_bytes
202    }
203
204    /// Clone the paired store for metadata and mutation paths.
205    ///
206    /// Whole-buffer production reads must use [`Self::hydrate_verified`].
207    pub(crate) fn store(&self) -> Arc<dyn BlobStore> {
208        Arc::clone(&self.store)
209    }
210
211    /// Hydrate one complete digest-verified object under weighted admission.
212    pub async fn hydrate_verified(
213        &self,
214        content_ref: &ContentRef,
215        max_bytes: u64,
216    ) -> RuntimeResult<VerifiedBlob> {
217        if max_bytes > khive_storage::MAX_BLOB_WHOLE_BYTES {
218            return Err(RuntimeError::InvalidInput(format!(
219                "blob hydration max_bytes must not exceed {} bytes, got {max_bytes}",
220                khive_storage::MAX_BLOB_WHOLE_BYTES
221            )));
222        }
223
224        let acquire = Arc::clone(&self.admission).acquire_many_owned(max_bytes as u32);
225        let admission =
226            khive_storage::await_request_read_phase("blob_hydration_admission", acquire)
227                .await?
228                .map_err(|error| {
229                    RuntimeError::Internal(format!("blob hydration admission closed: {error}"))
230                })?;
231
232        let (sender, receiver) = tokio::sync::oneshot::channel::<RuntimeResult<VerifiedBlob>>();
233        let store = Arc::clone(&self.store);
234        let content_ref = content_ref.clone();
235        crate::track_named_background_task("blob_hydration", async move {
236            // The tracked supervisor itself owns backend work and the lease.
237            // Dropping a request only drops `receiver`; the supervisor stays
238            // visible to daemon drain until the backend future actually ends.
239            let result = match store.get_bounded_verified(&content_ref, max_bytes).await {
240                Ok(bytes) => Ok(VerifiedBlob {
241                    bytes,
242                    _admission: admission,
243                }),
244                Err(error) => Err(RuntimeError::Storage(error)),
245            };
246            let _ = sender.send(result);
247        });
248
249        khive_storage::await_request_read_phase("blob_hydration", receiver)
250            .await?
251            .map_err(|_| {
252                RuntimeError::Internal(
253                    "blob hydration supervisor ended without delivering a result".to_string(),
254                )
255            })?
256    }
257}
258
259/// One verified raw blob buffer and its aggregate admission lease.
260///
261/// The wrapper intentionally has no `Clone` or owned-byte extraction API.
262/// Borrowed bytes may still be copied under the caller's own allocation budget.
263///
264/// ```compile_fail
265/// use khive_runtime::VerifiedBlob;
266///
267/// fn clone_verified(blob: &VerifiedBlob) {
268///     let _ = <VerifiedBlob as Clone>::clone(blob);
269/// }
270/// ```
271///
272/// ```compile_fail
273/// use khive_runtime::VerifiedBlob;
274///
275/// fn extract_owned(blob: VerifiedBlob) -> Vec<u8> {
276///     blob.into_bytes()
277/// }
278/// ```
279///
280/// ```compile_fail
281/// use khive_runtime::VerifiedBlob;
282///
283/// fn access_private_storage(blob: &VerifiedBlob) -> usize {
284///     blob.bytes.len()
285/// }
286/// ```
287pub struct VerifiedBlob {
288    bytes: Vec<u8>,
289    _admission: tokio::sync::OwnedSemaphorePermit,
290}
291
292impl VerifiedBlob {
293    /// Borrow the verified bytes without releasing weighted admission.
294    pub fn bytes(&self) -> &[u8] {
295        &self.bytes
296    }
297}
298
299impl AsRef<[u8]> for VerifiedBlob {
300    fn as_ref(&self) -> &[u8] {
301        self.bytes()
302    }
303}
304
305impl std::fmt::Debug for VerifiedBlob {
306    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
307        formatter
308            .debug_struct("VerifiedBlob")
309            .field("len", &self.bytes.len())
310            .finish_non_exhaustive()
311    }
312}
313
314/// Resolve the `BlobStore` this `backend` should use, per `cfg.storage.blob`.
315///
316/// - Absent, or `backend = "fs"`: `FsBlobStore` via `StorageBackend::blob_store`,
317///   using the existing `KHIVE_BLOB_ROOT` > `root` > `<db_dir>/blobs` precedence
318///   (khive#292) — unchanged from every configuration written before this
319///   section existed.
320/// - `backend = "s3"`: `S3BlobStore`, built from the non-secret TOML fields
321///   plus environment credentials (`S3BlobStore::new`).
322pub fn resolve_blob_store(
323    cfg: &KhiveConfig,
324    backend: &StorageBackend,
325) -> Result<Arc<dyn BlobStore>, SqliteError> {
326    match &cfg.storage.blob {
327        None => backend.blob_store(None, None),
328        Some(BlobConfig::Fs { root, floor_bytes }) => {
329            let root_path = root.as_ref().map(std::path::PathBuf::from);
330            backend.blob_store(root_path.as_deref(), *floor_bytes)
331        }
332        Some(BlobConfig::S3 {
333            bucket,
334            region,
335            endpoint,
336            prefix,
337            allow_http,
338        }) => {
339            let mut s3_cfg = S3BlobStoreConfig::new(bucket.clone(), region.clone());
340            if let Some(endpoint) = endpoint {
341                s3_cfg = s3_cfg.with_endpoint(endpoint.clone());
342            }
343            if let Some(prefix) = prefix {
344                s3_cfg = s3_cfg.with_prefix(prefix.clone());
345            }
346            if let Some(allow_http) = allow_http {
347                s3_cfg = s3_cfg.with_allow_http(*allow_http);
348            }
349            let store = S3BlobStore::new(s3_cfg)?;
350            Ok(Arc::new(store))
351        }
352    }
353}
354
355/// Resolve the configured store for one pack runtime's effective access mode.
356///
357/// A read-only runtime retains bounded verified reads plus `exists`/`size`
358/// against an already-present fs root (or a configured S3 store), but boot
359/// never creates the default fs root and the wrapper rejects every physical
360/// mutator. The mode belongs to the runtime assigned to the `blob` pack; a
361/// mixed topology must not infer it from the main audit backend.
362pub fn resolve_blob_store_for_mode(
363    cfg: &KhiveConfig,
364    backend: &StorageBackend,
365    read_only: bool,
366) -> Result<Arc<dyn BlobStore>, SqliteError> {
367    if !read_only {
368        return resolve_blob_store(cfg, backend);
369    }
370
371    let inner: Arc<dyn BlobStore> = match &cfg.storage.blob {
372        None => backend.blob_store_read_only(None, None)?,
373        Some(BlobConfig::Fs { root, floor_bytes }) => {
374            let root_path = root.as_ref().map(std::path::PathBuf::from);
375            backend.blob_store_read_only(root_path.as_deref(), *floor_bytes)?
376        }
377        Some(BlobConfig::S3 {
378            bucket,
379            region,
380            endpoint,
381            prefix,
382            allow_http,
383        }) => {
384            let mut s3_cfg = S3BlobStoreConfig::new(bucket.clone(), region.clone());
385            if let Some(endpoint) = endpoint {
386                s3_cfg = s3_cfg.with_endpoint(endpoint.clone());
387            }
388            if let Some(prefix) = prefix {
389                s3_cfg = s3_cfg.with_prefix(prefix.clone());
390            }
391            if let Some(allow_http) = allow_http {
392                s3_cfg = s3_cfg.with_allow_http(*allow_http);
393            }
394            Arc::new(S3BlobStore::new(s3_cfg)?)
395        }
396    };
397    Ok(Arc::new(ReadOnlyBlobStore { inner }))
398}
399
400/// Wrap an arbitrary store so every physical mutator is refused while the
401/// bounded read surface stays available. Used by the runtime's install seam
402/// to hold the read-only invariant for stores installed after boot; wrapping
403/// an already-wrapped store is harmless (reads delegate, mutators refuse at
404/// the outer layer).
405pub(crate) fn wrap_read_only(inner: Arc<dyn BlobStore>) -> Arc<dyn BlobStore> {
406    Arc::new(ReadOnlyBlobStore { inner })
407}
408
409#[derive(Debug)]
410struct ReadOnlyBlobStore {
411    inner: Arc<dyn BlobStore>,
412}
413
414impl ReadOnlyBlobStore {
415    fn mutation_error(operation: &'static str) -> StorageError {
416        StorageError::Unsupported {
417            capability: StorageCapability::Blob,
418            operation: operation.into(),
419            message: "blob storage is read-only for this pack runtime".to_string(),
420        }
421    }
422}
423
424#[async_trait]
425impl BlobStore for ReadOnlyBlobStore {
426    async fn put(&self, _bytes: Vec<u8>) -> StorageResult<ContentRef> {
427        Err(Self::mutation_error("put"))
428    }
429
430    fn upload_lease_idle_cap(&self) -> Option<std::time::Duration> {
431        self.inner.upload_lease_idle_cap()
432    }
433
434    async fn begin_upload_with_lease(
435        &self,
436        _size: u64,
437        _config: khive_storage::blob::UploadLeaseConfig,
438    ) -> StorageResult<UploadId> {
439        Err(Self::mutation_error("begin_upload_with_lease"))
440    }
441
442    async fn renew_upload(&self, _id: &UploadId) -> StorageResult<()> {
443        Err(Self::mutation_error("renew_upload"))
444    }
445
446    async fn begin_upload(&self, _declared_size: u64) -> StorageResult<UploadId> {
447        Err(Self::mutation_error("begin_upload"))
448    }
449
450    async fn append_part(&self, _id: &UploadId, _bytes: Vec<u8>) -> StorageResult<u64> {
451        Err(Self::mutation_error("append_part"))
452    }
453
454    async fn commit_upload(&self, _id: &UploadId, _content_ref: &ContentRef) -> StorageResult<()> {
455        Err(Self::mutation_error("commit_upload"))
456    }
457
458    async fn abort_upload(&self, _id: &UploadId) -> StorageResult<()> {
459        Err(Self::mutation_error("abort_upload"))
460    }
461
462    async fn sweep_uploads(&self, _idle_for: std::time::Duration) -> StorageResult<u64> {
463        Err(Self::mutation_error("sweep_uploads"))
464    }
465
466    async fn get_bounded_verified(
467        &self,
468        content_ref: &ContentRef,
469        max_bytes: u64,
470    ) -> StorageResult<Vec<u8>> {
471        self.inner
472            .get_bounded_verified(content_ref, max_bytes)
473            .await
474    }
475
476    async fn exists(&self, content_ref: &ContentRef) -> StorageResult<bool> {
477        self.inner.exists(content_ref).await
478    }
479
480    async fn size(&self, content_ref: &ContentRef) -> StorageResult<Option<u64>> {
481        self.inner.size(content_ref).await
482    }
483
484    async fn delete(&self, _content_ref: &ContentRef) -> StorageResult<bool> {
485        Err(Self::mutation_error("delete"))
486    }
487
488    async fn orphan_sweep(
489        &self,
490        _config: &BlobOrphanSweepConfig,
491    ) -> StorageResult<BlobOrphanSweepResult> {
492        Err(Self::mutation_error("orphan_sweep"))
493    }
494
495    async fn transactional_orphan_sweep(
496        &self,
497        _sql: &dyn SqlAccess,
498        _dry_run: bool,
499    ) -> StorageResult<BlobOrphanSweepResult> {
500        Err(Self::mutation_error("transactional_orphan_sweep"))
501    }
502}
503
504#[cfg(test)]
505#[path = "blob_tests.rs"]
506mod tests;