Skip to main content

mdcast_client/
bundle.rs

1//! The caller-facing half of asset negotiation: a bag of `key → bytes` that
2//! knows how to hash itself.
3//!
4//! Nobody using this crate should ever compute a sha256 or hand-build an
5//! [`AssetManifest`] — that is the whole point. Fill an [`AssetBundle`] with
6//! the same flat `AssetProvider` keys mdcast uses everywhere else
7//! (`img/logo.png`, `typst/layouts/pdf/hero.typ`, `reference/reference.docx`),
8//! hand it to a render call, and the digests, the manifest, and the upload of
9//! whatever the server is missing all follow from it.
10
11use 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
22/// The sha256 of `bytes`, in the form the wire protocol names blobs by.
23pub fn sha256(bytes: &[u8]) -> Digest {
24    Digest::from_sha256(Sha256::digest(bytes).into())
25}
26
27/// The error a digest-only entry's `fetch` closure failed with. Opaque on
28/// purpose — the caller's storage layer (a DB, a filesystem, an object store)
29/// is not this crate's concern, only that the failure is reported rather than
30/// swallowed.
31pub type BlobFetchError = Box<dyn std::error::Error + Send + Sync>;
32
33type FetchFuture = Pin<Box<dyn Future<Output = std::result::Result<Bytes, BlobFetchError>> + Send>>;
34
35/// Type-erased so entries backed by different closures can share one map.
36/// `Fn`, not `FnOnce`: a fetch may run again if the caller reuses the bundle
37/// across renders, though within a single render it runs at most once — only
38/// if the server's `409` actually names this digest.
39type Fetch = Arc<dyn Fn() -> FetchFuture + Send + Sync>;
40
41/// One bundle entry: bytes already in hand, or a digest with bytes fetched
42/// lazily on demand.
43#[derive(Clone)]
44enum Entry {
45    Bytes(Bytes),
46    Lazy { digest: Digest, fetch: Fetch },
47}
48
49impl Entry {
50    /// The digest this entry is known by — computed on the spot for eager
51    /// bytes, already known (no fetch) for a lazy entry.
52    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/// Asset key → bytes (or a digest with bytes fetched lazily), for one render.
73#[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    /// Declare `key`, returning the bytes it previously held (a previously
84    /// digest-only entry reports `None` — there were no bytes in hand).
85    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    /// [`AssetBundle::insert`] for chaining at a call site.
93    #[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    /// Declare `key` by digest only: `manifest()`/`bind()` use `digest`
100    /// without ever calling `fetch`, which only runs if the server's `409`
101    /// actually names this digest — the steady-state case for a caller whose
102    /// storage is already content-addressed never touches the bytes at all.
103    /// `fetch` may be called again on a retry of the same bundle, so it
104    /// should be idempotent; a fetch that fails, or whose bytes hash to
105    /// something other than `digest`, surfaces as
106    /// [`Error::BlobFetchFailed`](crate::Error::BlobFetchFailed) or
107    /// [`Error::DigestMismatch`](crate::Error::DigestMismatch) when the
108    /// upload it was needed for runs — never a silent skip.
109    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    /// [`AssetBundle::insert_digest`] for chaining at a call site.
134    #[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    /// The bytes `key` holds, if it was declared eagerly. `None` both for an
151    /// absent key and for one declared with [`AssetBundle::with_digest`] —
152    /// use [`Client::check`](crate::Client::check) / render to reach a
153    /// digest-only entry's bytes, since fetching them is async.
154    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    /// The manifest naming every asset in the bundle by digest. Never
174    /// touches a digest-only entry's bytes.
175    pub fn manifest(&self) -> AssetManifest {
176        self.digests()
177            .into_iter()
178            .map(|(key, digest)| (key.to_string(), digest))
179            .collect()
180    }
181
182    /// Every asset's key and digest — cheap, and safe for a digest-only entry
183    /// since it never calls `fetch`.
184    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    /// The bytes named by `key`, verified against `expected`. Eager bytes are
192    /// just cloned (as before, untrusted against `expected` — the bundle is
193    /// the source of truth for its own bytes). A digest-only entry's `fetch`
194    /// runs here: a failure becomes [`Error::BlobFetchFailed`], and bytes
195    /// that hash to something other than `expected` become
196    /// [`Error::DigestMismatch`] — never silently skipped, since the caller's
197    /// next attempt would just `409` on the same digest again.
198    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    /// The manifest a render request will actually carry.
223    ///
224    /// An empty `declared` (the common case — the caller never touched the
225    /// field) is filled from the bundle. A caller that did build its own is
226    /// verified against the bundle instead of trusted: a key with no bytes, or
227    /// a digest that disagrees with those bytes, fails here rather than after
228    /// a round trip, because a `409` can only name a digest and would leave the
229    /// caller guessing which of its assets went wrong. Only touches digests —
230    /// a digest-only entry's bytes are never fetched for this check.
231    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    /// The published sha256 of the empty input and of "hello" — a check that
273    /// this hashes what it claims to, not merely something stable.
274    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}