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 std::borrow::Cow;
4
5use jmap_types::Id;
6use reqwest::header::{HeaderValue, CONTENT_TYPE};
7use serde::{Deserialize, Serialize};
8use sha2::{Digest, Sha256};
9use subtle::ConstantTimeEq;
10
11use crate::client::{read_capped_body, JmapClient};
12use crate::error::ClientError;
13
14/// Parameters for [`JmapClient::download_blob`].
15///
16/// Use a struct literal to avoid confusion between the string-typed fields:
17///
18/// ```rust,ignore
19/// client.download_blob(DownloadBlobParams {
20///     download_url_template: &session.download_url,
21///     account_id: "A13824",
22///     blob_id: "Gbc4c...",
23///     name: "attachment.pdf",
24///     accept_type: Some("application/pdf"),
25///     expected_sha256: None,
26/// }).await?;
27/// ```
28///
29/// To request integrity verification, construct a typed
30/// [`jmap_cid_types::Sha256`] and pass a borrow:
31///
32/// ```rust,ignore
33/// let expected = jmap_cid_types::Sha256::from_hex(
34///     "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
35/// )?;
36/// client.download_blob(DownloadBlobParams {
37///     // ... other fields ...
38///     expected_sha256: Some(&expected),
39///     # download_url_template: "",
40///     # account_id: "",
41///     # blob_id: "",
42///     # name: "",
43///     # accept_type: None,
44/// }).await?;
45/// ```
46#[derive(Debug, Clone, Copy)]
47pub struct DownloadBlobParams<'a> {
48    /// URL template from `Session.download_url`.
49    ///
50    /// Typed as `&JmapUrlTemplate` (bd:JMAP-6r7c.40) so the compiler
51    /// refuses an accidental `&session.api_url` (a plain `JmapUrl`).
52    pub download_url_template: &'a crate::request::JmapUrlTemplate,
53    /// Account ID that owns the blob.
54    pub account_id: &'a str,
55    /// Server-assigned blob identifier.
56    pub blob_id: &'a str,
57    /// Human-readable filename for the `{name}` template variable.
58    pub name: &'a str,
59    /// Optional accept type for the `{type}` template variable (e.g. `"image/png"`).
60    /// Pass `None` when no content-type preference is needed; `{type}` expands to an
61    /// empty string.
62    pub accept_type: Option<&'a str>,
63    /// Optional expected SHA-256 digest for integrity verification.
64    /// Pass `None` to skip the check.
65    ///
66    /// The typed [`jmap_cid_types::Sha256`] wrapper guarantees the value is
67    /// exactly 64 characters of lowercase hex per draft-atwood-jmap-cid-00 §2 ABNF.
68    /// Construction is the only validation point: callers build the
69    /// [`Sha256`](jmap_cid_types::Sha256) via [`from_hex`](jmap_cid_types::Sha256::from_hex)
70    /// or deserialize and propagate the [`Sha256DigestError`](jmap_cid_types::Sha256DigestError);
71    /// `download_blob` itself does not re-validate, so the `&str`-validation
72    /// branch and its [`ClientError::InvalidArgument`] mapping no longer exist
73    /// (bd:JMAP-6r7c.48, bd:JMAP-6r7c.53).
74    pub expected_sha256: Option<&'a jmap_cid_types::Sha256>,
75}
76
77/// Parameters for [`JmapClient::upload_blob_session`] (bd:JMAP-6r7c.64).
78///
79/// Slimmed variant of [`UploadBlobParams`] that omits the URL template
80/// — the Session-taking variant supplies it from `session.upload_url`.
81/// Construct with a struct literal:
82///
83/// ```rust,ignore
84/// client.upload_blob_session(&session, UploadBlobSessionParams {
85///     account_id: "A13824",
86///     content_type: "application/pdf",
87///     data: bytes::Bytes::from(buffer),
88/// }).await?;
89/// ```
90#[derive(Debug, Clone)]
91pub struct UploadBlobSessionParams<'a> {
92    /// Account ID that will own the uploaded blob.
93    pub account_id: &'a str,
94    /// Media type sent as the HTTP `Content-Type` request header.
95    pub content_type: &'a str,
96    /// Raw bytes to upload.
97    pub data: bytes::Bytes,
98}
99
100/// Parameters for [`JmapClient::download_blob_session`] (bd:JMAP-6r7c.64).
101///
102/// Slimmed variant of [`DownloadBlobParams`] that omits the URL
103/// template — the Session-taking variant supplies it from
104/// `session.download_url`. Construct with a struct literal:
105///
106/// ```rust,ignore
107/// client.download_blob_session(&session, DownloadBlobSessionParams {
108///     account_id: "A13824",
109///     blob_id: "Gbc4c...",
110///     name: "attachment.pdf",
111///     accept_type: Some("application/pdf"),
112///     expected_sha256: None,
113/// }).await?;
114/// ```
115#[derive(Debug, Clone, Copy)]
116pub struct DownloadBlobSessionParams<'a> {
117    /// Account ID that owns the blob.
118    pub account_id: &'a str,
119    /// Server-assigned blob identifier.
120    pub blob_id: &'a str,
121    /// Human-readable filename for the `{name}` template variable.
122    pub name: &'a str,
123    /// Optional accept type for the `{type}` template variable.
124    pub accept_type: Option<&'a str>,
125    /// Optional expected SHA-256 digest for integrity verification.
126    pub expected_sha256: Option<&'a jmap_cid_types::Sha256>,
127}
128
129/// Parameters for [`JmapClient::upload_blob`].
130///
131/// Use a struct literal to avoid confusion between the three string-typed
132/// fields (the URL template, the account id, and the content type — three
133/// positional `&str` arguments are exactly the parameter-confusion footgun
134/// [`DownloadBlobParams`] eliminated for the download side):
135///
136/// ```rust,ignore
137/// client.upload_blob(UploadBlobParams {
138///     upload_url_template: &session.upload_url,
139///     account_id: "A13824",
140///     content_type: "application/pdf",
141///     data: bytes::Bytes::from(buffer),
142/// }).await?;
143/// ```
144///
145/// Adding an optional per-call timeout override, a body-integrity hint,
146/// or any other parameter in a future minor release is a non-breaking
147/// minor-version bump — callers who do not set the new field keep working.
148/// A positional-arg signature would have locked that future evolution to
149/// a major bump (bd:JMAP-6r7c.50).
150///
151/// `data` is owned (`bytes::Bytes`) rather than borrowed, because the
152/// HTTP request body takes ownership of the bytes and the `Bytes` clone
153/// is a cheap refcount bump on the underlying buffer. The other three
154/// fields borrow with a single shared lifetime parameter `'a`.
155#[derive(Debug, Clone)]
156pub struct UploadBlobParams<'a> {
157    /// URL template from `Session.upload_url`. `{accountId}` is the only
158    /// template variable substituted before the POST request.
159    ///
160    /// Typed as `&JmapUrlTemplate` (bd:JMAP-6r7c.40) so the compiler
161    /// refuses an accidental `&session.api_url` (a plain `JmapUrl`).
162    pub upload_url_template: &'a crate::request::JmapUrlTemplate,
163    /// Account ID that will own the uploaded blob; substituted for
164    /// `{accountId}` in the URL template.
165    pub account_id: &'a str,
166    /// Media type sent as the HTTP `Content-Type` request header. Must
167    /// be a valid HTTP header value (no CR/LF, no leading/trailing
168    /// whitespace) or upload fails with
169    /// [`ClientError::InvalidHeaderValue`].
170    pub content_type: &'a str,
171    /// Raw bytes to upload. The pre-upload SHA-256 is computed locally
172    /// and cross-checked against the server's `BlobUploadResponse.sha256`
173    /// (when present); the byte length is cross-checked against the
174    /// server's `BlobUploadResponse.size`.
175    pub data: bytes::Bytes,
176}
177
178/// Response body returned by a successful blob upload (RFC 8620 §6.1).
179///
180/// # SemVer coupling with `jmap-cid-types` (bd:JMAP-6r7c.30)
181///
182/// The `sha256` field uses `jmap_cid_types::Sha256` — a workspace-sibling
183/// type, not a wrapped opaque type the way `reqwest::Error` is wrapped
184/// behind [`HttpError`](crate::HttpError). Consumers that touch
185/// `BlobUploadResponse.sha256` transitively depend on `jmap-cid-types` and
186/// must pin its major version alongside `jmap-base-client`.
187///
188/// The coupling is deliberate. `jmap-cid-types` is a workspace sibling of
189/// `jmap-base-client` (both live in the `crate-jmap` workspace) and ships
190/// in the same release cadence — every `jmap-cid-types` major bump is also
191/// a `jmap-base-client` major bump. The SemVer-isolation pattern that
192/// hides `reqwest::Error` behind [`HttpError`](crate::HttpError) is
193/// designed for *third-party* deps whose release cadence is uncorrelated
194/// with this crate's; workspace siblings do not need that isolation
195/// because the workspace-level major-version policy already coordinates
196/// them.
197///
198/// Third-party consumers picking up `jmap-base-client` from crates.io
199/// should declare both deps with matching majors:
200///
201/// ```toml
202/// [dependencies]
203/// jmap-base-client = "0.1"
204/// jmap-cid-types   = "0.1"
205/// ```
206///
207/// If you only ever pattern-match on `Option::Some(_)` (without naming the
208/// inner type) you can skip the explicit `jmap-cid-types` dep; touching
209/// `Sha256`'s methods or `AsRef<str>` impl requires it.
210///
211/// # `extra` equality is feature-flag-dependent (bd:JMAP-6r7c.43)
212///
213/// The derived `PartialEq` / `Eq` impl's behaviour on the `extra` field
214/// depends on the global `serde_json/preserve_order` feature flag — see
215/// the [crate-level note](crate#extra-field-equality-and-the-serde_jsonpreserve_order-feature-bdjmap-6r7c43)
216/// for the canonical statement and the workspace posture.
217#[non_exhaustive]
218#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
219#[serde(rename_all = "camelCase")]
220pub struct BlobUploadResponse {
221    /// The account the blob was uploaded to.
222    pub account_id: Id,
223    /// Server-assigned opaque blob identifier.
224    pub blob_id: Id,
225    /// Media type of the uploaded blob as determined by the server.
226    #[serde(rename = "type")]
227    pub content_type: String,
228    /// Size of the uploaded blob in bytes.
229    pub size: u64,
230    /// SHA-256 digest of the uploaded blob, present only when the
231    /// server advertises the `urn:ietf:params:jmap:cid` capability
232    /// (draft-atwood-jmap-cid-00 §3).
233    ///
234    /// The wire format is a 64-character lowercase-hex string per
235    /// the draft's ABNF (`%x30-39 / %x61-66`). The typed
236    /// [`jmap_cid_types::Sha256`] enforces that shape on
237    /// deserialize: a server response carrying a sha256 field that
238    /// is not exactly 64 bytes of lowercase hex will fail to parse
239    /// and surface as [`ClientError::Parse`]. Servers that do not
240    /// implement the CID extension omit the field; the typed
241    /// representation here is `None`.
242    ///
243    /// History: bd:JMAP-v9py.13 promoted this field from a permissive
244    /// `Option<String>` to the typed `Option<jmap_cid_types::Sha256>`,
245    /// and bd:JMAP-6r7c.48 propagated the same typed shape to the
246    /// download-side [`DownloadBlobParams::expected_sha256`](crate::DownloadBlobParams::expected_sha256)
247    /// caller-supplied argument. The previous implementation tolerated
248    /// uppercase hex via a permissive ad-hoc validator and a normalize-
249    /// to-lowercase step before integrity comparison; the typed path is
250    /// strict on every construction site. The inter-op question (whether
251    /// to recover the uppercase tolerance via a custom Deserialize wrapper)
252    /// is tracked by bd:JMAP-noz7.
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub sha256: Option<jmap_cid_types::Sha256>,
255
256    /// Catch-all for vendor / site / private extension fields not covered
257    /// by the typed fields above. Preserves unknown fields across
258    /// deserialize/serialize round-trip per workspace extras-preservation
259    /// policy (see workspace AGENTS.md).
260    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
261    pub extra: serde_json::Map<String, serde_json::Value>,
262}
263
264/// Expand a RFC 6570 Level-1 URI template by substituting variables.
265///
266/// For each `(name, value)` pair in `vars`, replaces `{name}` in `template`
267/// with the percent-encoded form of `value`. Encoding follows RFC 3986
268/// unreserved characters (ALPHA / DIGIT / `-` / `.` / `_` / `~`), which pass
269/// through unchanged; all other bytes are encoded as `%XX` with uppercase hex
270/// (RFC 3986 §2.1 requires uppercase).
271///
272/// Entries in `vars` whose name does not appear in the template are silently
273/// ignored. A variable that appears in the template but has no entry in `vars`
274/// is an error (`ClientError::InvalidSession`) because templates come from the
275/// server's Session document — an unexpected variable indicates a server bug.
276///
277/// # RFC 6570 Level-1
278/// Only simple-string expansion is supported. Reserved-expansion (`{+var}`)
279/// and other Level-2+ operators are not supported.
280///
281/// # ⚠ Level-2+ templates silently mis-expand (bd:JMAP-6r7c.32)
282///
283/// A server that returns a Level-2 or higher template — e.g.
284/// `{+blobId}` (reserved-expansion), `{#var}` (fragment-style), or
285/// `{var*}` (list-shaped) — will NOT be detected as malformed. The
286/// `{+blobId}` form parses as a variable name `+blobId` which is then
287/// either replaced verbatim (if a caller happens to pass that exact
288/// name in `vars`) or rejected as an unknown variable. The
289/// percent-encoding logic treats every non-unreserved byte as
290/// something to escape; Level-2 reserved expansion EXPECTS reserved
291/// characters like `/` and `:` to pass through unchanged. A server
292/// using `{+downloadId}` to embed a slash-bearing identifier directly
293/// would have those slashes percent-encoded by this function and the
294/// resulting URL would be rejected server-side.
295///
296/// **JMAP servers SHOULD use Level-1 templates only per RFC 8620 §2.**
297/// If your server uses higher levels, you must expand the template
298/// yourself with an RFC-6570-compliant external library and pass the
299/// already-expanded URL to methods that accept a plain URL — do not
300/// pass a Level-2+ template through this function.
301///
302/// # Usage with `subscribe_events`
303///
304/// [`Session::event_source_url`] is a URI template with variables `types`,
305/// `closeafter`, and `ping`. Expand it before calling
306/// [`JmapClient::subscribe_events`]:
307///
308/// ```rust,ignore
309/// let url = expand_url_template(
310///     &session.event_source_url,
311///     &[
312///         ("types", "Email,Mailbox"),
313///         ("closeafter", "state"),
314///         ("ping", "0"),
315///     ],
316/// )?;
317/// client.subscribe_events(&url, None).await?;
318/// ```
319///
320/// [`Session::event_source_url`]: crate::request::Session::event_source_url
321/// [`JmapClient::subscribe_events`]: crate::client::JmapClient::subscribe_events
322pub fn expand_url_template(template: &str, vars: &[(&str, &str)]) -> Result<String, ClientError> {
323    let mut result = String::with_capacity(template.len() + 64);
324    let mut rest = template;
325    while let Some(open) = rest.find('{') {
326        result.push_str(&rest[..open]);
327        rest = &rest[open + 1..];
328        let close = rest.find('}').ok_or_else(|| {
329            ClientError::InvalidSession("URL template has unmatched '{'".to_owned())
330        })?;
331        let name = &rest[..close];
332        rest = &rest[close + 1..];
333        if let Some((_, value)) = vars.iter().find(|(n, _)| *n == name) {
334            result.push_str(&percent_encode(value));
335        } else {
336            return Err(ClientError::InvalidSession(format!(
337                "URL template variable not supplied: {{{name}}}"
338            )));
339        }
340    }
341    result.push_str(rest);
342    Ok(result)
343}
344
345/// Percent-encode a string value per RFC 3986 §2.3 unreserved character set.
346///
347/// The return type is [`Cow<'_, str>`](Cow) so the common JMAP case — inputs
348/// that contain only unreserved characters (alphanumeric / `-` / `.` / `_` /
349/// `~`) such as account ids and blob ids — borrows the input slice and
350/// performs no allocation. Inputs that contain any byte requiring percent-
351/// escape allocate a fresh string with the encoded form.
352fn percent_encode(value: &str) -> Cow<'_, str> {
353    // Fast path: scan for the first byte that needs escaping. If none, no
354    // allocation is required.
355    let first_escape = value.bytes().position(|b| !is_unreserved(b));
356    let Some(first) = first_escape else {
357        return Cow::Borrowed(value);
358    };
359
360    // Allocate only when at least one byte needs escaping. Reserve at least
361    // enough capacity for the unchanged prefix plus the three-byte encoding
362    // of the first escaped byte; further escapes will grow as needed.
363    let mut out = String::with_capacity(value.len() + 2);
364    out.push_str(&value[..first]);
365    for byte in value.as_bytes()[first..].iter().copied() {
366        if is_unreserved(byte) {
367            out.push(char::from(byte));
368        } else {
369            out.push('%');
370            out.push(hex_nibble_upper(byte >> 4));
371            out.push(hex_nibble_upper(byte & 0x0f));
372        }
373    }
374    Cow::Owned(out)
375}
376
377/// Returns `true` if `byte` is in the RFC 3986 §2.3 unreserved set.
378fn is_unreserved(byte: u8) -> bool {
379    byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'.' || byte == b'_' || byte == b'~'
380}
381
382/// Returns the uppercase hex character for `nibble` (0–15).
383/// Used for percent-encoding (RFC 3986 §2.1 requires uppercase).
384fn hex_nibble_upper(nibble: u8) -> char {
385    match nibble {
386        0..=9 => char::from(b'0' + nibble),
387        10..=15 => char::from(b'A' + nibble - 10),
388        _ => unreachable!("nibble must be 0–15"),
389    }
390}
391
392/// Returns the lowercase hex character for `nibble` (0–15).
393/// Used for SHA-256 hex output (JMAP-CID §1 requires lowercase).
394fn hex_nibble_lower(nibble: u8) -> char {
395    match nibble {
396        0..=9 => char::from(b'0' + nibble),
397        10..=15 => char::from(b'a' + nibble - 10),
398        _ => unreachable!("nibble must be 0–15"),
399    }
400}
401
402impl JmapClient {
403    /// Upload raw bytes to the JMAP blob store (RFC 8620 §6.1).
404    ///
405    /// `params.upload_url_template` is from `Session.upload_url`;
406    /// `{accountId}` is substituted before the request.
407    /// `params.content_type` is sent as the `Content-Type` header. If the
408    /// server returns a `sha256` field (JMAP-CID capability), it is
409    /// verified against the locally-computed digest and
410    /// `ClientError::BlobIntegrityMismatch` is returned on mismatch.
411    pub async fn upload_blob(
412        &self,
413        params: UploadBlobParams<'_>,
414    ) -> Result<BlobUploadResponse, ClientError> {
415        let UploadBlobParams {
416            upload_url_template,
417            account_id,
418            content_type,
419            data,
420        } = params;
421        crate::client::require_http_url(upload_url_template.as_str())?;
422        let ct_hv =
423            HeaderValue::from_str(content_type).map_err(ClientError::from_invalid_header)?;
424        let url = expand_url_template(upload_url_template.as_str(), &[("accountId", account_id)])?;
425
426        // Compute SHA-256 and capture size before handing ownership of data
427        // to the request body. local_size is used to cross-check the server's
428        // reported `size` after upload (bd:JMAP-6lsm.8) — when the server
429        // does not return sha256 (most do not), size is the only signal that
430        // the bytes we sent are the bytes the server stored.
431        let local_sha256 = compute_sha256_hex(&data);
432        let local_size = data.len() as u64;
433
434        let req = self.inject_auth(
435            self.http
436                .post(&url)
437                .header(CONTENT_TYPE, ct_hv)
438                .timeout(self.config.request_timeout)
439                .body(data),
440        );
441
442        let resp = req.send().await.map_err(ClientError::from_reqwest)?;
443        let status = resp.status();
444        Self::check_auth_status(status)?;
445        let resp = resp.error_for_status().map_err(ClientError::from_reqwest)?;
446
447        let upload_limit = self.config.max_upload_response_body;
448
449        // Stream the body chunk-by-chunk with the cap enforced before each
450        // accumulation (bd:JMAP-6r7c.1). See `read_capped_body` for the DOS
451        // rationale; without per-chunk streaming, a server that under-reports
452        // or omits Content-Length can force unbounded allocation here.
453        let bytes = read_capped_body(resp, upload_limit).await?;
454        let upload_resp: BlobUploadResponse =
455            serde_json::from_slice(&bytes).map_err(ClientError::from_parse)?;
456
457        // Defense-in-depth: cross-check the server's reported size against the
458        // bytes we actually uploaded (bd:JMAP-6lsm.8). When sha256 is present
459        // (below) it makes a size mismatch implausible because length is
460        // implicit in the digest, but most servers do not advertise the
461        // JMAP-CID sha256 capability — in that case `size` is the only
462        // signal that the upload was complete and intact. A mismatch
463        // surfaces as UnexpectedResponse rather than a typed variant; a
464        // future minor release may add ClientError::BlobSizeMismatch for
465        // structured matching, but keeping the variant set stable in 0.1.x
466        // is the conservative choice.
467        if upload_resp.size != local_size {
468            return Err(ClientError::UnexpectedResponse(format!(
469                "blob upload size mismatch: client uploaded {local_size} bytes, server reports \
470                 {server_size} bytes",
471                server_size = upload_resp.size,
472            )));
473        }
474
475        if let Some(ref server_sha256) = upload_resp.sha256 {
476            // The typed `jmap_cid_types::Sha256` deserialize already
477            // enforces the 64-character lowercase-hex ABNF; reaching
478            // this branch implies the wire value is canonical. We
479            // can compare the locally-computed digest (also
480            // canonical lowercase hex from `compute_sha256_hex`)
481            // against the typed value's string form directly.
482            //
483            // Uppercase-hex from a non-conformant server is rejected
484            // at deserialize per draft-atwood-jmap-cid-00 §2 ABNF and
485            // surfaces as ClientError::Parse before this branch runs.
486            //
487            // Constant-time compare via `hex_digest_eq` (bd:JMAP-6r7c.61):
488            // both sides are canonical 64-char lowercase hex by construction.
489            if !hex_digest_eq(&local_sha256, server_sha256.as_str()) {
490                return Err(ClientError::BlobIntegrityMismatch {
491                    expected: local_sha256,
492                    actual: server_sha256.as_str().to_owned(),
493                });
494            }
495        }
496
497        Ok(upload_resp)
498    }
499
500    /// Download a blob by ID (RFC 8620 §6.2).
501    ///
502    /// Template variables `{accountId}`, `{blobId}`, `{name}`, and `{type}` are
503    /// substituted from the corresponding fields of `params` before the GET
504    /// request. `{type}` expands to an empty string when `params.accept_type`
505    /// is `None`; templates that include `?accept={type}` produce `?accept=`.
506    /// If the server does not tolerate an empty `?accept=` parameter, omit
507    /// `{type}` from the `download_url` template in the Session document.
508    ///
509    /// If `params.expected_sha256` is `Some`, the downloaded bytes are verified
510    /// against the typed [`jmap_cid_types::Sha256`] digest and
511    /// `ClientError::BlobIntegrityMismatch` is returned on mismatch.
512    pub async fn download_blob(
513        &self,
514        params: DownloadBlobParams<'_>,
515    ) -> Result<bytes::Bytes, ClientError> {
516        let DownloadBlobParams {
517            download_url_template,
518            account_id,
519            blob_id,
520            name,
521            accept_type,
522            expected_sha256,
523        } = params;
524        crate::client::require_http_url(download_url_template.as_str())?;
525        let vars = [
526            ("accountId", account_id),
527            ("blobId", blob_id),
528            ("name", name),
529            // Always supply {type} — even as empty string — so templates
530            // containing `?accept={type}` expand cleanly rather than triggering
531            // the unexpanded-placeholder error.
532            ("type", accept_type.unwrap_or("")),
533        ];
534        let url = expand_url_template(download_url_template.as_str(), &vars)?;
535
536        let req = self.inject_auth(self.http.get(&url).timeout(self.config.request_timeout));
537
538        let resp = req.send().await.map_err(ClientError::from_reqwest)?;
539        let status = resp.status();
540        Self::check_auth_status(status)?;
541        let resp = resp.error_for_status().map_err(ClientError::from_reqwest)?;
542
543        let download_limit = self.config.max_download_body;
544
545        // Stream the body chunk-by-chunk with the cap enforced before each
546        // accumulation (bd:JMAP-6r7c.1). See `read_capped_body` for the DOS
547        // rationale; without per-chunk streaming, a server that under-reports
548        // or omits Content-Length can force unbounded allocation here.
549        let bytes = bytes::Bytes::from(read_capped_body(resp, download_limit).await?);
550
551        if let Some(expected) = expected_sha256 {
552            // The typed `jmap_cid_types::Sha256` enforces the canonical
553            // 64-char lowercase-hex ABNF at construction (bd:JMAP-6r7c.48),
554            // so the runtime length/charset check and the
555            // `to_ascii_lowercase` normalize step the previous
556            // `Option<&str>` shape required (bd:JMAP-6r7c.53) are gone.
557            let actual = compute_sha256_hex(&bytes);
558            // Constant-time compare via `hex_digest_eq` (bd:JMAP-6r7c.61):
559            // both sides are canonical 64-char lowercase hex by construction.
560            if !hex_digest_eq(&actual, expected.as_str()) {
561                return Err(ClientError::BlobIntegrityMismatch {
562                    expected: expected.as_str().to_owned(),
563                    actual,
564                });
565            }
566        }
567
568        Ok(bytes)
569    }
570
571    /// Upload raw bytes via a [`crate::Session`]-supplied URL
572    /// template (bd:JMAP-6r7c.64).
573    ///
574    /// Type-safe convenience wrapper over [`Self::upload_blob`] —
575    /// supplies `session.upload_url` for `upload_url_template`
576    /// internally. The caller cannot accidentally pass `session.api_url`
577    /// or any other URL field because the parameter set
578    /// ([`UploadBlobSessionParams`]) does not include a URL field.
579    pub async fn upload_blob_session(
580        &self,
581        session: &crate::request::Session,
582        params: UploadBlobSessionParams<'_>,
583    ) -> Result<BlobUploadResponse, ClientError> {
584        let UploadBlobSessionParams {
585            account_id,
586            content_type,
587            data,
588        } = params;
589        self.upload_blob(UploadBlobParams {
590            upload_url_template: &session.upload_url,
591            account_id,
592            content_type,
593            data,
594        })
595        .await
596    }
597
598    /// Download a blob via a [`crate::Session`]-supplied URL
599    /// template (bd:JMAP-6r7c.64).
600    ///
601    /// Type-safe convenience wrapper over [`Self::download_blob`] —
602    /// supplies `session.download_url` for `download_url_template`
603    /// internally. See [`Self::upload_blob_session`] for the
604    /// rationale.
605    pub async fn download_blob_session(
606        &self,
607        session: &crate::request::Session,
608        params: DownloadBlobSessionParams<'_>,
609    ) -> Result<bytes::Bytes, ClientError> {
610        let DownloadBlobSessionParams {
611            account_id,
612            blob_id,
613            name,
614            accept_type,
615            expected_sha256,
616        } = params;
617        self.download_blob(DownloadBlobParams {
618            download_url_template: &session.download_url,
619            account_id,
620            blob_id,
621            name,
622            accept_type,
623            expected_sha256,
624        })
625        .await
626    }
627}
628
629/// Compute SHA-256 of `data` and return as 64-char lowercase hex string.
630fn compute_sha256_hex(data: &[u8]) -> String {
631    let hash = Sha256::digest(data);
632    let mut s = String::with_capacity(64);
633    for b in hash.iter() {
634        s.push(hex_nibble_lower(*b >> 4));
635        s.push(hex_nibble_lower(*b & 0x0f));
636    }
637    s
638}
639
640/// Constant-time equality for two 64-character lowercase SHA-256 hex digests
641/// (bd:JMAP-6r7c.61).
642///
643/// Both upload-side and download-side integrity checks compare hex digests
644/// that are guaranteed-canonical by construction before reaching this
645/// helper: `compute_sha256_hex` always emits 64 lowercase nibbles, and
646/// `jmap_cid_types::Sha256` enforces the 64-char lowercase-hex ABNF on
647/// every construction path (deserialize, `from_hex`, etc.). Length-
648/// discrimination is therefore not an information leak at these call sites.
649///
650/// Using `subtle::ConstantTimeEq::ct_eq` over `==` is discipline-propagation,
651/// not a defense against a concrete JMAP threat: SHA-256 of blob bytes is
652/// not a secret (the attacker can fetch the same blob), so a timing oracle
653/// on the digest comparison reveals nothing exploitable in the upload-side
654/// case. The download-side `expected_sha256` argument is caller-supplied
655/// and *could* in principle come from a private channel (signed manifest,
656/// end-to-end attestation); the constant-time compare closes that residual
657/// channel for callers who want it.
658///
659/// Matches the workspace's RustCrypto-first stance and the precedent set by
660/// `crate-jmap-chat-server` (invite-code lookup) and `crate-jmap-testjig`
661/// (bearer-token check).
662fn hex_digest_eq(a: &str, b: &str) -> bool {
663    a.as_bytes().ct_eq(b.as_bytes()).into()
664}
665
666#[cfg(test)]
667mod tests {
668    use super::*;
669
670    // Oracle: RFC 6570 §1.2 — simple substitution with unreserved-only value
671    #[test]
672    fn expand_upload_url() {
673        let result = expand_url_template(
674            "https://example.com/upload/{accountId}/",
675            &[("accountId", "account1")],
676        )
677        .expect("must succeed");
678        assert_eq!(result, "https://example.com/upload/account1/");
679    }
680
681    // Oracle: RFC 3986 §2.1 — space 0x20 encodes as %20
682    #[test]
683    fn expand_download_url_with_spaces() {
684        let result = expand_url_template(
685            "/download/{accountId}/{blobId}/{name}",
686            &[
687                ("accountId", "acc1"),
688                ("blobId", "blob-123"),
689                ("name", "my file.png"),
690            ],
691        )
692        .expect("must succeed");
693        assert_eq!(result, "/download/acc1/blob-123/my%20file.png");
694    }
695
696    // Oracle: RFC 3986 §2.1 — slash 0x2F encodes as %2F
697    #[test]
698    fn expand_with_slash_in_type() {
699        let result = expand_url_template(
700            "/dl/{accountId}/{blobId}/{name}?accept={type}",
701            &[
702                ("accountId", "a"),
703                ("blobId", "b"),
704                ("name", "x.jpg"),
705                ("type", "image/png"),
706            ],
707        )
708        .expect("must succeed");
709        assert_eq!(result, "/dl/a/b/x.jpg?accept=image%2Fpng");
710    }
711
712    // Oracle: expand_url_template must error when a template variable has no
713    // entry in vars — the template comes from the server's Session document,
714    // so an unrecognized variable name indicates a server-side bug.
715    #[test]
716    fn expand_unknown_variable_returns_error() {
717        let err = expand_url_template(
718            "https://example.com/upload/{accountId}/{unknownVar}/",
719            &[("accountId", "acc1")],
720        )
721        .expect_err("must fail when template variable is not supplied");
722        assert!(
723            matches!(err, crate::error::ClientError::InvalidSession(_)),
724            "expected InvalidSession, got {err:?}"
725        );
726    }
727
728    // Oracle: expand_url_template must error on a template with an unmatched
729    // '{' — no corresponding '}' exists. The expected error is InvalidSession
730    // because templates come from the server's Session document.
731    #[test]
732    fn expand_unmatched_open_brace_returns_error() {
733        let err = expand_url_template("https://example.com/{unclosed", &[])
734            .expect_err("unmatched '{' must return an error");
735        assert!(
736            matches!(err, crate::error::ClientError::InvalidSession(_)),
737            "expected InvalidSession for unmatched brace, got {err:?}"
738        );
739    }
740
741    // Oracle: vars entries whose name does not appear in the template are
742    // silently ignored — extra vars are benign (caller may pass a superset).
743    #[test]
744    fn expand_unused_var_is_ignored() {
745        let result = expand_url_template(
746            "https://example.com/upload/{accountId}/",
747            &[("accountId", "acc1"), ("extraVar", "value")],
748        )
749        .expect("extra vars must be silently ignored");
750        assert_eq!(result, "https://example.com/upload/acc1/");
751    }
752
753    // Oracle: RFC 3986 §2.3 — for inputs containing only unreserved
754    // characters (alphanumeric / `-` / `.` / `_` / `~`), percent_encode
755    // MUST return Cow::Borrowed without allocating. Verified by the
756    // matches! check against the Cow::Borrowed variant.
757    #[test]
758    fn percent_encode_unreserved_input_borrows() {
759        let input = "abc.DEF_123-xyz~tilde";
760        let result = percent_encode(input);
761        assert!(matches!(result, Cow::Borrowed(_)));
762        assert_eq!(result.as_ref(), input);
763    }
764
765    // Oracle: RFC 3986 §2.1 — inputs that contain any byte outside the
766    // unreserved set MUST be returned as Cow::Owned with each non-unreserved
767    // byte percent-escaped using uppercase hex (§2.1 mandates uppercase).
768    #[test]
769    fn percent_encode_with_space_is_owned_and_uppercase() {
770        let input = "hello world";
771        let result = percent_encode(input);
772        assert!(matches!(result, Cow::Owned(_)));
773        assert_eq!(result.as_ref(), "hello%20world");
774    }
775
776    // Oracle: RFC 3986 §2.1 — slash 0x2F encodes as %2F (uppercase hex).
777    // Mixed-content input MUST preserve the unreserved prefix verbatim
778    // and escape only the disallowed bytes.
779    #[test]
780    fn percent_encode_mixed_input_preserves_prefix() {
781        let input = "image/png";
782        let result = percent_encode(input);
783        assert!(matches!(result, Cow::Owned(_)));
784        assert_eq!(result.as_ref(), "image%2Fpng");
785    }
786
787    // Oracle: degenerate empty input. An empty string has no bytes requiring
788    // escape, so percent_encode MUST borrow it without allocation.
789    #[test]
790    fn percent_encode_empty_input_borrows() {
791        let result = percent_encode("");
792        assert!(matches!(result, Cow::Borrowed(_)));
793        assert_eq!(result.as_ref(), "");
794    }
795
796    // Oracle: tests/fixtures/blob/upload_response.json — hand-written fixture
797    // derived from RFC 8620 §6.1 blob upload response shape; not produced by
798    // the code under test.
799    #[test]
800    fn blob_upload_response_deserializes() {
801        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
802            .join("tests/fixtures/blob/upload_response.json");
803        let text =
804            std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("cannot read fixture: {e}"));
805        let resp: BlobUploadResponse =
806            serde_json::from_str(&text).expect("fixture must deserialize as BlobUploadResponse");
807
808        assert_eq!(resp.account_id, "account1");
809        assert_eq!(resp.blob_id, "Gbc4c377-c8c3-4b48-b2bb-8c1e4cfb8b2a");
810        assert_eq!(resp.content_type, "image/png");
811        assert_eq!(resp.size, 48291);
812        // The typed Sha256 wrapper carries the canonical lowercase
813        // hex string; compare via `as_str` to keep the assertion
814        // independent of the wrapper's Debug shape.
815        assert_eq!(
816            resp.sha256.as_ref().map(jmap_cid_types::Sha256::as_str),
817            Some("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")
818        );
819    }
820
821    /// `BlobUploadResponse.extra` captures unknown fields on deserialize.
822    #[test]
823    fn blob_upload_response_preserves_vendor_extras() {
824        let raw = serde_json::json!({
825            "accountId": "account1",
826            "blobId": "Gbc4c377-c8c3-4b48-b2bb-8c1e4cfb8b2a",
827            "type": "image/png",
828            "size": 48291,
829            "acmeCorpScanResult": "clean"
830        });
831        let obj: BlobUploadResponse =
832            serde_json::from_value(raw).expect("BlobUploadResponse must deserialize");
833        assert_eq!(
834            obj.extra.get("acmeCorpScanResult").and_then(|v| v.as_str()),
835            Some("clean")
836        );
837    }
838
839    // bd:JMAP-v9py.13 oracle.
840    //
841    // The fixture hex digest is hand-typed from RFC 6234 §8.5 ("abc"
842    // test vector, formatted lowercase). It is NOT derived from the
843    // code under test, satisfying the workspace test-integrity rule
844    // that test oracles must be independent.
845
846    /// Deserialize a Blob/upload response carrying a canonical
847    /// 64-char lowercase-hex sha256 → field parses as
848    /// Some(Sha256(_)) preserving the wire string.
849    #[test]
850    fn blob_upload_response_deserializes_sha256_typed() {
851        let raw = serde_json::json!({
852            "accountId": "account1",
853            "blobId": "blob1",
854            "type": "text/plain",
855            "size": 3,
856            "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
857        });
858        let obj: BlobUploadResponse = serde_json::from_value(raw).expect("must deserialize");
859        let s = obj.sha256.expect("sha256 must be Some");
860        assert_eq!(
861            s.as_str(),
862            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
863        );
864    }
865
866    /// Serialize a BlobUploadResponse with `sha256: None` → the
867    /// `sha256` key must be ABSENT from the wire output per
868    /// `skip_serializing_if = "Option::is_none"`. This is the
869    /// contract for servers not advertising the CID capability.
870    #[test]
871    fn blob_upload_response_serializes_without_sha256_when_none() {
872        let resp = BlobUploadResponse {
873            account_id: Id::from("a1"),
874            blob_id: Id::from("b1"),
875            content_type: "text/plain".to_owned(),
876            size: 0,
877            sha256: None,
878            extra: serde_json::Map::new(),
879        };
880        let v = serde_json::to_value(&resp).expect("must serialize");
881        let obj = v.as_object().expect("object");
882        assert!(
883            !obj.contains_key("sha256"),
884            "None must elide the sha256 key: {v:?}"
885        );
886    }
887
888    /// Round-trip preservation: a server that omits the sha256
889    /// field deserializes into `None`, and serializing back
890    /// produces an output without a `sha256` key. This is the
891    /// shape RFC 8620 §6.1 compliant servers (without the CID
892    /// extension) produce; verify we don't accidentally inject a
893    /// null or empty sha256 on round-trip.
894    #[test]
895    fn blob_upload_response_no_sha256_round_trip() {
896        let raw = serde_json::json!({
897            "accountId": "account1",
898            "blobId": "blob1",
899            "type": "text/plain",
900            "size": 3
901        });
902        let obj: BlobUploadResponse = serde_json::from_value(raw).expect("must deserialize");
903        assert!(obj.sha256.is_none());
904
905        let re = serde_json::to_value(&obj).expect("must serialize");
906        let map = re.as_object().expect("object");
907        assert!(
908            !map.contains_key("sha256"),
909            "round-trip must not introduce sha256 key: {re:?}"
910        );
911    }
912
913    /// A non-conformant server sending uppercase hex now fails to
914    /// deserialize (typed `Sha256` is strict lowercase-only per
915    /// draft-atwood-jmap-cid-00 §2). Pinning the strict behavior
916    /// per bd:JMAP-v9py.13's design; the inter-op question is
917    /// tracked separately at bd:JMAP-noz7.
918    #[test]
919    fn blob_upload_response_rejects_uppercase_sha256() {
920        let raw = serde_json::json!({
921            "accountId": "account1",
922            "blobId": "blob1",
923            "type": "text/plain",
924            "size": 3,
925            "sha256": "BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD"
926        });
927        let err = serde_json::from_value::<BlobUploadResponse>(raw)
928            .expect_err("uppercase hex must fail to deserialize");
929        let msg = err.to_string();
930        assert!(
931            msg.contains("non-lowercase-hex") || msg.contains("at") || msg.contains("hex"),
932            "error must explain the hex constraint: {msg}"
933        );
934    }
935
936    // bd:JMAP-6r7c.61 — behavioral round-trip for the constant-time hex
937    // digest comparison helper. We cannot assert side-channel resistance
938    // from a unit test (only the compiler/CPU pipeline can); these tests
939    // assert the semantic equality contract still holds after swapping
940    // `String ==` for `subtle::ConstantTimeEq::ct_eq`. Oracle: NIST
941    // FIPS 180-4 Appendix A example 1 (SHA-256 of "abc") and the
942    // widely-published SHA-256 of the empty string (RFC 6234
943    // implementations, NIST CAVS test vectors) — both hand-typed from
944    // external sources, not computed by this crate.
945    #[test]
946    fn hex_digest_eq_matches_identical_canonical_digests() {
947        // NIST FIPS 180-4 Appendix A example 1: SHA-256("abc").
948        let abc_sha256_nist = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
949        assert!(hex_digest_eq(abc_sha256_nist, abc_sha256_nist));
950    }
951
952    #[test]
953    fn hex_digest_eq_rejects_one_byte_mismatch() {
954        let abc_sha256_nist = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
955        // Flip the final nibble (d -> c). Distance: 1 hex char, well
956        // inside what a byte-by-byte == would have caught.
957        let one_byte_off = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ac";
958        assert!(!hex_digest_eq(abc_sha256_nist, one_byte_off));
959    }
960
961    #[test]
962    fn hex_digest_eq_rejects_complete_mismatch() {
963        // NIST FIPS 180-4 Appendix A example 1: SHA-256("abc").
964        let abc_sha256_nist = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
965        // SHA-256 of the empty string (widely-published canonical value;
966        // RFC 6234, NIST CAVS, etc.).
967        let empty_sha256_canonical =
968            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
969        assert!(!hex_digest_eq(abc_sha256_nist, empty_sha256_canonical));
970    }
971}