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}