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#[async_trait]
14pub trait BlobStore: Send + Sync {
15 async fn put(&self, payload: Vec<u8>) -> Result<String, LaserError>;
18 async fn get(&self, reference: &str) -> Result<Vec<u8>, LaserError>;
20}
21
22pub 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
45pub 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 capsule
57 .validate()
58 .map_err(|error| LaserError::Invalid(format!("body ref: {error}")))?;
59 let payload = store.get(&capsule.reference).await?;
60 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 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}