1use std::collections::BTreeMap;
12use std::future::Future;
13use std::pin::Pin;
14use std::sync::Arc;
15
16use bytes::Bytes;
17use mdcast_api::{AssetManifest, Digest};
18use sha2::{Digest as _, Sha256};
19
20use crate::error::{Error, Result};
21
22pub fn sha256(bytes: &[u8]) -> Digest {
24 Digest::from_sha256(Sha256::digest(bytes).into())
25}
26
27pub type BlobFetchError = Box<dyn std::error::Error + Send + Sync>;
32
33type FetchFuture = Pin<Box<dyn Future<Output = std::result::Result<Bytes, BlobFetchError>> + Send>>;
34
35type Fetch = Arc<dyn Fn() -> FetchFuture + Send + Sync>;
40
41#[derive(Clone)]
44enum Entry {
45 Bytes(Bytes),
46 Lazy { digest: Digest, fetch: Fetch },
47}
48
49impl Entry {
50 fn digest(&self) -> Digest {
53 match self {
54 Entry::Bytes(bytes) => sha256(bytes),
55 Entry::Lazy { digest, .. } => digest.clone(),
56 }
57 }
58}
59
60impl std::fmt::Debug for Entry {
61 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62 match self {
63 Entry::Bytes(bytes) => f.debug_tuple("Bytes").field(bytes).finish(),
64 Entry::Lazy { digest, .. } => f
65 .debug_struct("Lazy")
66 .field("digest", digest)
67 .finish_non_exhaustive(),
68 }
69 }
70}
71
72#[derive(Debug, Clone, Default)]
74pub struct AssetBundle {
75 assets: BTreeMap<String, Entry>,
76}
77
78impl AssetBundle {
79 pub fn new() -> Self {
80 Self::default()
81 }
82
83 pub fn insert(&mut self, key: impl Into<String>, bytes: impl Into<Bytes>) -> Option<Bytes> {
86 match self.assets.insert(key.into(), Entry::Bytes(bytes.into())) {
87 Some(Entry::Bytes(bytes)) => Some(bytes),
88 _ => None,
89 }
90 }
91
92 #[must_use]
94 pub fn with(mut self, key: impl Into<String>, bytes: impl Into<Bytes>) -> Self {
95 self.insert(key, bytes);
96 self
97 }
98
99 pub fn insert_digest<F, Fut, E>(
110 &mut self,
111 key: impl Into<String>,
112 digest: Digest,
113 fetch: F,
114 ) -> Option<Bytes>
115 where
116 F: Fn() -> Fut + Send + Sync + 'static,
117 Fut: Future<Output = std::result::Result<Bytes, E>> + Send + 'static,
118 E: std::error::Error + Send + Sync + 'static,
119 {
120 let fetch: Fetch = Arc::new(move || {
121 let fut = fetch();
122 Box::pin(async move { fut.await.map_err(|e| Box::new(e) as BlobFetchError) })
123 });
124 match self
125 .assets
126 .insert(key.into(), Entry::Lazy { digest, fetch })
127 {
128 Some(Entry::Bytes(bytes)) => Some(bytes),
129 _ => None,
130 }
131 }
132
133 #[must_use]
135 pub fn with_digest<F, Fut, E>(
136 mut self,
137 key: impl Into<String>,
138 digest: Digest,
139 fetch: F,
140 ) -> Self
141 where
142 F: Fn() -> Fut + Send + Sync + 'static,
143 Fut: Future<Output = std::result::Result<Bytes, E>> + Send + 'static,
144 E: std::error::Error + Send + Sync + 'static,
145 {
146 self.insert_digest(key, digest, fetch);
147 self
148 }
149
150 pub fn get(&self, key: &str) -> Option<&Bytes> {
155 match self.assets.get(key)? {
156 Entry::Bytes(bytes) => Some(bytes),
157 Entry::Lazy { .. } => None,
158 }
159 }
160
161 pub fn keys(&self) -> impl Iterator<Item = &str> {
162 self.assets.keys().map(String::as_str)
163 }
164
165 pub fn len(&self) -> usize {
166 self.assets.len()
167 }
168
169 pub fn is_empty(&self) -> bool {
170 self.assets.is_empty()
171 }
172
173 pub fn manifest(&self) -> AssetManifest {
176 self.digests()
177 .into_iter()
178 .map(|(key, digest)| (key.to_string(), digest))
179 .collect()
180 }
181
182 pub(crate) fn digests(&self) -> Vec<(&str, Digest)> {
185 self.assets
186 .iter()
187 .map(|(key, entry)| (key.as_str(), entry.digest()))
188 .collect()
189 }
190
191 pub(crate) async fn resolve(&self, key: &str, expected: &Digest) -> Result<Bytes> {
199 match self.assets.get(key) {
200 None => Err(Error::MissingAsset {
201 key: key.to_string(),
202 }),
203 Some(Entry::Bytes(bytes)) => Ok(bytes.clone()),
204 Some(Entry::Lazy { fetch, .. }) => {
205 let bytes = fetch().await.map_err(|source| Error::BlobFetchFailed {
206 key: key.to_string(),
207 source,
208 })?;
209 let actual = sha256(&bytes);
210 if &actual != expected {
211 return Err(Error::DigestMismatch {
212 key: key.to_string(),
213 declared: expected.clone(),
214 actual,
215 });
216 }
217 Ok(bytes)
218 }
219 }
220 }
221
222 pub(crate) fn bind(&self, declared: AssetManifest) -> Result<AssetManifest> {
232 let computed = self.manifest();
233 if declared.is_empty() {
234 return Ok(computed);
235 }
236 for (key, digest) in declared.entries() {
237 match computed.get(key) {
238 None => {
239 return Err(Error::MissingAsset {
240 key: key.to_string(),
241 });
242 }
243 Some(actual) if actual != digest => {
244 return Err(Error::DigestMismatch {
245 key: key.to_string(),
246 declared: digest.clone(),
247 actual: actual.clone(),
248 });
249 }
250 Some(_) => {}
251 }
252 }
253 Ok(declared)
254 }
255}
256
257impl<K: Into<String>, V: Into<Bytes>> FromIterator<(K, V)> for AssetBundle {
258 fn from_iter<I: IntoIterator<Item = (K, V)>>(iter: I) -> Self {
259 Self {
260 assets: iter
261 .into_iter()
262 .map(|(k, v)| (k.into(), Entry::Bytes(v.into())))
263 .collect(),
264 }
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271
272 const EMPTY: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
275 const HELLO: &str = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
276
277 fn bundle() -> AssetBundle {
278 AssetBundle::new()
279 .with("img/logo.png", &b"hello"[..])
280 .with("reference/reference.docx", &b""[..])
281 }
282
283 #[test]
284 fn sha256_matches_the_known_vectors() {
285 assert_eq!(sha256(b"").as_str(), EMPTY);
286 assert_eq!(sha256(b"hello").as_str(), HELLO);
287 }
288
289 #[test]
290 fn manifest_names_every_asset_by_its_digest() {
291 let manifest = bundle().manifest();
292
293 assert_eq!(manifest.len(), 2);
294 assert_eq!(
295 manifest.get("img/logo.png").map(Digest::as_str),
296 Some(HELLO)
297 );
298 assert_eq!(
299 manifest.get("reference/reference.docx").map(Digest::as_str),
300 Some(EMPTY)
301 );
302 }
303
304 #[test]
305 fn an_empty_bundle_produces_an_empty_manifest() {
306 let empty = AssetBundle::new();
307 assert!(empty.is_empty());
308 assert!(empty.manifest().is_empty());
309 }
310
311 #[test]
312 fn bind_fills_an_undeclared_manifest_from_the_bundle() {
313 assert_eq!(
314 bundle().bind(AssetManifest::new()).unwrap(),
315 bundle().manifest()
316 );
317 }
318
319 #[test]
320 fn bind_keeps_a_declared_manifest_that_the_bundle_satisfies() {
321 let declared = AssetManifest::from_iter([("img/logo.png".to_string(), sha256(b"hello"))]);
322
323 let bound = bundle().bind(declared.clone()).unwrap();
324
325 assert_eq!(bound, declared, "a partial manifest is a deliberate choice");
326 assert_eq!(bound.len(), 1);
327 }
328
329 #[test]
330 fn bind_rejects_a_key_the_bundle_has_no_bytes_for() {
331 let declared = AssetManifest::from_iter([("img/absent.png".to_string(), sha256(b"x"))]);
332
333 match bundle().bind(declared) {
334 Err(Error::MissingAsset { key }) => assert_eq!(key, "img/absent.png"),
335 other => panic!("expected MissingAsset, got {other:?}"),
336 }
337 }
338
339 #[test]
340 fn bind_rejects_a_digest_that_disagrees_with_the_bytes() {
341 let declared =
342 AssetManifest::from_iter([("img/logo.png".to_string(), sha256(b"something else"))]);
343
344 match bundle().bind(declared) {
345 Err(Error::DigestMismatch { key, actual, .. }) => {
346 assert_eq!(key, "img/logo.png");
347 assert_eq!(actual.as_str(), HELLO);
348 }
349 other => panic!("expected DigestMismatch, got {other:?}"),
350 }
351 }
352
353 #[test]
354 fn insert_replaces_and_reports_the_previous_bytes() {
355 let mut bundle = AssetBundle::new();
356
357 assert_eq!(bundle.insert("k", &b"one"[..]), None);
358 assert_eq!(bundle.insert("k", &b"two"[..]), Some(Bytes::from("one")));
359 assert_eq!(bundle.get("k").map(|b| &b[..]), Some(&b"two"[..]));
360 assert_eq!(bundle.len(), 1);
361 }
362
363 #[test]
364 fn collects_from_pairs_and_reports_its_keys_in_order() {
365 let bundle = AssetBundle::from_iter([("b/2.png", "two"), ("a/1.png", "one")]);
366
367 assert_eq!(
368 bundle.keys().collect::<Vec<_>>(),
369 vec!["a/1.png", "b/2.png"]
370 );
371 }
372
373 #[derive(Debug, thiserror::Error)]
374 #[error("boom")]
375 struct Boom;
376
377 fn digest_only_bundle(calls: Arc<std::sync::atomic::AtomicUsize>) -> AssetBundle {
378 AssetBundle::new()
379 .with("img/logo.png", &b"hello"[..])
380 .with_digest("img/photo.jpg", sha256(b"photo"), move || {
381 let calls = Arc::clone(&calls);
382 async move {
383 calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
384 Ok::<Bytes, Boom>(Bytes::from_static(b"photo"))
385 }
386 })
387 }
388
389 #[test]
390 fn manifest_and_bind_never_call_a_digest_only_entrys_fetch() {
391 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
392 let bundle = digest_only_bundle(Arc::clone(&calls));
393
394 let manifest = bundle.manifest();
395 bundle.bind(AssetManifest::new()).unwrap();
396
397 assert_eq!(
398 manifest.get("img/photo.jpg").map(Digest::as_str),
399 Some(sha256(b"photo").as_str())
400 );
401 assert_eq!(
402 calls.load(std::sync::atomic::Ordering::SeqCst),
403 0,
404 "manifest/bind must never fetch a digest-only entry's bytes"
405 );
406 }
407
408 #[test]
409 fn get_reports_none_for_a_digest_only_entry() {
410 let bundle = digest_only_bundle(Arc::new(std::sync::atomic::AtomicUsize::new(0)));
411
412 assert_eq!(bundle.get("img/photo.jpg"), None);
413 assert_eq!(
414 bundle.get("img/logo.png").map(|b| &b[..]),
415 Some(&b"hello"[..])
416 );
417 }
418
419 #[tokio::test]
420 async fn resolve_fetches_and_verifies_a_digest_only_entry() {
421 let calls = Arc::new(std::sync::atomic::AtomicUsize::new(0));
422 let bundle = digest_only_bundle(Arc::clone(&calls));
423
424 let bytes = bundle
425 .resolve("img/photo.jpg", &sha256(b"photo"))
426 .await
427 .unwrap();
428
429 assert_eq!(&bytes[..], b"photo");
430 assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1);
431 }
432
433 #[tokio::test]
434 async fn resolve_reports_a_failed_fetch_as_blob_fetch_failed() {
435 let bundle = AssetBundle::new().with_digest("img/photo.jpg", sha256(b"photo"), || async {
436 Err::<Bytes, Boom>(Boom)
437 });
438
439 match bundle.resolve("img/photo.jpg", &sha256(b"photo")).await {
440 Err(Error::BlobFetchFailed { key, .. }) => assert_eq!(key, "img/photo.jpg"),
441 other => panic!("expected BlobFetchFailed, got {other:?}"),
442 }
443 }
444
445 #[tokio::test]
446 async fn resolve_reports_bytes_that_hash_to_the_wrong_digest_as_a_mismatch() {
447 let bundle = AssetBundle::new().with_digest("img/photo.jpg", sha256(b"photo"), || async {
448 Ok::<Bytes, Boom>(Bytes::from_static(b"not the declared photo"))
449 });
450
451 match bundle.resolve("img/photo.jpg", &sha256(b"photo")).await {
452 Err(Error::DigestMismatch {
453 key,
454 declared,
455 actual,
456 }) => {
457 assert_eq!(key, "img/photo.jpg");
458 assert_eq!(declared, sha256(b"photo"));
459 assert_eq!(actual, sha256(b"not the declared photo"));
460 }
461 other => panic!("expected DigestMismatch, got {other:?}"),
462 }
463 }
464
465 #[test]
466 fn with_digest_replacing_an_eager_entry_reports_its_previous_bytes() {
467 let mut bundle = AssetBundle::new();
468 bundle.insert("k", &b"one"[..]);
469
470 let previous = bundle.insert_digest("k", sha256(b"two"), || async {
471 Ok::<Bytes, Boom>(Bytes::new())
472 });
473
474 assert_eq!(previous, Some(Bytes::from("one")));
475 }
476}