Skip to main content

laser_sdk/
blob.rs

1use crate::error::LaserError;
2use async_trait::async_trait;
3use laser_wire::agent::BodyRef;
4use laser_wire::content::ContentType;
5use laser_wire::framing::{decode_named, encode_named};
6use sha2::{Digest, Sha256};
7
8/// Where claim-checked bodies live. The seam rule applies: no default store
9/// ships, the deployment chooses (an S3-compatible bucket, the kv surface for
10/// small overflow, a filesystem in dev), and the same trait shape as
11/// [`Deduplicator`](crate::agent::Deduplicator) keeps it `dyn`-usable behind
12/// a plain reference.
13#[async_trait]
14pub trait BlobStore: Send + Sync {
15    /// Store `payload` and return the reference a [`BodyRef`] will carry
16    /// (a URI, object key, or kv key, bounded by the capsule's cap).
17    async fn put(&self, payload: Vec<u8>) -> Result<String, LaserError>;
18    /// Fetch the bytes behind `reference`.
19    async fn get(&self, reference: &str) -> Result<Vec<u8>, LaserError>;
20}
21
22/// Claim-check `payload` against `store` when it is at or over
23/// `threshold_bytes`: the bytes are `put`, hashed, and replaced by the encoded
24/// [`BodyRef`] capsule with content-type `ref`. Under the threshold the
25/// payload comes back untouched, byte-identical to no claim check at all.
26/// Returns the (possibly replaced) payload and the content type to stamp when
27/// the check fired.
28pub async fn check_in(
29    store: &dyn BlobStore,
30    threshold_bytes: usize,
31    payload: Vec<u8>,
32) -> Result<(Vec<u8>, Option<ContentType>), LaserError> {
33    if payload.len() < threshold_bytes {
34        return Ok((payload, None));
35    }
36    let sha256: [u8; 32] = Sha256::digest(&payload).into();
37    let size_bytes = payload.len() as u64;
38    let reference = store.put(payload).await?;
39    let capsule = BodyRef::new(reference, size_bytes, sha256);
40    let encoded = encode_named(&capsule)
41        .map_err(|error| LaserError::Codec(format!("encode body ref: {error}")))?;
42    Ok((encoded, Some(ContentType::Ref)))
43}
44
45/// Resolve a claim-checked body: decode the [`BodyRef`] capsule from
46/// `payload`, fetch the referenced bytes, and verify their SHA-256 against the
47/// capsule's digest. A mismatch is the typed integrity refusal, never
48/// unverified payload: the capsule's digest is the reader's proof the store
49/// returned what the producer externalized.
50pub async fn resolve_body(store: &dyn BlobStore, payload: &[u8]) -> Result<Vec<u8>, LaserError> {
51    let capsule: BodyRef = decode_named(payload)
52        .map_err(|error| LaserError::Codec(format!("decode body ref: {error}")))?;
53    // The capsule arrives off the log, so its reference is untrusted input to
54    // whatever store the caller supplied. Enforce the wire caps before handing
55    // it over.
56    capsule
57        .validate()
58        .map_err(|error| LaserError::Invalid(format!("body ref: {error}")))?;
59    let payload = store.get(&capsule.reference).await?;
60    // The capsule states the size it externalized. A length that disagrees is
61    // already a failed claim check, caught before hashing the bytes.
62    if payload.len() as u64 != capsule.size_bytes {
63        return Err(LaserError::Integrity {
64            reference: capsule.reference,
65        });
66    }
67    let digest: [u8; 32] = Sha256::digest(&payload).into();
68    if digest.as_slice() != capsule.sha256.as_slice() {
69        return Err(LaserError::Integrity {
70            reference: capsule.reference,
71        });
72    }
73    Ok(payload)
74}
75
76impl crate::agent::AgentMessage {
77    /// The real body of a claim-checked message. It starts from
78    /// [`body`](Self::body): the AGDX envelope's body for an AGDX record, the
79    /// payload otherwise. When the content type is `ref`, that body is the
80    /// capsule, which is decoded, fetched, and digest-verified through
81    /// [`resolve_body`]. Any other content type returns the body as-is. The
82    /// consume-side pairing of the publish builder's `claim_check`.
83    pub async fn resolve_body(&self, store: &dyn BlobStore) -> Result<Vec<u8>, LaserError> {
84        if self.content_type == Some(ContentType::Ref) {
85            return resolve_body(store, self.body()).await;
86        }
87        Ok(self.body().to_vec())
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94    use std::collections::HashMap;
95    use std::sync::Mutex;
96
97    #[derive(Default)]
98    struct MemoryStore {
99        blobs: Mutex<HashMap<String, Vec<u8>>>,
100    }
101
102    #[async_trait]
103    impl BlobStore for MemoryStore {
104        async fn put(&self, payload: Vec<u8>) -> Result<String, LaserError> {
105            let mut blobs = self.blobs.lock().expect("store lock");
106            let reference = format!("blob-{}", blobs.len());
107            blobs.insert(reference.clone(), payload);
108            Ok(reference)
109        }
110
111        async fn get(&self, reference: &str) -> Result<Vec<u8>, LaserError> {
112            self.blobs
113                .lock()
114                .expect("store lock")
115                .get(reference)
116                .cloned()
117                .ok_or_else(|| LaserError::Invalid(format!("no blob at `{reference}`")))
118        }
119    }
120
121    #[tokio::test]
122    async fn given_a_small_body_when_checked_in_then_should_pass_through_byte_identical() {
123        let store = MemoryStore::default();
124        let payload = b"small".to_vec();
125        let (out, content_type) = check_in(&store, 1024, payload.clone())
126            .await
127            .expect("passes through");
128        assert_eq!(out, payload);
129        assert!(content_type.is_none());
130        assert!(store.blobs.lock().expect("store lock").is_empty());
131    }
132
133    #[tokio::test]
134    async fn given_a_large_body_when_checked_in_then_should_round_trip_through_the_store() {
135        let store = MemoryStore::default();
136        let payload = vec![7u8; 4096];
137        let (capsule, content_type) = check_in(&store, 1024, payload.clone())
138            .await
139            .expect("externalizes");
140        assert_eq!(content_type, Some(ContentType::Ref));
141        let resolved = resolve_body(&store, &capsule).await.expect("resolves");
142        assert_eq!(resolved, payload);
143    }
144
145    #[tokio::test]
146    async fn given_a_claim_checked_agdx_record_when_resolved_then_should_resolve_the_envelope_body()
147    {
148        use crate::types::MintUlid;
149        use laser_wire::agent::{AgentEnvelope, ConversationId, RecordId};
150        use std::str::FromStr;
151        let store = MemoryStore::default();
152        let body = vec![9u8; 4096];
153        let (capsule, content_type) = check_in(&store, 1024, body.clone())
154            .await
155            .expect("externalizes");
156        let envelope = AgentEnvelope::event(
157            RecordId::mint(),
158            ConversationId::mint(),
159            laser_wire::agent::AgentId::from_str("worker").expect("a valid agent id"),
160            capsule,
161        );
162        let mut message = crate::testing::agent_message(
163            encode_named(&envelope).expect("the envelope encodes"),
164            crate::provenance::Provenance::builder()
165                .conversation_id(crate::types::ConversationId::new())
166                .build(),
167        );
168        message.envelope = Some(envelope);
169        message.content_type = content_type;
170        assert_eq!(message.resolve_body(&store).await.expect("resolves"), body);
171        message.content_type = None;
172        assert_eq!(
173            message.resolve_body(&store).await.expect("passes through"),
174            message.body(),
175            "a body that was not claim-checked comes back as the envelope body"
176        );
177    }
178
179    #[tokio::test]
180    async fn given_a_tampered_blob_when_resolved_then_should_refuse_with_the_integrity_error() {
181        let store = MemoryStore::default();
182        let payload = vec![7u8; 4096];
183        let (capsule, _) = check_in(&store, 1024, payload).await.expect("externalizes");
184        for blob in store.blobs.lock().expect("store lock").values_mut() {
185            *blob = b"swapped".to_vec();
186        }
187        let refused = resolve_body(&store, &capsule).await;
188        assert!(matches!(refused, Err(LaserError::Integrity { .. })));
189    }
190}