jmap_base_client/blob.rs
1//! Blob upload/download operations and supporting types (RFC 8620 §6.1, §6.2)
2
3use futures::StreamExt as _;
4use jmap_types::Id;
5use reqwest::header::{HeaderValue, CONTENT_TYPE};
6use serde::Deserialize;
7use sha2::{Digest, Sha256};
8
9use crate::client::JmapClient;
10use crate::error::ClientError;
11
12/// Parameters for [`JmapClient::download_blob`].
13///
14/// Use a struct literal to avoid confusion between the six string-typed fields:
15///
16/// ```rust,ignore
17/// client.download_blob(DownloadBlobParams {
18/// download_url_template: &session.download_url,
19/// account_id: "A13824",
20/// blob_id: "Gbc4c...",
21/// name: "attachment.pdf",
22/// accept_type: Some("application/pdf"),
23/// expected_sha256: None,
24/// }).await?;
25/// ```
26#[derive(Debug, Clone, Copy)]
27pub struct DownloadBlobParams<'a> {
28 /// URL template from `Session.download_url`.
29 pub download_url_template: &'a str,
30 /// Account ID that owns the blob.
31 pub account_id: &'a str,
32 /// Server-assigned blob identifier.
33 pub blob_id: &'a str,
34 /// Human-readable filename for the `{name}` template variable.
35 pub name: &'a str,
36 /// Optional accept type for the `{type}` template variable (e.g. `"image/png"`).
37 /// Pass `None` when no content-type preference is needed; `{type}` expands to an
38 /// empty string.
39 pub accept_type: Option<&'a str>,
40 /// Optional expected SHA-256 hex digest for integrity verification.
41 /// Pass `None` to skip the check.
42 pub expected_sha256: Option<&'a str>,
43}
44
45/// Response body returned by a successful blob upload (RFC 8620 §6.1).
46#[non_exhaustive]
47#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
48#[serde(rename_all = "camelCase")]
49pub struct BlobUploadResponse {
50 /// The account the blob was uploaded to.
51 pub account_id: Id,
52 /// Server-assigned opaque blob identifier.
53 pub blob_id: Id,
54 /// Media type of the uploaded blob as determined by the server.
55 #[serde(rename = "type")]
56 pub content_type: String,
57 /// Size of the uploaded blob in bytes.
58 pub size: u64,
59 /// SHA-256 hex digest of the uploaded blob as 64 hex characters (uppercase or
60 /// lowercase), if the server supports the JMAP-CID draft extension (`draft-atwood-jmap-cid`).
61 ///
62 /// This field is **not** defined by RFC 8620. It is present only on servers
63 /// that advertise the `urn:ietf:params:jmap:cid` capability. Servers that
64 /// do not implement the extension omit the field.
65 pub sha256: Option<String>,
66}
67
68/// Expand a RFC 6570 Level-1 URI template by substituting variables.
69///
70/// For each `(name, value)` pair in `vars`, replaces `{name}` in `template`
71/// with the percent-encoded form of `value`. Encoding follows RFC 3986
72/// unreserved characters (ALPHA / DIGIT / `-` / `.` / `_` / `~`), which pass
73/// through unchanged; all other bytes are encoded as `%XX` with uppercase hex
74/// (RFC 3986 §2.1 requires uppercase).
75///
76/// Entries in `vars` whose name does not appear in the template are silently
77/// ignored. A variable that appears in the template but has no entry in `vars`
78/// is an error (`ClientError::InvalidSession`) because templates come from the
79/// server's Session document — an unexpected variable indicates a server bug.
80///
81/// # RFC 6570 Level-1
82/// Only simple-string expansion is supported. Reserved-expansion (`{+var}`)
83/// and other Level-2+ operators are not supported.
84///
85/// # Usage with `subscribe_events`
86///
87/// [`Session::event_source_url`] is a URI template with variables `types`,
88/// `closeafter`, and `ping`. Expand it before calling
89/// [`JmapClient::subscribe_events`]:
90///
91/// ```rust,ignore
92/// let url = expand_url_template(
93/// &session.event_source_url,
94/// &[
95/// ("types", "Email,Mailbox"),
96/// ("closeafter", "state"),
97/// ("ping", "0"),
98/// ],
99/// )?;
100/// client.subscribe_events(&url, None).await?;
101/// ```
102///
103/// [`Session::event_source_url`]: crate::request::Session::event_source_url
104/// [`JmapClient::subscribe_events`]: crate::client::JmapClient::subscribe_events
105pub fn expand_url_template(template: &str, vars: &[(&str, &str)]) -> Result<String, ClientError> {
106 let mut result = String::with_capacity(template.len() + 64);
107 let mut rest = template;
108 while let Some(open) = rest.find('{') {
109 result.push_str(&rest[..open]);
110 rest = &rest[open + 1..];
111 let close = rest.find('}').ok_or_else(|| {
112 ClientError::InvalidSession("URL template has unmatched '{'".to_owned())
113 })?;
114 let name = &rest[..close];
115 rest = &rest[close + 1..];
116 if let Some((_, value)) = vars.iter().find(|(n, _)| *n == name) {
117 result.push_str(&percent_encode(value));
118 } else {
119 return Err(ClientError::InvalidSession(format!(
120 "URL template variable not supplied: {{{name}}}"
121 )));
122 }
123 }
124 result.push_str(rest);
125 Ok(result)
126}
127
128/// Percent-encode a string value per RFC 3986 §2.3 unreserved character set.
129fn percent_encode(value: &str) -> String {
130 let mut out = String::with_capacity(value.len());
131 for byte in value.bytes() {
132 if byte.is_ascii_alphanumeric()
133 || byte == b'-'
134 || byte == b'.'
135 || byte == b'_'
136 || byte == b'~'
137 {
138 out.push(char::from(byte));
139 } else {
140 out.push('%');
141 out.push(hex_nibble_upper(byte >> 4));
142 out.push(hex_nibble_upper(byte & 0x0f));
143 }
144 }
145 out
146}
147
148/// Returns the uppercase hex character for `nibble` (0–15).
149/// Used for percent-encoding (RFC 3986 §2.1 requires uppercase).
150fn hex_nibble_upper(nibble: u8) -> char {
151 match nibble {
152 0..=9 => char::from(b'0' + nibble),
153 10..=15 => char::from(b'A' + nibble - 10),
154 _ => unreachable!("nibble must be 0–15"),
155 }
156}
157
158/// Returns the lowercase hex character for `nibble` (0–15).
159/// Used for SHA-256 hex output (JMAP-CID §1 requires lowercase).
160fn hex_nibble_lower(nibble: u8) -> char {
161 match nibble {
162 0..=9 => char::from(b'0' + nibble),
163 10..=15 => char::from(b'a' + nibble - 10),
164 _ => unreachable!("nibble must be 0–15"),
165 }
166}
167
168impl JmapClient {
169 /// Upload raw bytes to the JMAP blob store (RFC 8620 §6.1).
170 ///
171 /// `upload_url_template` is from `Session.upload_url`; `{accountId}` is
172 /// substituted before the request. `content_type` is sent as the
173 /// `Content-Type` header. If the server returns a `sha256` field
174 /// (JMAP-CID capability), it is verified against the locally-computed
175 /// digest and `ClientError::BlobIntegrityMismatch` is returned on mismatch.
176 pub async fn upload_blob(
177 &self,
178 upload_url_template: &str,
179 account_id: &str,
180 data: bytes::Bytes,
181 content_type: &str,
182 ) -> Result<BlobUploadResponse, ClientError> {
183 crate::client::require_http_url(upload_url_template)?;
184 let ct_hv = HeaderValue::from_str(content_type).map_err(ClientError::InvalidHeaderValue)?;
185 let url = expand_url_template(upload_url_template, &[("accountId", account_id)])?;
186
187 // Compute SHA-256 before handing ownership of data to the request body.
188 let local_sha256 = compute_sha256_hex(&data);
189
190 let req = self.inject_auth(
191 self.http
192 .post(&url)
193 .header(CONTENT_TYPE, ct_hv)
194 .timeout(self.config.request_timeout)
195 .body(data),
196 );
197
198 let resp = req.send().await.map_err(ClientError::Http)?;
199 let status = resp.status();
200 Self::check_auth_status(status)?;
201 let resp = resp.error_for_status().map_err(ClientError::Http)?;
202
203 let upload_limit = self.config.max_upload_body;
204
205 if let Some(len) = resp.content_length() {
206 if len > upload_limit {
207 return Err(ClientError::ResponseTooLarge {
208 actual: len,
209 limit: upload_limit,
210 });
211 }
212 }
213 let bytes = resp.bytes().await.map_err(ClientError::Http)?;
214 if bytes.len() as u64 > upload_limit {
215 return Err(ClientError::ResponseTooLarge {
216 actual: bytes.len() as u64,
217 limit: upload_limit,
218 });
219 }
220 let upload_resp: BlobUploadResponse =
221 serde_json::from_slice(&bytes).map_err(ClientError::Parse)?;
222
223 if let Some(ref server_sha256) = upload_resp.sha256 {
224 if !is_valid_sha256_hex(server_sha256) {
225 return Err(ClientError::InvalidSession(format!(
226 "server sha256 field is not 64-char hex: {server_sha256:?}"
227 )));
228 }
229 // Normalize to lowercase before comparison: JMAP-CID specifies lowercase
230 // but non-conformant servers may return uppercase hex. Both represent the
231 // same digest; rejecting on case alone would cause spurious integrity errors.
232 let server_lower = server_sha256.to_ascii_lowercase();
233 if local_sha256 != server_lower {
234 return Err(ClientError::BlobIntegrityMismatch {
235 expected: local_sha256,
236 actual: server_lower,
237 });
238 }
239 }
240
241 Ok(upload_resp)
242 }
243
244 /// Download a blob by ID (RFC 8620 §6.2).
245 ///
246 /// Template variables `{accountId}`, `{blobId}`, `{name}`, and `{type}` are
247 /// substituted from the corresponding fields of `params` before the GET
248 /// request. `{type}` expands to an empty string when `params.accept_type`
249 /// is `None`; templates that include `?accept={type}` produce `?accept=`.
250 /// If the server does not tolerate an empty `?accept=` parameter, omit
251 /// `{type}` from the `download_url` template in the Session document.
252 ///
253 /// If `params.expected_sha256` is `Some`, the downloaded bytes are verified
254 /// against the hex digest and `ClientError::BlobIntegrityMismatch` is
255 /// returned on mismatch.
256 pub async fn download_blob(
257 &self,
258 params: DownloadBlobParams<'_>,
259 ) -> Result<bytes::Bytes, ClientError> {
260 let DownloadBlobParams {
261 download_url_template,
262 account_id,
263 blob_id,
264 name,
265 accept_type,
266 expected_sha256,
267 } = params;
268 crate::client::require_http_url(download_url_template)?;
269 let vars = [
270 ("accountId", account_id),
271 ("blobId", blob_id),
272 ("name", name),
273 // Always supply {type} — even as empty string — so templates
274 // containing `?accept={type}` expand cleanly rather than triggering
275 // the unexpanded-placeholder error.
276 ("type", accept_type.unwrap_or("")),
277 ];
278 let url = expand_url_template(download_url_template, &vars)?;
279
280 let req = self.inject_auth(self.http.get(&url).timeout(self.config.request_timeout));
281
282 let resp = req.send().await.map_err(ClientError::Http)?;
283 let status = resp.status();
284 Self::check_auth_status(status)?;
285 let resp = resp.error_for_status().map_err(ClientError::Http)?;
286
287 let download_limit = self.config.max_download_body;
288
289 // Early rejection on Content-Length header. Does not replace the streaming
290 // check below: Content-Length can lie or be absent.
291 if let Some(len) = resp.content_length() {
292 if len > download_limit {
293 return Err(ClientError::ResponseTooLarge {
294 actual: len,
295 limit: download_limit,
296 });
297 }
298 }
299
300 // Stream body chunk-by-chunk and enforce the cap before each accumulation.
301 // This prevents buffering a response that exceeds the limit when Content-Length
302 // is absent or lying — without this, resp.bytes().await would buffer the full
303 // response before the check could fire.
304 let mut stream = resp.bytes_stream();
305 let mut body: Vec<u8> = Vec::new();
306 while let Some(chunk) = stream.next().await {
307 let chunk = chunk.map_err(ClientError::Http)?;
308 let new_len = body.len() as u64 + chunk.len() as u64;
309 if new_len > download_limit {
310 return Err(ClientError::ResponseTooLarge {
311 actual: new_len,
312 limit: download_limit,
313 });
314 }
315 body.extend_from_slice(&chunk);
316 }
317 let bytes = bytes::Bytes::from(body);
318
319 if let Some(expected) = expected_sha256 {
320 if !is_valid_sha256_hex(expected) {
321 return Err(ClientError::InvalidArgument(format!(
322 "expected_sha256 is not 64-char hex: {expected:?}"
323 )));
324 }
325 let actual = compute_sha256_hex(&bytes);
326 // Normalize expected to lowercase; callers may hold uppercase hex from
327 // a server or external source. Both represent the same digest.
328 let expected_lower = expected.to_ascii_lowercase();
329 if actual != expected_lower {
330 return Err(ClientError::BlobIntegrityMismatch {
331 expected: expected_lower,
332 actual,
333 });
334 }
335 }
336
337 Ok(bytes)
338 }
339}
340
341/// Returns `true` if `s` is exactly 64 hex characters (uppercase or lowercase).
342///
343/// Callers are responsible for producing the appropriate [`ClientError`] variant:
344/// - server-provided digest (upload response `sha256` field) → [`ClientError::InvalidSession`]
345/// - caller-supplied expected digest (`download_blob` `expected_sha256` arg) → [`ClientError::InvalidArgument`]
346fn is_valid_sha256_hex(s: &str) -> bool {
347 s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
348}
349
350/// Compute SHA-256 of `data` and return as 64-char lowercase hex string.
351fn compute_sha256_hex(data: &[u8]) -> String {
352 let hash = Sha256::digest(data);
353 hash.iter().fold(String::with_capacity(64), |mut s, b| {
354 s.push(hex_nibble_lower(*b >> 4));
355 s.push(hex_nibble_lower(*b & 0x0f));
356 s
357 })
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363
364 // Oracle: RFC 6570 §1.2 — simple substitution with unreserved-only value
365 #[test]
366 fn expand_upload_url() {
367 let result = expand_url_template(
368 "https://example.com/upload/{accountId}/",
369 &[("accountId", "account1")],
370 )
371 .expect("must succeed");
372 assert_eq!(result, "https://example.com/upload/account1/");
373 }
374
375 // Oracle: RFC 3986 §2.1 — space 0x20 encodes as %20
376 #[test]
377 fn expand_download_url_with_spaces() {
378 let result = expand_url_template(
379 "/download/{accountId}/{blobId}/{name}",
380 &[
381 ("accountId", "acc1"),
382 ("blobId", "blob-123"),
383 ("name", "my file.png"),
384 ],
385 )
386 .expect("must succeed");
387 assert_eq!(result, "/download/acc1/blob-123/my%20file.png");
388 }
389
390 // Oracle: RFC 3986 §2.1 — slash 0x2F encodes as %2F
391 #[test]
392 fn expand_with_slash_in_type() {
393 let result = expand_url_template(
394 "/dl/{accountId}/{blobId}/{name}?accept={type}",
395 &[
396 ("accountId", "a"),
397 ("blobId", "b"),
398 ("name", "x.jpg"),
399 ("type", "image/png"),
400 ],
401 )
402 .expect("must succeed");
403 assert_eq!(result, "/dl/a/b/x.jpg?accept=image%2Fpng");
404 }
405
406 // Oracle: expand_url_template must error when a template variable has no
407 // entry in vars — the template comes from the server's Session document,
408 // so an unrecognized variable name indicates a server-side bug.
409 #[test]
410 fn expand_unknown_variable_returns_error() {
411 let err = expand_url_template(
412 "https://example.com/upload/{accountId}/{unknownVar}/",
413 &[("accountId", "acc1")],
414 )
415 .expect_err("must fail when template variable is not supplied");
416 assert!(
417 matches!(err, crate::error::ClientError::InvalidSession(_)),
418 "expected InvalidSession, got {err:?}"
419 );
420 }
421
422 // Oracle: expand_url_template must error on a template with an unmatched
423 // '{' — no corresponding '}' exists. The expected error is InvalidSession
424 // because templates come from the server's Session document.
425 #[test]
426 fn expand_unmatched_open_brace_returns_error() {
427 let err = expand_url_template("https://example.com/{unclosed", &[])
428 .expect_err("unmatched '{' must return an error");
429 assert!(
430 matches!(err, crate::error::ClientError::InvalidSession(_)),
431 "expected InvalidSession for unmatched brace, got {err:?}"
432 );
433 }
434
435 // Oracle: vars entries whose name does not appear in the template are
436 // silently ignored — extra vars are benign (caller may pass a superset).
437 #[test]
438 fn expand_unused_var_is_ignored() {
439 let result = expand_url_template(
440 "https://example.com/upload/{accountId}/",
441 &[("accountId", "acc1"), ("extraVar", "value")],
442 )
443 .expect("extra vars must be silently ignored");
444 assert_eq!(result, "https://example.com/upload/acc1/");
445 }
446
447 // Oracle: tests/fixtures/blob/upload_response.json — hand-written fixture
448 // derived from RFC 8620 §6.1 blob upload response shape; not produced by
449 // the code under test.
450 #[test]
451 fn blob_upload_response_deserializes() {
452 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
453 .join("tests/fixtures/blob/upload_response.json");
454 let text =
455 std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("cannot read fixture: {e}"));
456 let resp: BlobUploadResponse =
457 serde_json::from_str(&text).expect("fixture must deserialize as BlobUploadResponse");
458
459 assert_eq!(resp.account_id, "account1");
460 assert_eq!(resp.blob_id, "Gbc4c377-c8c3-4b48-b2bb-8c1e4cfb8b2a");
461 assert_eq!(resp.content_type, "image/png");
462 assert_eq!(resp.size, 48291);
463 assert_eq!(
464 resp.sha256.as_deref(),
465 Some("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")
466 );
467 }
468}