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;
4use std::pin::Pin;
5use std::sync::{Arc, Mutex, PoisonError};
6use std::task::{Context, Poll};
7
8use bytes::Bytes;
9use futures::{Stream, StreamExt};
10use jmap_types::Id;
11use reqwest::header::{CONTENT_LENGTH, CONTENT_TYPE, HeaderValue};
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14use subtle::ConstantTimeEq;
15
16use crate::client::{JmapClient, read_capped_body};
17use crate::error::ClientError;
18
19/// Parameters for [`JmapClient::download_blob`].
20///
21/// Use a struct literal to avoid confusion between the string-typed fields:
22///
23/// ```rust,ignore
24/// client.download_blob(DownloadBlobParams {
25///     download_url_template: &session.download_url,
26///     account_id: &Id::from("A13824"),
27///     blob_id: &Id::from("Gbc4c..."),
28///     name: "attachment.pdf",
29///     accept_type: Some("application/pdf"),
30///     expected_sha256: None,
31/// }).await?;
32/// ```
33///
34/// To request integrity verification, construct a typed
35/// [`jmap_cid_types::Sha256`] and pass a borrow:
36///
37/// ```rust,ignore
38/// let expected = jmap_cid_types::Sha256::from_hex(
39///     "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
40/// )?;
41/// client.download_blob(DownloadBlobParams {
42///     // ... other fields ...
43///     expected_sha256: Some(&expected),
44///     # download_url_template: "",
45///     # account_id: &Id::from(""),
46///     # blob_id: &Id::from(""),
47///     # name: "",
48///     # accept_type: None,
49/// }).await?;
50/// ```
51#[derive(Debug, Clone, Copy)]
52pub struct DownloadBlobParams<'a> {
53    /// URL template from `Session.download_url`.
54    ///
55    /// Typed as `&JmapUrlTemplate` (bd:JMAP-6r7c.40) so the compiler
56    /// refuses an accidental `&session.api_url` (a plain `JmapUrl`).
57    pub download_url_template: &'a crate::request::JmapUrlTemplate,
58    /// Account ID that owns the blob.
59    pub account_id: &'a Id,
60    /// Server-assigned blob identifier.
61    pub blob_id: &'a Id,
62    /// Human-readable filename for the `{name}` template variable.
63    pub name: &'a str,
64    /// Optional accept type for the `{type}` template variable (e.g. `"image/png"`).
65    /// Pass `None` when no content-type preference is needed; `{type}` expands to an
66    /// empty string.
67    pub accept_type: Option<&'a str>,
68    /// Optional expected SHA-256 digest for integrity verification.
69    /// Pass `None` to skip the check.
70    ///
71    /// The typed [`jmap_cid_types::Sha256`] wrapper guarantees the value is
72    /// exactly 64 characters of lowercase hex per draft-atwood-jmap-cid-00 §2 ABNF.
73    /// Construction is the only validation point: callers build the
74    /// [`Sha256`](jmap_cid_types::Sha256) via [`from_hex`](jmap_cid_types::Sha256::from_hex)
75    /// or deserialize and propagate the [`Sha256DigestError`](jmap_cid_types::Sha256DigestError);
76    /// `download_blob` itself does not re-validate, so the `&str`-validation
77    /// branch and its [`ClientError::InvalidArgument`] mapping no longer exist
78    /// (bd:JMAP-6r7c.48, bd:JMAP-6r7c.53).
79    pub expected_sha256: Option<&'a jmap_cid_types::Sha256>,
80}
81
82/// Parameters for [`JmapClient::upload_blob_session`] (bd:JMAP-6r7c.64).
83///
84/// Slimmed variant of [`UploadBlobParams`] that omits the URL template
85/// — the Session-taking variant supplies it from `session.upload_url`.
86/// Construct with a struct literal:
87///
88/// ```rust,ignore
89/// client.upload_blob_session(&session, UploadBlobSessionParams {
90///     account_id: &Id::from("A13824"),
91///     content_type: "application/pdf",
92///     data: bytes::Bytes::from(buffer),
93/// }).await?;
94/// ```
95#[derive(Debug, Clone)]
96pub struct UploadBlobSessionParams<'a> {
97    /// Account ID that will own the uploaded blob.
98    pub account_id: &'a Id,
99    /// Media type sent as the HTTP `Content-Type` request header.
100    pub content_type: &'a str,
101    /// Raw bytes to upload.
102    pub data: bytes::Bytes,
103}
104
105/// Parameters for [`JmapClient::download_blob_session`] (bd:JMAP-6r7c.64).
106///
107/// Slimmed variant of [`DownloadBlobParams`] that omits the URL
108/// template — the Session-taking variant supplies it from
109/// `session.download_url`. Construct with a struct literal:
110///
111/// ```rust,ignore
112/// client.download_blob_session(&session, DownloadBlobSessionParams {
113///     account_id: &Id::from("A13824"),
114///     blob_id: &Id::from("Gbc4c..."),
115///     name: "attachment.pdf",
116///     accept_type: Some("application/pdf"),
117///     expected_sha256: None,
118/// }).await?;
119/// ```
120#[derive(Debug, Clone, Copy)]
121pub struct DownloadBlobSessionParams<'a> {
122    /// Account ID that owns the blob.
123    pub account_id: &'a Id,
124    /// Server-assigned blob identifier.
125    pub blob_id: &'a Id,
126    /// Human-readable filename for the `{name}` template variable.
127    pub name: &'a str,
128    /// Optional accept type for the `{type}` template variable.
129    pub accept_type: Option<&'a str>,
130    /// Optional expected SHA-256 digest for integrity verification.
131    pub expected_sha256: Option<&'a jmap_cid_types::Sha256>,
132}
133
134/// Parameters for [`JmapClient::upload_blob`].
135///
136/// Use a struct literal to avoid confusion between the three string-typed
137/// fields (the URL template, the account id, and the content type — three
138/// positional `&str` arguments are exactly the parameter-confusion footgun
139/// [`DownloadBlobParams`] eliminated for the download side):
140///
141/// ```rust,ignore
142/// client.upload_blob(UploadBlobParams {
143///     upload_url_template: &session.upload_url,
144///     account_id: &Id::from("A13824"),
145///     content_type: "application/pdf",
146///     data: bytes::Bytes::from(buffer),
147/// }).await?;
148/// ```
149///
150/// Adding an optional per-call timeout override, a body-integrity hint,
151/// or any other parameter in a future minor release is a non-breaking
152/// minor-version bump — callers who do not set the new field keep working.
153/// A positional-arg signature would have locked that future evolution to
154/// a major bump (bd:JMAP-6r7c.50).
155///
156/// `data` is owned (`bytes::Bytes`) rather than borrowed, because the
157/// HTTP request body takes ownership of the bytes and the `Bytes` clone
158/// is a cheap refcount bump on the underlying buffer. The other three
159/// fields borrow with a single shared lifetime parameter `'a`.
160#[derive(Debug, Clone)]
161pub struct UploadBlobParams<'a> {
162    /// URL template from `Session.upload_url`. `{accountId}` is the only
163    /// template variable substituted before the POST request.
164    ///
165    /// Typed as `&JmapUrlTemplate` (bd:JMAP-6r7c.40) so the compiler
166    /// refuses an accidental `&session.api_url` (a plain `JmapUrl`).
167    pub upload_url_template: &'a crate::request::JmapUrlTemplate,
168    /// Account ID that will own the uploaded blob; substituted for
169    /// `{accountId}` in the URL template.
170    pub account_id: &'a Id,
171    /// Media type sent as the HTTP `Content-Type` request header. Must
172    /// be a valid HTTP header value (no CR/LF, no leading/trailing
173    /// whitespace) or upload fails with
174    /// [`ClientError::InvalidHeaderValue`].
175    pub content_type: &'a str,
176    /// Raw bytes to upload. The pre-upload SHA-256 is computed locally
177    /// and cross-checked against the server's `BlobUploadResponse.sha256`
178    /// (when present); the byte length is cross-checked against the
179    /// server's `BlobUploadResponse.size`.
180    pub data: bytes::Bytes,
181}
182
183/// Request body for [`JmapClient::upload_blob_stream`]: a stream of byte
184/// chunks sent to the server in order.
185///
186/// Build one with `Box::pin(stream)` from any
187/// `Stream<Item = Result<Bytes, std::io::Error>> + Send + 'static`, or with
188/// [`upload_body_from_reader`] from a [`tokio::io::AsyncRead`]. A stream
189/// with another error type can be adapted with
190/// `.map_err(std::io::Error::other)`. An `Err` item aborts the upload and
191/// surfaces as [`ClientError::Http`].
192pub type BlobUploadBody = Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>;
193
194/// Wrap a [`tokio::io::AsyncRead`] (a file, a pipe, a decompressor) as a
195/// [`BlobUploadBody`]. The reader is read in chunks as the request body is
196/// sent; it is never buffered whole.
197pub fn upload_body_from_reader<R>(reader: R) -> BlobUploadBody
198where
199    R: tokio::io::AsyncRead + Send + 'static,
200{
201    Box::pin(tokio_util::io::ReaderStream::new(reader))
202}
203
204/// Make `body` fail unless it yields exactly `declared` bytes.
205///
206/// hyper stops polling a body once `Content-Length` bytes have been sent,
207/// so a longer body would otherwise be truncated silently and the server
208/// would store, and report the size of, only the prefix. On reaching
209/// `declared` bytes the next item is therefore read ahead and must be the
210/// end of the stream.
211fn exact_length_body(body: BlobUploadBody, declared: u64) -> BlobUploadBody {
212    let longer = move || {
213        std::io::Error::other(format!(
214            "upload body is longer than its declared content_length of {declared} bytes"
215        ))
216    };
217    // State: (body, bytes sent so far, whether the stream has finished).
218    Box::pin(futures::stream::unfold(
219        (body, 0u64, false),
220        move |(mut body, mut sent, finished)| async move {
221            if finished {
222                return None;
223            }
224            let chunk = match body.next().await {
225                Some(Ok(chunk)) => chunk,
226                Some(Err(e)) => return Some((Err(e), (body, sent, true))),
227                None if sent == declared => return None,
228                None => {
229                    let err = std::io::Error::other(format!(
230                        "upload body ended after {sent} of its declared {declared} bytes"
231                    ));
232                    return Some((Err(err), (body, sent, true)));
233                }
234            };
235            sent += chunk.len() as u64;
236            if sent > declared {
237                return Some((Err(longer()), (body, sent, true)));
238            }
239            if sent < declared {
240                return Some((Ok(chunk), (body, sent, false)));
241            }
242            loop {
243                match body.next().await {
244                    Some(Ok(extra)) if extra.is_empty() => {}
245                    Some(Ok(_)) => return Some((Err(longer()), (body, sent, true))),
246                    Some(Err(e)) => return Some((Err(e), (body, sent, true))),
247                    None => return Some((Ok(chunk), (body, sent, true))),
248                }
249            }
250        },
251    ))
252}
253
254/// Parameters for [`JmapClient::upload_blob_stream`].
255///
256/// The streaming counterpart of [`UploadBlobParams`]: `body` replaces the
257/// in-memory `data`, and `content_length` states the body length when it
258/// is known in advance.
259///
260/// ```rust,ignore
261/// let file = tokio::fs::File::open("video.mp4").await?;
262/// let len = file.metadata().await?.len();
263/// client.upload_blob_stream(UploadBlobStreamParams {
264///     upload_url_template: &session.upload_url,
265///     account_id: &Id::from("A13824"),
266///     content_type: "video/mp4",
267///     body: upload_body_from_reader(file),
268///     content_length: Some(len),
269/// }).await?;
270/// ```
271pub struct UploadBlobStreamParams<'a> {
272    /// URL template from `Session.upload_url`. `{accountId}` is the only
273    /// template variable substituted before the POST request.
274    pub upload_url_template: &'a crate::request::JmapUrlTemplate,
275    /// Account ID that will own the uploaded blob; substituted for
276    /// `{accountId}` in the URL template.
277    pub account_id: &'a Id,
278    /// Media type sent as the HTTP `Content-Type` request header. Must
279    /// be a valid HTTP header value or upload fails with
280    /// [`ClientError::InvalidHeaderValue`].
281    pub content_type: &'a str,
282    /// The bytes to upload. Their SHA-256 and length are computed as they
283    /// are sent and cross-checked against the server's
284    /// `BlobUploadResponse.sha256` (when present) and `size`, exactly as
285    /// [`JmapClient::upload_blob`] does.
286    pub body: BlobUploadBody,
287    /// Exact byte length of `body`, when known. `Some(n)` is sent as
288    /// `Content-Length: n`; `None` sends the body with chunked
289    /// transfer coding (RFC 9112 §7.1). If `body` yields a different
290    /// number of bytes than `Some(n)` states, the upload fails with
291    /// [`ClientError::Http`].
292    pub content_length: Option<u64>,
293}
294
295impl std::fmt::Debug for UploadBlobStreamParams<'_> {
296    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
297        f.debug_struct("UploadBlobStreamParams")
298            .field("upload_url_template", &self.upload_url_template)
299            .field("account_id", &self.account_id)
300            .field("content_type", &self.content_type)
301            .field("content_length", &self.content_length)
302            .finish_non_exhaustive()
303    }
304}
305
306/// A blob body being downloaded, returned by
307/// [`JmapClient::download_blob_stream`].
308///
309/// Yields the blob as [`Bytes`] chunks in arrival order and ends after
310/// the first `Err`. The
311/// [`ClientConfig::max_download_body`](crate::ClientConfig::max_download_body)
312/// cap is enforced on the running total: the chunk that would cross it is
313/// not yielded, and [`ClientError::ResponseTooLarge`] is yielded instead.
314///
315/// When the download was started with `expected_sha256`, the digest is
316/// checked after the last chunk, and a mismatch is yielded as a final
317/// [`ClientError::BlobIntegrityMismatch`]. Chunks yielded before that
318/// point are therefore **unverified**: treat the blob as intact only once
319/// the stream has ended without an error.
320pub struct BlobDownloadStream {
321    content_length: Option<u64>,
322    inner: Pin<Box<dyn Stream<Item = Result<Bytes, ClientError>> + Send>>,
323}
324
325impl BlobDownloadStream {
326    /// The `Content-Length` the server advertised, if any — for example,
327    /// to size a progress bar. It is already known to be within the
328    /// download cap; the byte count the stream actually yields is what the
329    /// cap and the digest check apply to.
330    pub fn content_length(&self) -> Option<u64> {
331        self.content_length
332    }
333}
334
335impl Stream for BlobDownloadStream {
336    type Item = Result<Bytes, ClientError>;
337
338    fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
339        self.inner.poll_next_unpin(cx)
340    }
341}
342
343impl std::fmt::Debug for BlobDownloadStream {
344    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
345        f.debug_struct("BlobDownloadStream")
346            .field("content_length", &self.content_length)
347            .finish_non_exhaustive()
348    }
349}
350
351/// State threaded through the [`BlobDownloadStream`] unfold loop.
352struct DownloadState {
353    body: Pin<Box<dyn Stream<Item = reqwest::Result<Bytes>> + Send>>,
354    /// Running hasher and the caller's expected digest; `None` when the
355    /// caller did not ask for verification.
356    verify: Option<(Sha256, jmap_cid_types::Sha256)>,
357    received: u64,
358    limit: u64,
359    /// Set once an error has been yielded, so the stream ends after it.
360    done: bool,
361}
362
363impl DownloadState {
364    async fn next(mut self) -> Option<(Result<Bytes, ClientError>, Self)> {
365        if self.done {
366            return None;
367        }
368        let err = match self.body.next().await {
369            Some(Ok(chunk)) => {
370                self.received += chunk.len() as u64;
371                if self.received > self.limit {
372                    ClientError::ResponseTooLarge {
373                        actual: self.received,
374                        limit: self.limit,
375                    }
376                } else {
377                    if let Some((hasher, _)) = self.verify.as_mut() {
378                        hasher.update(&chunk);
379                    }
380                    return Some((Ok(chunk), self));
381                }
382            }
383            Some(Err(e)) => ClientError::from_reqwest(e),
384            None => {
385                // End of body: finish with nothing when no digest was
386                // requested or it matches, else with the mismatch error.
387                let (hasher, expected) = self.verify.take()?;
388                verify_download_digest(&expected, hex_lower(&hasher.finalize())).err()?
389            }
390        };
391        self.done = true;
392        Some((Err(err), self))
393    }
394}
395
396/// Response body returned by a successful blob upload (RFC 8620 §6.1).
397///
398/// # SemVer coupling with `jmap-cid-types` (bd:JMAP-6r7c.30)
399///
400/// The `sha256` field uses `jmap_cid_types::Sha256` — a workspace-sibling
401/// type, not a wrapped opaque type the way `reqwest::Error` is wrapped
402/// behind [`HttpError`](crate::HttpError). Consumers that touch
403/// `BlobUploadResponse.sha256` transitively depend on `jmap-cid-types` and
404/// must pin its major version alongside `jmap-base-client`.
405///
406/// The coupling is deliberate. `jmap-cid-types` is a workspace sibling of
407/// `jmap-base-client` (both live in the `crate-jmap` workspace) and ships
408/// in the same release cadence — every `jmap-cid-types` major bump is also
409/// a `jmap-base-client` major bump. The SemVer-isolation pattern that
410/// hides `reqwest::Error` behind [`HttpError`](crate::HttpError) is
411/// designed for *third-party* deps whose release cadence is uncorrelated
412/// with this crate's; workspace siblings do not need that isolation
413/// because the workspace-level major-version policy already coordinates
414/// them.
415///
416/// Third-party consumers picking up `jmap-base-client` from crates.io
417/// should declare both deps with matching majors:
418///
419/// ```toml
420/// [dependencies]
421/// jmap-base-client = "0.1"
422/// jmap-cid-types   = "0.1"
423/// ```
424///
425/// If you only ever pattern-match on `Option::Some(_)` (without naming the
426/// inner type) you can skip the explicit `jmap-cid-types` dep; touching
427/// `Sha256`'s methods or `AsRef<str>` impl requires it.
428///
429/// # `extra` equality is feature-flag-dependent (bd:JMAP-6r7c.43)
430///
431/// The derived `PartialEq` / `Eq` impl's behaviour on the `extra` field
432/// depends on the global `serde_json/preserve_order` feature flag — see
433/// the [crate-level note](crate#extra-field-equality-and-the-serde_jsonpreserve_order-feature-bdjmap-6r7c43)
434/// for the canonical statement and the workspace posture.
435#[non_exhaustive]
436#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
437#[serde(rename_all = "camelCase")]
438pub struct BlobUploadResponse {
439    /// The account the blob was uploaded to.
440    pub account_id: Id,
441    /// Server-assigned opaque blob identifier.
442    pub blob_id: Id,
443    /// Media type of the uploaded blob as determined by the server.
444    #[serde(rename = "type")]
445    pub content_type: String,
446    /// Size of the uploaded blob in bytes.
447    pub size: u64,
448    /// SHA-256 digest of the uploaded blob, present only when the
449    /// server advertises the `urn:ietf:params:jmap:cid` capability
450    /// (draft-atwood-jmap-cid-00 §3).
451    ///
452    /// The wire format is a 64-character lowercase-hex string per
453    /// the draft's ABNF (`%x30-39 / %x61-66`). The typed
454    /// [`jmap_cid_types::Sha256`] enforces that shape on
455    /// deserialize: a server response carrying a sha256 field that
456    /// is not exactly 64 bytes of lowercase hex will fail to parse
457    /// and surface as [`ClientError::Parse`]. Servers that do not
458    /// implement the CID extension omit the field; the typed
459    /// representation here is `None`.
460    ///
461    /// History: bd:JMAP-v9py.13 promoted this field from a permissive
462    /// `Option<String>` to the typed `Option<jmap_cid_types::Sha256>`,
463    /// and bd:JMAP-6r7c.48 propagated the same typed shape to the
464    /// download-side [`DownloadBlobParams::expected_sha256`](crate::DownloadBlobParams::expected_sha256)
465    /// caller-supplied argument. The previous implementation tolerated
466    /// uppercase hex via a permissive ad-hoc validator and a normalize-
467    /// to-lowercase step before integrity comparison; the typed path is
468    /// strict on every construction site. The inter-op question (whether
469    /// to recover the uppercase tolerance via a custom Deserialize wrapper)
470    /// is tracked by bd:JMAP-noz7.
471    #[serde(default, skip_serializing_if = "Option::is_none")]
472    pub sha256: Option<jmap_cid_types::Sha256>,
473
474    /// Catch-all for vendor / site / private extension fields not covered
475    /// by the typed fields above. Preserves unknown fields across
476    /// deserialize/serialize round-trip per workspace extras-preservation
477    /// policy (see workspace AGENTS.md).
478    #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
479    pub extra: serde_json::Map<String, serde_json::Value>,
480}
481
482/// Expand a RFC 6570 Level-1 URI template by substituting variables.
483///
484/// For each `(name, value)` pair in `vars`, replaces `{name}` in `template`
485/// with the percent-encoded form of `value`. Encoding follows RFC 3986
486/// unreserved characters (ALPHA / DIGIT / `-` / `.` / `_` / `~`), which pass
487/// through unchanged; all other bytes are encoded as `%XX` with uppercase hex
488/// (RFC 3986 §2.1 requires uppercase).
489///
490/// Entries in `vars` whose name does not appear in the template are silently
491/// ignored. A variable that appears in the template but has no entry in `vars`
492/// is an error (`ClientError::InvalidSession`) because templates come from the
493/// server's Session document — an unexpected variable indicates a server bug.
494///
495/// # RFC 6570 Level-1
496/// Only simple-string expansion is supported. Reserved-expansion (`{+var}`)
497/// and other Level-2+ operators are not supported.
498///
499/// # ⚠ Level-2+ templates silently mis-expand (bd:JMAP-6r7c.32)
500///
501/// A server that returns a Level-2 or higher template — e.g.
502/// `{+blobId}` (reserved-expansion), `{#var}` (fragment-style), or
503/// `{var*}` (list-shaped) — will NOT be detected as malformed. The
504/// `{+blobId}` form parses as a variable name `+blobId` which is then
505/// either replaced verbatim (if a caller happens to pass that exact
506/// name in `vars`) or rejected as an unknown variable. The
507/// percent-encoding logic treats every non-unreserved byte as
508/// something to escape; Level-2 reserved expansion EXPECTS reserved
509/// characters like `/` and `:` to pass through unchanged. A server
510/// using `{+downloadId}` to embed a slash-bearing identifier directly
511/// would have those slashes percent-encoded by this function and the
512/// resulting URL would be rejected server-side.
513///
514/// **JMAP servers SHOULD use Level-1 templates only per RFC 8620 §2.**
515/// If your server uses higher levels, you must expand the template
516/// yourself with an RFC-6570-compliant external library and pass the
517/// already-expanded URL to methods that accept a plain URL — do not
518/// pass a Level-2+ template through this function.
519///
520/// # Usage with `subscribe_events`
521///
522/// [`Session::event_source_url`] is a URI template with variables `types`,
523/// `closeafter`, and `ping`. Expand it before calling
524/// [`JmapClient::subscribe_events`]:
525///
526/// ```rust,ignore
527/// let url = expand_url_template(
528///     &session.event_source_url,
529///     &[
530///         ("types", "Email,Mailbox"),
531///         ("closeafter", "state"),
532///         ("ping", "0"),
533///     ],
534/// )?;
535/// client.subscribe_events(&url, None).await?;
536/// ```
537///
538/// [`Session::event_source_url`]: crate::request::Session::event_source_url
539/// [`JmapClient::subscribe_events`]: crate::client::JmapClient::subscribe_events
540pub fn expand_url_template(template: &str, vars: &[(&str, &str)]) -> Result<String, ClientError> {
541    let mut result = String::with_capacity(template.len() + 64);
542    let mut rest = template;
543    while let Some(open) = rest.find('{') {
544        result.push_str(&rest[..open]);
545        rest = &rest[open + 1..];
546        let close = rest.find('}').ok_or_else(|| {
547            ClientError::InvalidSession("URL template has unmatched '{'".to_owned())
548        })?;
549        let name = &rest[..close];
550        rest = &rest[close + 1..];
551        if let Some((_, value)) = vars.iter().find(|(n, _)| *n == name) {
552            result.push_str(&percent_encode(value));
553        } else {
554            return Err(ClientError::InvalidSession(format!(
555                "URL template variable not supplied: {{{name}}}"
556            )));
557        }
558    }
559    result.push_str(rest);
560    Ok(result)
561}
562
563/// Percent-encode a string value per RFC 3986 §2.3 unreserved character set.
564///
565/// The return type is [`Cow<'_, str>`](Cow) so the common JMAP case — inputs
566/// that contain only unreserved characters (alphanumeric / `-` / `.` / `_` /
567/// `~`) such as account ids and blob ids — borrows the input slice and
568/// performs no allocation. Inputs that contain any byte requiring percent-
569/// escape allocate a fresh string with the encoded form.
570fn percent_encode(value: &str) -> Cow<'_, str> {
571    // Fast path: scan for the first byte that needs escaping. If none, no
572    // allocation is required.
573    let first_escape = value.bytes().position(|b| !is_unreserved(b));
574    let Some(first) = first_escape else {
575        return Cow::Borrowed(value);
576    };
577
578    // Allocate only when at least one byte needs escaping. Reserve at least
579    // enough capacity for the unchanged prefix plus the three-byte encoding
580    // of the first escaped byte; further escapes will grow as needed.
581    let mut out = String::with_capacity(value.len() + 2);
582    out.push_str(&value[..first]);
583    for byte in value.as_bytes()[first..].iter().copied() {
584        if is_unreserved(byte) {
585            out.push(char::from(byte));
586        } else {
587            out.push('%');
588            out.push(hex_nibble_upper(byte >> 4));
589            out.push(hex_nibble_upper(byte & 0x0f));
590        }
591    }
592    Cow::Owned(out)
593}
594
595/// Returns `true` if `byte` is in the RFC 3986 §2.3 unreserved set.
596fn is_unreserved(byte: u8) -> bool {
597    byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'.' || byte == b'_' || byte == b'~'
598}
599
600/// Returns the uppercase hex character for `nibble` (0–15).
601/// Used for percent-encoding (RFC 3986 §2.1 requires uppercase).
602fn hex_nibble_upper(nibble: u8) -> char {
603    match nibble {
604        0..=9 => char::from(b'0' + nibble),
605        10..=15 => char::from(b'A' + nibble - 10),
606        _ => unreachable!("nibble must be 0–15"),
607    }
608}
609
610/// Returns the lowercase hex character for `nibble` (0–15).
611/// Used for SHA-256 hex output (JMAP-CID §1 requires lowercase).
612fn hex_nibble_lower(nibble: u8) -> char {
613    match nibble {
614        0..=9 => char::from(b'0' + nibble),
615        10..=15 => char::from(b'a' + nibble - 10),
616        _ => unreachable!("nibble must be 0–15"),
617    }
618}
619
620impl JmapClient {
621    /// Upload raw bytes to the JMAP blob store (RFC 8620 §6.1).
622    ///
623    /// `params.upload_url_template` is from `Session.upload_url`;
624    /// `{accountId}` is substituted before the request.
625    /// `params.content_type` is sent as the `Content-Type` header. If the
626    /// server returns a `sha256` field (JMAP-CID capability), it is
627    /// verified against the locally-computed digest and
628    /// `ClientError::BlobIntegrityMismatch` is returned on mismatch.
629    pub async fn upload_blob(
630        &self,
631        params: UploadBlobParams<'_>,
632    ) -> Result<BlobUploadResponse, ClientError> {
633        let UploadBlobParams {
634            upload_url_template,
635            account_id,
636            content_type,
637            data,
638        } = params;
639        let req = self.upload_request(upload_url_template, account_id, content_type)?;
640
641        // Compute SHA-256 and capture size before handing ownership of data
642        // to the request body. local_size is used to cross-check the server's
643        // reported `size` after upload (bd:JMAP-6lsm.8) — when the server
644        // does not return sha256 (most do not), size is the only signal that
645        // the bytes we sent are the bytes the server stored.
646        let local_sha256 = compute_sha256_hex(&data);
647        let local_size = data.len() as u64;
648
649        let upload_resp = self.send_upload(req.body(data)).await?;
650        verify_upload_response(&upload_resp, local_size, local_sha256)?;
651        Ok(upload_resp)
652    }
653
654    /// Upload a blob from a byte stream (RFC 8620 §6.1) without holding it
655    /// in memory.
656    ///
657    /// The streaming counterpart of [`Self::upload_blob`], with the same URL
658    /// expansion, `Content-Type`, response cap and integrity checks: the
659    /// SHA-256 and length of the bytes are computed as each chunk is sent,
660    /// then compared with the server's `BlobUploadResponse.size` (mismatch:
661    /// [`ClientError::UnexpectedResponse`]) and `sha256` when present
662    /// (mismatch: [`ClientError::BlobIntegrityMismatch`]).
663    ///
664    /// [`ClientConfig::request_timeout`](crate::ClientConfig::request_timeout)
665    /// bounds the whole upload, including sending the body. Large uploads
666    /// over slow links need a client configured with a longer timeout.
667    pub async fn upload_blob_stream(
668        &self,
669        params: UploadBlobStreamParams<'_>,
670    ) -> Result<BlobUploadResponse, ClientError> {
671        let UploadBlobStreamParams {
672            upload_url_template,
673            account_id,
674            content_type,
675            body,
676            content_length,
677        } = params;
678        let mut req = self.upload_request(upload_url_template, account_id, content_type)?;
679        let body = match content_length {
680            Some(len) => {
681                req = req.header(CONTENT_LENGTH, len);
682                exact_length_body(body, len)
683            }
684            None => body,
685        };
686
687        // reqwest owns the body while sending, so the digest and byte count
688        // are accumulated through a shared tally as each chunk is pulled.
689        let tally = Arc::new(Mutex::new((Sha256::new(), 0u64)));
690        let tap = Arc::clone(&tally);
691        let body = body.inspect(move |chunk| {
692            if let Ok(bytes) = chunk {
693                let mut tally = tap.lock().unwrap_or_else(PoisonError::into_inner);
694                tally.0.update(bytes);
695                tally.1 += bytes.len() as u64;
696            }
697        });
698
699        let upload_resp = self
700            .send_upload(req.body(reqwest::Body::wrap_stream(body)))
701            .await?;
702        let (hasher, local_size) = tally.lock().unwrap_or_else(PoisonError::into_inner).clone();
703        verify_upload_response(&upload_resp, local_size, hex_lower(&hasher.finalize()))?;
704        Ok(upload_resp)
705    }
706
707    /// Build the authenticated `POST` to the expanded upload URL, with
708    /// `Content-Type` set and no body yet.
709    fn upload_request(
710        &self,
711        upload_url_template: &crate::request::JmapUrlTemplate,
712        account_id: &Id,
713        content_type: &str,
714    ) -> Result<reqwest::RequestBuilder, ClientError> {
715        crate::client::require_http_url(upload_url_template.as_str())?;
716        let ct_hv =
717            HeaderValue::from_str(content_type).map_err(ClientError::from_invalid_header)?;
718        let url = expand_url_template(
719            upload_url_template.as_str(),
720            &[("accountId", account_id.as_ref())],
721        )?;
722        Ok(self.inject_auth(
723            self.http
724                .post(&url)
725                .header(CONTENT_TYPE, ct_hv)
726                .timeout(self.config.request_timeout),
727        ))
728    }
729
730    /// Send an upload request and parse the server's `BlobUploadResponse`.
731    async fn send_upload(
732        &self,
733        req: reqwest::RequestBuilder,
734    ) -> Result<BlobUploadResponse, ClientError> {
735        let resp = req.send().await.map_err(ClientError::from_reqwest)?;
736        let status = resp.status();
737        Self::check_auth_status(status)?;
738        Self::check_rate_limit(&resp)?;
739        let resp = resp.error_for_status().map_err(ClientError::from_reqwest)?;
740
741        let upload_limit = self.config.max_upload_response_body;
742
743        // Stream the body chunk-by-chunk with the cap enforced before each
744        // accumulation (bd:JMAP-6r7c.1). See `read_capped_body` for the DOS
745        // rationale; without per-chunk streaming, a server that under-reports
746        // or omits Content-Length can force unbounded allocation here.
747        let bytes = read_capped_body(resp, upload_limit).await?;
748        serde_json::from_slice(&bytes).map_err(ClientError::from_parse)
749    }
750
751    /// Download a blob by ID (RFC 8620 §6.2).
752    ///
753    /// Template variables `{accountId}`, `{blobId}`, `{name}`, and `{type}` are
754    /// substituted from the corresponding fields of `params` before the GET
755    /// request. `{type}` expands to an empty string when `params.accept_type`
756    /// is `None`; templates that include `?accept={type}` produce `?accept=`.
757    /// If the server does not tolerate an empty `?accept=` parameter, omit
758    /// `{type}` from the `download_url` template in the Session document.
759    ///
760    /// If `params.expected_sha256` is `Some`, the downloaded bytes are verified
761    /// against the typed [`jmap_cid_types::Sha256`] digest and
762    /// `ClientError::BlobIntegrityMismatch` is returned on mismatch.
763    pub async fn download_blob(
764        &self,
765        params: DownloadBlobParams<'_>,
766    ) -> Result<bytes::Bytes, ClientError> {
767        let expected_sha256 = params.expected_sha256;
768        let resp = self.download_response(params).await?;
769
770        let download_limit = self.config.max_download_body;
771
772        // Stream the body chunk-by-chunk with the cap enforced before each
773        // accumulation (bd:JMAP-6r7c.1). See `read_capped_body` for the DOS
774        // rationale; without per-chunk streaming, a server that under-reports
775        // or omits Content-Length can force unbounded allocation here.
776        let bytes = bytes::Bytes::from(read_capped_body(resp, download_limit).await?);
777
778        if let Some(expected) = expected_sha256 {
779            // The typed `jmap_cid_types::Sha256` enforces the canonical
780            // 64-char lowercase-hex ABNF at construction (bd:JMAP-6r7c.48),
781            // so the runtime length/charset check and the
782            // `to_ascii_lowercase` normalize step the previous
783            // `Option<&str>` shape required (bd:JMAP-6r7c.53) are gone.
784            verify_download_digest(expected, compute_sha256_hex(&bytes))?;
785        }
786
787        Ok(bytes)
788    }
789
790    /// Download a blob by ID (RFC 8620 §6.2) as a stream of chunks instead
791    /// of one buffer.
792    ///
793    /// The streaming counterpart of [`Self::download_blob`], with the same
794    /// URL expansion and the same
795    /// [`ClientConfig::max_download_body`](crate::ClientConfig::max_download_body)
796    /// cap. An advertised `Content-Length` over the cap fails here with
797    /// [`ClientError::ResponseTooLarge`]; a body that grows past the cap
798    /// while streaming yields that error from the stream. When
799    /// `params.expected_sha256` is `Some`, the digest is checked at end of
800    /// stream — see [`BlobDownloadStream`] for what that means for chunks
801    /// already yielded.
802    ///
803    /// [`ClientConfig::request_timeout`](crate::ClientConfig::request_timeout)
804    /// bounds the whole download, including reading the stream. Large
805    /// downloads over slow links need a client configured with a longer
806    /// timeout (and, usually, a larger `max_download_body`).
807    pub async fn download_blob_stream(
808        &self,
809        params: DownloadBlobParams<'_>,
810    ) -> Result<BlobDownloadStream, ClientError> {
811        let expected_sha256 = params.expected_sha256.cloned();
812        let resp = self.download_response(params).await?;
813
814        let limit = self.config.max_download_body;
815        let content_length = resp.content_length();
816        if let Some(len) = content_length
817            && len > limit
818        {
819            return Err(ClientError::ResponseTooLarge { actual: len, limit });
820        }
821
822        let state = DownloadState {
823            body: Box::pin(resp.bytes_stream()),
824            verify: expected_sha256.map(|expected| (Sha256::new(), expected)),
825            received: 0,
826            limit,
827            done: false,
828        };
829        Ok(BlobDownloadStream {
830            content_length,
831            inner: Box::pin(futures::stream::unfold(state, DownloadState::next).fuse()),
832        })
833    }
834
835    /// Send the authenticated `GET` for a blob and return the response once
836    /// its status has been checked, with the body still unread.
837    async fn download_response(
838        &self,
839        params: DownloadBlobParams<'_>,
840    ) -> Result<reqwest::Response, ClientError> {
841        let DownloadBlobParams {
842            download_url_template,
843            account_id,
844            blob_id,
845            name,
846            accept_type,
847            expected_sha256: _,
848        } = params;
849        crate::client::require_http_url(download_url_template.as_str())?;
850        let vars = [
851            ("accountId", account_id.as_ref()),
852            ("blobId", blob_id.as_ref()),
853            ("name", name),
854            // Always supply {type} — even as empty string — so templates
855            // containing `?accept={type}` expand cleanly rather than triggering
856            // the unexpanded-placeholder error.
857            ("type", accept_type.unwrap_or("")),
858        ];
859        let url = expand_url_template(download_url_template.as_str(), &vars)?;
860
861        let req = self.inject_auth(self.http.get(&url).timeout(self.config.request_timeout));
862
863        let resp = req.send().await.map_err(ClientError::from_reqwest)?;
864        let status = resp.status();
865        Self::check_auth_status(status)?;
866        Self::check_rate_limit(&resp)?;
867        resp.error_for_status().map_err(ClientError::from_reqwest)
868    }
869
870    /// Upload raw bytes via a [`crate::Session`]-supplied URL
871    /// template (bd:JMAP-6r7c.64).
872    ///
873    /// Type-safe convenience wrapper over [`Self::upload_blob`] —
874    /// supplies `session.upload_url` for `upload_url_template`
875    /// internally. The caller cannot accidentally pass `session.api_url`
876    /// or any other URL field because the parameter set
877    /// ([`UploadBlobSessionParams`]) does not include a URL field.
878    pub async fn upload_blob_session(
879        &self,
880        session: &crate::request::Session,
881        params: UploadBlobSessionParams<'_>,
882    ) -> Result<BlobUploadResponse, ClientError> {
883        let UploadBlobSessionParams {
884            account_id,
885            content_type,
886            data,
887        } = params;
888        self.upload_blob(UploadBlobParams {
889            upload_url_template: &session.upload_url,
890            account_id,
891            content_type,
892            data,
893        })
894        .await
895    }
896
897    /// Download a blob via a [`crate::Session`]-supplied URL
898    /// template (bd:JMAP-6r7c.64).
899    ///
900    /// Type-safe convenience wrapper over [`Self::download_blob`] —
901    /// supplies `session.download_url` for `download_url_template`
902    /// internally. See [`Self::upload_blob_session`] for the
903    /// rationale.
904    pub async fn download_blob_session(
905        &self,
906        session: &crate::request::Session,
907        params: DownloadBlobSessionParams<'_>,
908    ) -> Result<bytes::Bytes, ClientError> {
909        let DownloadBlobSessionParams {
910            account_id,
911            blob_id,
912            name,
913            accept_type,
914            expected_sha256,
915        } = params;
916        self.download_blob(DownloadBlobParams {
917            download_url_template: &session.download_url,
918            account_id,
919            blob_id,
920            name,
921            accept_type,
922            expected_sha256,
923        })
924        .await
925    }
926}
927
928/// Compute SHA-256 of `data` and return as 64-char lowercase hex string.
929fn compute_sha256_hex(data: &[u8]) -> String {
930    hex_lower(&Sha256::digest(data))
931}
932
933/// Format a digest as lowercase hex (JMAP-CID §1 requires lowercase).
934fn hex_lower(digest: &[u8]) -> String {
935    let mut s = String::with_capacity(digest.len() * 2);
936    for b in digest {
937        s.push(hex_nibble_lower(*b >> 4));
938        s.push(hex_nibble_lower(*b & 0x0f));
939    }
940    s
941}
942
943/// Cross-check a `BlobUploadResponse` against the length and SHA-256 hex
944/// digest of the bytes the client sent.
945fn verify_upload_response(
946    upload_resp: &BlobUploadResponse,
947    local_size: u64,
948    local_sha256: String,
949) -> Result<(), ClientError> {
950    // Defense-in-depth: cross-check the server's reported size against the
951    // bytes we actually uploaded (bd:JMAP-6lsm.8). When sha256 is present
952    // (below) it makes a size mismatch implausible because length is
953    // implicit in the digest, but most servers do not advertise the
954    // JMAP-CID sha256 capability — in that case `size` is the only
955    // signal that the upload was complete and intact. A mismatch
956    // surfaces as UnexpectedResponse rather than a typed variant; a
957    // future minor release may add ClientError::BlobSizeMismatch for
958    // structured matching, but keeping the variant set stable in 0.1.x
959    // is the conservative choice.
960    if upload_resp.size != local_size {
961        return Err(ClientError::UnexpectedResponse(format!(
962            "blob upload size mismatch: client uploaded {local_size} bytes, server reports \
963             {server_size} bytes",
964            server_size = upload_resp.size,
965        )));
966    }
967
968    if let Some(ref server_sha256) = upload_resp.sha256 {
969        // The typed `jmap_cid_types::Sha256` deserialize already
970        // enforces the 64-character lowercase-hex ABNF; reaching
971        // this branch implies the wire value is canonical. We
972        // can compare the locally-computed digest (also
973        // canonical lowercase hex from `hex_lower`)
974        // against the typed value's string form directly.
975        //
976        // Uppercase-hex from a non-conformant server is rejected
977        // at deserialize per draft-atwood-jmap-cid-00 §2 ABNF and
978        // surfaces as ClientError::Parse before this branch runs.
979        //
980        // Constant-time compare via `hex_digest_eq` (bd:JMAP-6r7c.61):
981        // both sides are canonical 64-char lowercase hex by construction.
982        if !hex_digest_eq(&local_sha256, server_sha256.as_str()) {
983            return Err(ClientError::BlobIntegrityMismatch {
984                expected: local_sha256,
985                actual: server_sha256.as_str().to_owned(),
986            });
987        }
988    }
989    Ok(())
990}
991
992/// Compare the caller's expected digest with the SHA-256 hex digest of the
993/// bytes actually downloaded.
994fn verify_download_digest(
995    expected: &jmap_cid_types::Sha256,
996    actual: String,
997) -> Result<(), ClientError> {
998    // Constant-time compare via `hex_digest_eq` (bd:JMAP-6r7c.61):
999    // both sides are canonical 64-char lowercase hex by construction.
1000    if hex_digest_eq(&actual, expected.as_str()) {
1001        Ok(())
1002    } else {
1003        Err(ClientError::BlobIntegrityMismatch {
1004            expected: expected.as_str().to_owned(),
1005            actual,
1006        })
1007    }
1008}
1009
1010/// Constant-time equality for two 64-character lowercase SHA-256 hex digests
1011/// (bd:JMAP-6r7c.61).
1012///
1013/// Both upload-side and download-side integrity checks compare hex digests
1014/// that are guaranteed-canonical by construction before reaching this
1015/// helper: `compute_sha256_hex` always emits 64 lowercase nibbles, and
1016/// `jmap_cid_types::Sha256` enforces the 64-char lowercase-hex ABNF on
1017/// every construction path (deserialize, `from_hex`, etc.). Length-
1018/// discrimination is therefore not an information leak at these call sites.
1019///
1020/// Using `subtle::ConstantTimeEq::ct_eq` over `==` is discipline-propagation,
1021/// not a defense against a concrete JMAP threat: SHA-256 of blob bytes is
1022/// not a secret (the attacker can fetch the same blob), so a timing oracle
1023/// on the digest comparison reveals nothing exploitable in the upload-side
1024/// case. The download-side `expected_sha256` argument is caller-supplied
1025/// and *could* in principle come from a private channel (signed manifest,
1026/// end-to-end attestation); the constant-time compare closes that residual
1027/// channel for callers who want it.
1028///
1029/// Matches the workspace's RustCrypto-first stance and the precedent set by
1030/// `crate-jmap-chat-server` (invite-code lookup) and `crate-jmap-testjig`
1031/// (bearer-token check).
1032fn hex_digest_eq(a: &str, b: &str) -> bool {
1033    a.as_bytes().ct_eq(b.as_bytes()).into()
1034}
1035
1036#[cfg(test)]
1037mod tests {
1038    use super::*;
1039
1040    // Oracle: RFC 6570 §1.2 — simple substitution with unreserved-only value
1041    #[test]
1042    fn expand_upload_url() {
1043        let result = expand_url_template(
1044            "https://example.com/upload/{accountId}/",
1045            &[("accountId", "account1")],
1046        )
1047        .expect("must succeed");
1048        assert_eq!(result, "https://example.com/upload/account1/");
1049    }
1050
1051    // Oracle: RFC 3986 §2.1 — space 0x20 encodes as %20
1052    #[test]
1053    fn expand_download_url_with_spaces() {
1054        let result = expand_url_template(
1055            "/download/{accountId}/{blobId}/{name}",
1056            &[
1057                ("accountId", "acc1"),
1058                ("blobId", "blob-123"),
1059                ("name", "my file.png"),
1060            ],
1061        )
1062        .expect("must succeed");
1063        assert_eq!(result, "/download/acc1/blob-123/my%20file.png");
1064    }
1065
1066    // Oracle: RFC 3986 §2.1 — slash 0x2F encodes as %2F
1067    #[test]
1068    fn expand_with_slash_in_type() {
1069        let result = expand_url_template(
1070            "/dl/{accountId}/{blobId}/{name}?accept={type}",
1071            &[
1072                ("accountId", "a"),
1073                ("blobId", "b"),
1074                ("name", "x.jpg"),
1075                ("type", "image/png"),
1076            ],
1077        )
1078        .expect("must succeed");
1079        assert_eq!(result, "/dl/a/b/x.jpg?accept=image%2Fpng");
1080    }
1081
1082    // Oracle: expand_url_template must error when a template variable has no
1083    // entry in vars — the template comes from the server's Session document,
1084    // so an unrecognized variable name indicates a server-side bug.
1085    #[test]
1086    fn expand_unknown_variable_returns_error() {
1087        let err = expand_url_template(
1088            "https://example.com/upload/{accountId}/{unknownVar}/",
1089            &[("accountId", "acc1")],
1090        )
1091        .expect_err("must fail when template variable is not supplied");
1092        assert!(
1093            matches!(err, crate::error::ClientError::InvalidSession(_)),
1094            "expected InvalidSession, got {err:?}"
1095        );
1096    }
1097
1098    // Oracle: expand_url_template must error on a template with an unmatched
1099    // '{' — no corresponding '}' exists. The expected error is InvalidSession
1100    // because templates come from the server's Session document.
1101    #[test]
1102    fn expand_unmatched_open_brace_returns_error() {
1103        let err = expand_url_template("https://example.com/{unclosed", &[])
1104            .expect_err("unmatched '{' must return an error");
1105        assert!(
1106            matches!(err, crate::error::ClientError::InvalidSession(_)),
1107            "expected InvalidSession for unmatched brace, got {err:?}"
1108        );
1109    }
1110
1111    // Oracle: vars entries whose name does not appear in the template are
1112    // silently ignored — extra vars are benign (caller may pass a superset).
1113    #[test]
1114    fn expand_unused_var_is_ignored() {
1115        let result = expand_url_template(
1116            "https://example.com/upload/{accountId}/",
1117            &[("accountId", "acc1"), ("extraVar", "value")],
1118        )
1119        .expect("extra vars must be silently ignored");
1120        assert_eq!(result, "https://example.com/upload/acc1/");
1121    }
1122
1123    // Oracle: RFC 3986 §2.3 — for inputs containing only unreserved
1124    // characters (alphanumeric / `-` / `.` / `_` / `~`), percent_encode
1125    // MUST return Cow::Borrowed without allocating. Verified by the
1126    // matches! check against the Cow::Borrowed variant.
1127    #[test]
1128    fn percent_encode_unreserved_input_borrows() {
1129        let input = "abc.DEF_123-xyz~tilde";
1130        let result = percent_encode(input);
1131        assert!(matches!(result, Cow::Borrowed(_)));
1132        assert_eq!(result.as_ref(), input);
1133    }
1134
1135    // Oracle: RFC 3986 §2.1 — inputs that contain any byte outside the
1136    // unreserved set MUST be returned as Cow::Owned with each non-unreserved
1137    // byte percent-escaped using uppercase hex (§2.1 mandates uppercase).
1138    #[test]
1139    fn percent_encode_with_space_is_owned_and_uppercase() {
1140        let input = "hello world";
1141        let result = percent_encode(input);
1142        assert!(matches!(result, Cow::Owned(_)));
1143        assert_eq!(result.as_ref(), "hello%20world");
1144    }
1145
1146    // Oracle: RFC 3986 §2.1 — slash 0x2F encodes as %2F (uppercase hex).
1147    // Mixed-content input MUST preserve the unreserved prefix verbatim
1148    // and escape only the disallowed bytes.
1149    #[test]
1150    fn percent_encode_mixed_input_preserves_prefix() {
1151        let input = "image/png";
1152        let result = percent_encode(input);
1153        assert!(matches!(result, Cow::Owned(_)));
1154        assert_eq!(result.as_ref(), "image%2Fpng");
1155    }
1156
1157    // Oracle: degenerate empty input. An empty string has no bytes requiring
1158    // escape, so percent_encode MUST borrow it without allocation.
1159    #[test]
1160    fn percent_encode_empty_input_borrows() {
1161        let result = percent_encode("");
1162        assert!(matches!(result, Cow::Borrowed(_)));
1163        assert_eq!(result.as_ref(), "");
1164    }
1165
1166    // Oracle: tests/fixtures/blob/upload_response.json — hand-written fixture
1167    // derived from RFC 8620 §6.1 blob upload response shape; not produced by
1168    // the code under test.
1169    #[test]
1170    fn blob_upload_response_deserializes() {
1171        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1172            .join("tests/fixtures/blob/upload_response.json");
1173        let text =
1174            std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("cannot read fixture: {e}"));
1175        let resp: BlobUploadResponse =
1176            serde_json::from_str(&text).expect("fixture must deserialize as BlobUploadResponse");
1177
1178        assert_eq!(resp.account_id, "account1");
1179        assert_eq!(resp.blob_id, "Gbc4c377-c8c3-4b48-b2bb-8c1e4cfb8b2a");
1180        assert_eq!(resp.content_type, "image/png");
1181        assert_eq!(resp.size, 48291);
1182        // The typed Sha256 wrapper carries the canonical lowercase
1183        // hex string; compare via `as_str` to keep the assertion
1184        // independent of the wrapper's Debug shape.
1185        assert_eq!(
1186            resp.sha256.as_ref().map(jmap_cid_types::Sha256::as_str),
1187            Some("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")
1188        );
1189    }
1190
1191    /// `BlobUploadResponse.extra` captures unknown fields on deserialize.
1192    #[test]
1193    fn blob_upload_response_preserves_vendor_extras() {
1194        let raw = serde_json::json!({
1195            "accountId": "account1",
1196            "blobId": "Gbc4c377-c8c3-4b48-b2bb-8c1e4cfb8b2a",
1197            "type": "image/png",
1198            "size": 48291,
1199            "acmeCorpScanResult": "clean"
1200        });
1201        let obj: BlobUploadResponse =
1202            serde_json::from_value(raw).expect("BlobUploadResponse must deserialize");
1203        assert_eq!(
1204            obj.extra.get("acmeCorpScanResult").and_then(|v| v.as_str()),
1205            Some("clean")
1206        );
1207    }
1208
1209    // bd:JMAP-v9py.13 oracle.
1210    //
1211    // The fixture hex digest is hand-typed from RFC 6234 §8.5 ("abc"
1212    // test vector, formatted lowercase). It is NOT derived from the
1213    // code under test, satisfying the workspace test-integrity rule
1214    // that test oracles must be independent.
1215
1216    /// Deserialize a Blob/upload response carrying a canonical
1217    /// 64-char lowercase-hex sha256 → field parses as
1218    /// Some(Sha256(_)) preserving the wire string.
1219    #[test]
1220    fn blob_upload_response_deserializes_sha256_typed() {
1221        let raw = serde_json::json!({
1222            "accountId": "account1",
1223            "blobId": "blob1",
1224            "type": "text/plain",
1225            "size": 3,
1226            "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
1227        });
1228        let obj: BlobUploadResponse = serde_json::from_value(raw).expect("must deserialize");
1229        let s = obj.sha256.expect("sha256 must be Some");
1230        assert_eq!(
1231            s.as_str(),
1232            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
1233        );
1234    }
1235
1236    /// Serialize a BlobUploadResponse with `sha256: None` → the
1237    /// `sha256` key must be ABSENT from the wire output per
1238    /// `skip_serializing_if = "Option::is_none"`. This is the
1239    /// contract for servers not advertising the CID capability.
1240    #[test]
1241    fn blob_upload_response_serializes_without_sha256_when_none() {
1242        let resp = BlobUploadResponse {
1243            account_id: Id::from("a1"),
1244            blob_id: Id::from("b1"),
1245            content_type: "text/plain".to_owned(),
1246            size: 0,
1247            sha256: None,
1248            extra: serde_json::Map::new(),
1249        };
1250        let v = serde_json::to_value(&resp).expect("must serialize");
1251        let obj = v.as_object().expect("object");
1252        assert!(
1253            !obj.contains_key("sha256"),
1254            "None must elide the sha256 key: {v:?}"
1255        );
1256    }
1257
1258    /// Round-trip preservation: a server that omits the sha256
1259    /// field deserializes into `None`, and serializing back
1260    /// produces an output without a `sha256` key. This is the
1261    /// shape RFC 8620 §6.1 compliant servers (without the CID
1262    /// extension) produce; verify we don't accidentally inject a
1263    /// null or empty sha256 on round-trip.
1264    #[test]
1265    fn blob_upload_response_no_sha256_round_trip() {
1266        let raw = serde_json::json!({
1267            "accountId": "account1",
1268            "blobId": "blob1",
1269            "type": "text/plain",
1270            "size": 3
1271        });
1272        let obj: BlobUploadResponse = serde_json::from_value(raw).expect("must deserialize");
1273        assert!(obj.sha256.is_none());
1274
1275        let re = serde_json::to_value(&obj).expect("must serialize");
1276        let map = re.as_object().expect("object");
1277        assert!(
1278            !map.contains_key("sha256"),
1279            "round-trip must not introduce sha256 key: {re:?}"
1280        );
1281    }
1282
1283    /// A non-conformant server sending uppercase hex now fails to
1284    /// deserialize (typed `Sha256` is strict lowercase-only per
1285    /// draft-atwood-jmap-cid-00 §2). Pinning the strict behavior
1286    /// per bd:JMAP-v9py.13's design; the inter-op question is
1287    /// tracked separately at bd:JMAP-noz7.
1288    #[test]
1289    fn blob_upload_response_rejects_uppercase_sha256() {
1290        let raw = serde_json::json!({
1291            "accountId": "account1",
1292            "blobId": "blob1",
1293            "type": "text/plain",
1294            "size": 3,
1295            "sha256": "BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD"
1296        });
1297        let err = serde_json::from_value::<BlobUploadResponse>(raw)
1298            .expect_err("uppercase hex must fail to deserialize");
1299        let msg = err.to_string();
1300        assert!(
1301            msg.contains("non-lowercase-hex") || msg.contains("at") || msg.contains("hex"),
1302            "error must explain the hex constraint: {msg}"
1303        );
1304    }
1305
1306    // bd:JMAP-6r7c.61 — behavioral round-trip for the constant-time hex
1307    // digest comparison helper. We cannot assert side-channel resistance
1308    // from a unit test (only the compiler/CPU pipeline can); these tests
1309    // assert the semantic equality contract still holds after swapping
1310    // `String ==` for `subtle::ConstantTimeEq::ct_eq`. Oracle: NIST
1311    // FIPS 180-4 Appendix A example 1 (SHA-256 of "abc") and the
1312    // widely-published SHA-256 of the empty string (RFC 6234
1313    // implementations, NIST CAVS test vectors) — both hand-typed from
1314    // external sources, not computed by this crate.
1315    #[test]
1316    fn hex_digest_eq_matches_identical_canonical_digests() {
1317        // NIST FIPS 180-4 Appendix A example 1: SHA-256("abc").
1318        let abc_sha256_nist = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
1319        assert!(hex_digest_eq(abc_sha256_nist, abc_sha256_nist));
1320    }
1321
1322    #[test]
1323    fn hex_digest_eq_rejects_one_byte_mismatch() {
1324        let abc_sha256_nist = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
1325        // Flip the final nibble (d -> c). Distance: 1 hex char, well
1326        // inside what a byte-by-byte == would have caught.
1327        let one_byte_off = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ac";
1328        assert!(!hex_digest_eq(abc_sha256_nist, one_byte_off));
1329    }
1330
1331    #[test]
1332    fn hex_digest_eq_rejects_complete_mismatch() {
1333        // NIST FIPS 180-4 Appendix A example 1: SHA-256("abc").
1334        let abc_sha256_nist = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
1335        // SHA-256 of the empty string (widely-published canonical value;
1336        // RFC 6234, NIST CAVS, etc.).
1337        let empty_sha256_canonical =
1338            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
1339        assert!(!hex_digest_eq(abc_sha256_nist, empty_sha256_canonical));
1340    }
1341}