1use bytes::Bytes;
11use mdcast_api::Digest;
12use mdcast_api::wire::{BlobCheckRequest, BlobCheckResponse, MissingBlob};
13use reqwest::Body;
14use reqwest::multipart::{Form, Part};
15
16use crate::bundle::AssetBundle;
17use crate::client::{BLOBS, BLOBS_CHECK, Client};
18use crate::error::{Error, Result};
19
20type UploadSet = Vec<(Digest, Bytes)>;
22
23impl Client {
24 pub async fn check(&self, bundle: &AssetBundle) -> Result<Vec<Digest>> {
28 let digests: Vec<Digest> = bundle
29 .digests()
30 .into_iter()
31 .map(|(_, digest)| digest)
32 .collect();
33 if digests.is_empty() {
34 return Ok(Vec::new());
35 }
36
37 let response = self
38 .post_json(BLOBS_CHECK, &BlobCheckRequest { digests })
39 .await?;
40 let body: BlobCheckResponse = self.json_response(response).await?;
41 Ok(body.missing)
42 }
43
44 pub async fn upload(&self, bundle: &AssetBundle, digests: &[Digest]) -> Result<()> {
48 self.upload_set(select_by_digest(digests, bundle).await?)
49 .await
50 }
51
52 pub async fn preflight(&self, bundle: &AssetBundle) -> Result<Vec<Digest>> {
56 let missing = self.check(bundle).await?;
57 self.upload(bundle, &missing).await?;
58 Ok(missing)
59 }
60
61 pub(crate) async fn upload_missing(
65 &self,
66 missing: &[MissingBlob],
67 bundle: &AssetBundle,
68 ) -> Result<()> {
69 self.upload_set(select_missing(missing, bundle).await?)
70 .await
71 }
72
73 async fn upload_set(&self, parts: UploadSet) -> Result<()> {
74 if parts.is_empty() {
75 return Ok(());
76 }
77
78 let mut form = Form::new();
79 for (digest, bytes) in parts {
80 let length = bytes.len() as u64;
81 form = form.part(
84 digest.to_string(),
85 Part::stream_with_length(Body::from(bytes), length),
86 );
87 }
88
89 let response = self
90 .http
91 .post(self.url(BLOBS))
92 .bearer_auth(&self.token)
93 .multipart(form)
94 .send()
95 .await?;
96 self.expect_success(response).await
97 }
98}
99
100async fn select_missing(missing: &[MissingBlob], bundle: &AssetBundle) -> Result<UploadSet> {
105 let mut parts = Vec::with_capacity(missing.len());
106 for blob in missing {
107 let bytes = bundle.resolve(&blob.key, &blob.digest).await?;
108 parts.push((blob.digest.clone(), bytes));
109 }
110 Ok(parts)
111}
112
113async fn select_by_digest(digests: &[Digest], bundle: &AssetBundle) -> Result<UploadSet> {
118 let catalogue = bundle.digests();
119 let mut parts: UploadSet = Vec::with_capacity(digests.len());
120
121 for digest in digests {
122 if parts.iter().any(|(taken, _)| taken == digest) {
123 continue;
124 }
125 let key = catalogue
126 .iter()
127 .find(|(_, candidate)| candidate == digest)
128 .map(|(key, _)| *key)
129 .ok_or_else(|| Error::UnknownDigest {
130 digest: digest.clone(),
131 })?;
132 let bytes = bundle.resolve(key, digest).await?;
133 parts.push((digest.clone(), bytes));
134 }
135 Ok(parts)
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use crate::bundle::sha256;
142
143 fn bundle() -> AssetBundle {
144 AssetBundle::new()
145 .with("img/kept.png", &b"kept"[..])
146 .with("img/gone.png", &b"gone"[..])
147 .with("img/also-gone.png", &b"also gone"[..])
148 }
149
150 fn missing(keys: &[&str]) -> Vec<MissingBlob> {
151 keys.iter()
152 .map(|key| MissingBlob {
153 key: key.to_string(),
154 digest: sha256(&bundle().get(key).expect("a bundled key")[..]),
155 })
156 .collect()
157 }
158
159 #[tokio::test]
160 async fn a_409_uploads_only_the_blobs_it_named() {
161 let parts = select_missing(&missing(&["img/gone.png", "img/also-gone.png"]), &bundle())
162 .await
163 .expect("both are in the bundle");
164
165 assert_eq!(parts.len(), 2, "never the whole bundle");
166 assert_eq!(parts[0].0, sha256(b"gone"));
167 assert_eq!(&parts[0].1[..], b"gone");
168 assert_eq!(parts[1].0, sha256(b"also gone"));
169 assert!(
170 !parts.iter().any(|(d, _)| *d == sha256(b"kept")),
171 "a blob the server already holds must not be re-uploaded"
172 );
173 }
174
175 #[tokio::test]
176 async fn a_409_naming_a_key_the_bundle_lacks_fails_before_the_upload() {
177 let phantom = vec![MissingBlob {
178 key: "img/never-supplied.png".to_string(),
179 digest: sha256(b"whatever"),
180 }];
181
182 match select_missing(&phantom, &bundle()).await {
183 Err(Error::MissingAsset { key }) => assert_eq!(key, "img/never-supplied.png"),
184 other => panic!("expected MissingAsset, got {other:?}"),
185 }
186 }
187
188 #[tokio::test]
189 async fn an_empty_missing_list_selects_nothing() {
190 assert!(select_missing(&[], &bundle()).await.unwrap().is_empty());
191 assert!(select_by_digest(&[], &bundle()).await.unwrap().is_empty());
192 }
193
194 #[tokio::test]
195 async fn selecting_by_digest_finds_the_bytes_that_hash_to_it() {
196 let parts = select_by_digest(&[sha256(b"kept"), sha256(b"gone")], &bundle())
197 .await
198 .unwrap();
199
200 assert_eq!(parts.len(), 2);
201 assert_eq!(&parts[0].1[..], b"kept");
202 assert_eq!(&parts[1].1[..], b"gone");
203 }
204
205 #[tokio::test]
206 async fn selecting_by_digest_deduplicates_shared_content() {
207 let bundle = AssetBundle::new()
208 .with("a.png", &b"same"[..])
209 .with("b.png", &b"same"[..]);
210 let digest = sha256(b"same");
211
212 let parts = select_by_digest(&[digest.clone(), digest], &bundle)
213 .await
214 .unwrap();
215
216 assert_eq!(parts.len(), 1, "one blob is one part, however many keys");
217 }
218
219 #[tokio::test]
220 async fn selecting_an_unknown_digest_is_an_error() {
221 let stranger = sha256(b"nothing in the bundle");
222
223 match select_by_digest(std::slice::from_ref(&stranger), &bundle()).await {
224 Err(Error::UnknownDigest { digest }) => assert_eq!(digest, stranger),
225 other => panic!("expected UnknownDigest, got {other:?}"),
226 }
227 }
228
229 #[tokio::test]
230 async fn a_409_naming_a_digest_only_entry_fetches_its_bytes() {
231 let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
232 let counted = std::sync::Arc::clone(&calls);
233 let bundle = AssetBundle::new().with_digest("img/lazy.png", sha256(b"lazy"), move || {
234 let counted = std::sync::Arc::clone(&counted);
235 async move {
236 counted.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
237 Ok::<Bytes, std::convert::Infallible>(Bytes::from_static(b"lazy"))
238 }
239 });
240 let named = vec![MissingBlob {
241 key: "img/lazy.png".to_string(),
242 digest: sha256(b"lazy"),
243 }];
244
245 let parts = select_missing(&named, &bundle).await.unwrap();
246
247 assert_eq!(parts.len(), 1);
248 assert_eq!(&parts[0].1[..], b"lazy");
249 assert_eq!(
250 calls.load(std::sync::atomic::Ordering::SeqCst),
251 1,
252 "the fetch runs exactly when the server names this digest"
253 );
254 }
255
256 #[tokio::test]
257 async fn selecting_by_digest_never_fetches_an_entry_that_isnt_chosen() {
258 let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
259 let counted = std::sync::Arc::clone(&calls);
260 let bundle = AssetBundle::new()
261 .with("img/kept.png", &b"kept"[..])
262 .with_digest("img/lazy.png", sha256(b"lazy"), move || {
263 let counted = std::sync::Arc::clone(&counted);
264 async move {
265 counted.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
266 Ok::<Bytes, std::convert::Infallible>(Bytes::from_static(b"lazy"))
267 }
268 });
269
270 let parts = select_by_digest(&[sha256(b"kept")], &bundle).await.unwrap();
271
272 assert_eq!(parts.len(), 1);
273 assert_eq!(
274 calls.load(std::sync::atomic::Ordering::SeqCst),
275 0,
276 "a digest not asked for must never be fetched"
277 );
278 }
279}