Skip to main content

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}