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::{HeaderValue, CONTENT_LENGTH, CONTENT_TYPE};
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14use subtle::ConstantTimeEq;
15
16use crate::client::{read_capped_body, JmapClient};
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: "A13824",
27///     blob_id: "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: "",
46///     # blob_id: "",
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 str,
60    /// Server-assigned blob identifier.
61    pub blob_id: &'a str,
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: "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 str,
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: "A13824",
114///     blob_id: "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 str,
124    /// Server-assigned blob identifier.
125    pub blob_id: &'a str,
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: "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 str,
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: "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 str,
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: &str,
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(upload_url_template.as_str(), &[("accountId", account_id)])?;
719        Ok(self.inject_auth(
720            self.http
721                .post(&url)
722                .header(CONTENT_TYPE, ct_hv)
723                .timeout(self.config.request_timeout),
724        ))
725    }
726
727    /// Send an upload request and parse the server's `BlobUploadResponse`.
728    async fn send_upload(
729        &self,
730        req: reqwest::RequestBuilder,
731    ) -> Result<BlobUploadResponse, ClientError> {
732        let resp = req.send().await.map_err(ClientError::from_reqwest)?;
733        let status = resp.status();
734        Self::check_auth_status(status)?;
735        Self::check_rate_limit(&resp)?;
736        let resp = resp.error_for_status().map_err(ClientError::from_reqwest)?;
737
738        let upload_limit = self.config.max_upload_response_body;
739
740        // Stream the body chunk-by-chunk with the cap enforced before each
741        // accumulation (bd:JMAP-6r7c.1). See `read_capped_body` for the DOS
742        // rationale; without per-chunk streaming, a server that under-reports
743        // or omits Content-Length can force unbounded allocation here.
744        let bytes = read_capped_body(resp, upload_limit).await?;
745        serde_json::from_slice(&bytes).map_err(ClientError::from_parse)
746    }
747
748    /// Download a blob by ID (RFC 8620 §6.2).
749    ///
750    /// Template variables `{accountId}`, `{blobId}`, `{name}`, and `{type}` are
751    /// substituted from the corresponding fields of `params` before the GET
752    /// request. `{type}` expands to an empty string when `params.accept_type`
753    /// is `None`; templates that include `?accept={type}` produce `?accept=`.
754    /// If the server does not tolerate an empty `?accept=` parameter, omit
755    /// `{type}` from the `download_url` template in the Session document.
756    ///
757    /// If `params.expected_sha256` is `Some`, the downloaded bytes are verified
758    /// against the typed [`jmap_cid_types::Sha256`] digest and
759    /// `ClientError::BlobIntegrityMismatch` is returned on mismatch.
760    pub async fn download_blob(
761        &self,
762        params: DownloadBlobParams<'_>,
763    ) -> Result<bytes::Bytes, ClientError> {
764        let expected_sha256 = params.expected_sha256;
765        let resp = self.download_response(params).await?;
766
767        let download_limit = self.config.max_download_body;
768
769        // Stream the body chunk-by-chunk with the cap enforced before each
770        // accumulation (bd:JMAP-6r7c.1). See `read_capped_body` for the DOS
771        // rationale; without per-chunk streaming, a server that under-reports
772        // or omits Content-Length can force unbounded allocation here.
773        let bytes = bytes::Bytes::from(read_capped_body(resp, download_limit).await?);
774
775        if let Some(expected) = expected_sha256 {
776            // The typed `jmap_cid_types::Sha256` enforces the canonical
777            // 64-char lowercase-hex ABNF at construction (bd:JMAP-6r7c.48),
778            // so the runtime length/charset check and the
779            // `to_ascii_lowercase` normalize step the previous
780            // `Option<&str>` shape required (bd:JMAP-6r7c.53) are gone.
781            verify_download_digest(expected, compute_sha256_hex(&bytes))?;
782        }
783
784        Ok(bytes)
785    }
786
787    /// Download a blob by ID (RFC 8620 §6.2) as a stream of chunks instead
788    /// of one buffer.
789    ///
790    /// The streaming counterpart of [`Self::download_blob`], with the same
791    /// URL expansion and the same
792    /// [`ClientConfig::max_download_body`](crate::ClientConfig::max_download_body)
793    /// cap. An advertised `Content-Length` over the cap fails here with
794    /// [`ClientError::ResponseTooLarge`]; a body that grows past the cap
795    /// while streaming yields that error from the stream. When
796    /// `params.expected_sha256` is `Some`, the digest is checked at end of
797    /// stream — see [`BlobDownloadStream`] for what that means for chunks
798    /// already yielded.
799    ///
800    /// [`ClientConfig::request_timeout`](crate::ClientConfig::request_timeout)
801    /// bounds the whole download, including reading the stream. Large
802    /// downloads over slow links need a client configured with a longer
803    /// timeout (and, usually, a larger `max_download_body`).
804    pub async fn download_blob_stream(
805        &self,
806        params: DownloadBlobParams<'_>,
807    ) -> Result<BlobDownloadStream, ClientError> {
808        let expected_sha256 = params.expected_sha256.cloned();
809        let resp = self.download_response(params).await?;
810
811        let limit = self.config.max_download_body;
812        let content_length = resp.content_length();
813        if let Some(len) = content_length {
814            if len > limit {
815                return Err(ClientError::ResponseTooLarge { actual: len, limit });
816            }
817        }
818
819        let state = DownloadState {
820            body: Box::pin(resp.bytes_stream()),
821            verify: expected_sha256.map(|expected| (Sha256::new(), expected)),
822            received: 0,
823            limit,
824            done: false,
825        };
826        Ok(BlobDownloadStream {
827            content_length,
828            inner: Box::pin(futures::stream::unfold(state, DownloadState::next).fuse()),
829        })
830    }
831
832    /// Send the authenticated `GET` for a blob and return the response once
833    /// its status has been checked, with the body still unread.
834    async fn download_response(
835        &self,
836        params: DownloadBlobParams<'_>,
837    ) -> Result<reqwest::Response, ClientError> {
838        let DownloadBlobParams {
839            download_url_template,
840            account_id,
841            blob_id,
842            name,
843            accept_type,
844            expected_sha256: _,
845        } = params;
846        crate::client::require_http_url(download_url_template.as_str())?;
847        let vars = [
848            ("accountId", account_id),
849            ("blobId", blob_id),
850            ("name", name),
851            // Always supply {type} — even as empty string — so templates
852            // containing `?accept={type}` expand cleanly rather than triggering
853            // the unexpanded-placeholder error.
854            ("type", accept_type.unwrap_or("")),
855        ];
856        let url = expand_url_template(download_url_template.as_str(), &vars)?;
857
858        let req = self.inject_auth(self.http.get(&url).timeout(self.config.request_timeout));
859
860        let resp = req.send().await.map_err(ClientError::from_reqwest)?;
861        let status = resp.status();
862        Self::check_auth_status(status)?;
863        Self::check_rate_limit(&resp)?;
864        resp.error_for_status().map_err(ClientError::from_reqwest)
865    }
866
867    /// Upload raw bytes via a [`crate::Session`]-supplied URL
868    /// template (bd:JMAP-6r7c.64).
869    ///
870    /// Type-safe convenience wrapper over [`Self::upload_blob`] —
871    /// supplies `session.upload_url` for `upload_url_template`
872    /// internally. The caller cannot accidentally pass `session.api_url`
873    /// or any other URL field because the parameter set
874    /// ([`UploadBlobSessionParams`]) does not include a URL field.
875    pub async fn upload_blob_session(
876        &self,
877        session: &crate::request::Session,
878        params: UploadBlobSessionParams<'_>,
879    ) -> Result<BlobUploadResponse, ClientError> {
880        let UploadBlobSessionParams {
881            account_id,
882            content_type,
883            data,
884        } = params;
885        self.upload_blob(UploadBlobParams {
886            upload_url_template: &session.upload_url,
887            account_id,
888            content_type,
889            data,
890        })
891        .await
892    }
893
894    /// Download a blob via a [`crate::Session`]-supplied URL
895    /// template (bd:JMAP-6r7c.64).
896    ///
897    /// Type-safe convenience wrapper over [`Self::download_blob`] —
898    /// supplies `session.download_url` for `download_url_template`
899    /// internally. See [`Self::upload_blob_session`] for the
900    /// rationale.
901    pub async fn download_blob_session(
902        &self,
903        session: &crate::request::Session,
904        params: DownloadBlobSessionParams<'_>,
905    ) -> Result<bytes::Bytes, ClientError> {
906        let DownloadBlobSessionParams {
907            account_id,
908            blob_id,
909            name,
910            accept_type,
911            expected_sha256,
912        } = params;
913        self.download_blob(DownloadBlobParams {
914            download_url_template: &session.download_url,
915            account_id,
916            blob_id,
917            name,
918            accept_type,
919            expected_sha256,
920        })
921        .await
922    }
923}
924
925/// Compute SHA-256 of `data` and return as 64-char lowercase hex string.
926fn compute_sha256_hex(data: &[u8]) -> String {
927    hex_lower(&Sha256::digest(data))
928}
929
930/// Format a digest as lowercase hex (JMAP-CID §1 requires lowercase).
931fn hex_lower(digest: &[u8]) -> String {
932    let mut s = String::with_capacity(digest.len() * 2);
933    for b in digest {
934        s.push(hex_nibble_lower(*b >> 4));
935        s.push(hex_nibble_lower(*b & 0x0f));
936    }
937    s
938}
939
940/// Cross-check a `BlobUploadResponse` against the length and SHA-256 hex
941/// digest of the bytes the client sent.
942fn verify_upload_response(
943    upload_resp: &BlobUploadResponse,
944    local_size: u64,
945    local_sha256: String,
946) -> Result<(), ClientError> {
947    // Defense-in-depth: cross-check the server's reported size against the
948    // bytes we actually uploaded (bd:JMAP-6lsm.8). When sha256 is present
949    // (below) it makes a size mismatch implausible because length is
950    // implicit in the digest, but most servers do not advertise the
951    // JMAP-CID sha256 capability — in that case `size` is the only
952    // signal that the upload was complete and intact. A mismatch
953    // surfaces as UnexpectedResponse rather than a typed variant; a
954    // future minor release may add ClientError::BlobSizeMismatch for
955    // structured matching, but keeping the variant set stable in 0.1.x
956    // is the conservative choice.
957    if upload_resp.size != local_size {
958        return Err(ClientError::UnexpectedResponse(format!(
959            "blob upload size mismatch: client uploaded {local_size} bytes, server reports \
960             {server_size} bytes",
961            server_size = upload_resp.size,
962        )));
963    }
964
965    if let Some(ref server_sha256) = upload_resp.sha256 {
966        // The typed `jmap_cid_types::Sha256` deserialize already
967        // enforces the 64-character lowercase-hex ABNF; reaching
968        // this branch implies the wire value is canonical. We
969        // can compare the locally-computed digest (also
970        // canonical lowercase hex from `hex_lower`)
971        // against the typed value's string form directly.
972        //
973        // Uppercase-hex from a non-conformant server is rejected
974        // at deserialize per draft-atwood-jmap-cid-00 §2 ABNF and
975        // surfaces as ClientError::Parse before this branch runs.
976        //
977        // Constant-time compare via `hex_digest_eq` (bd:JMAP-6r7c.61):
978        // both sides are canonical 64-char lowercase hex by construction.
979        if !hex_digest_eq(&local_sha256, server_sha256.as_str()) {
980            return Err(ClientError::BlobIntegrityMismatch {
981                expected: local_sha256,
982                actual: server_sha256.as_str().to_owned(),
983            });
984        }
985    }
986    Ok(())
987}
988
989/// Compare the caller's expected digest with the SHA-256 hex digest of the
990/// bytes actually downloaded.
991fn verify_download_digest(
992    expected: &jmap_cid_types::Sha256,
993    actual: String,
994) -> Result<(), ClientError> {
995    // Constant-time compare via `hex_digest_eq` (bd:JMAP-6r7c.61):
996    // both sides are canonical 64-char lowercase hex by construction.
997    if hex_digest_eq(&actual, expected.as_str()) {
998        Ok(())
999    } else {
1000        Err(ClientError::BlobIntegrityMismatch {
1001            expected: expected.as_str().to_owned(),
1002            actual,
1003        })
1004    }
1005}
1006
1007/// Constant-time equality for two 64-character lowercase SHA-256 hex digests
1008/// (bd:JMAP-6r7c.61).
1009///
1010/// Both upload-side and download-side integrity checks compare hex digests
1011/// that are guaranteed-canonical by construction before reaching this
1012/// helper: `compute_sha256_hex` always emits 64 lowercase nibbles, and
1013/// `jmap_cid_types::Sha256` enforces the 64-char lowercase-hex ABNF on
1014/// every construction path (deserialize, `from_hex`, etc.). Length-
1015/// discrimination is therefore not an information leak at these call sites.
1016///
1017/// Using `subtle::ConstantTimeEq::ct_eq` over `==` is discipline-propagation,
1018/// not a defense against a concrete JMAP threat: SHA-256 of blob bytes is
1019/// not a secret (the attacker can fetch the same blob), so a timing oracle
1020/// on the digest comparison reveals nothing exploitable in the upload-side
1021/// case. The download-side `expected_sha256` argument is caller-supplied
1022/// and *could* in principle come from a private channel (signed manifest,
1023/// end-to-end attestation); the constant-time compare closes that residual
1024/// channel for callers who want it.
1025///
1026/// Matches the workspace's RustCrypto-first stance and the precedent set by
1027/// `crate-jmap-chat-server` (invite-code lookup) and `crate-jmap-testjig`
1028/// (bearer-token check).
1029fn hex_digest_eq(a: &str, b: &str) -> bool {
1030    a.as_bytes().ct_eq(b.as_bytes()).into()
1031}
1032
1033#[cfg(test)]
1034mod tests {
1035    use super::*;
1036
1037    // Oracle: RFC 6570 §1.2 — simple substitution with unreserved-only value
1038    #[test]
1039    fn expand_upload_url() {
1040        let result = expand_url_template(
1041            "https://example.com/upload/{accountId}/",
1042            &[("accountId", "account1")],
1043        )
1044        .expect("must succeed");
1045        assert_eq!(result, "https://example.com/upload/account1/");
1046    }
1047
1048    // Oracle: RFC 3986 §2.1 — space 0x20 encodes as %20
1049    #[test]
1050    fn expand_download_url_with_spaces() {
1051        let result = expand_url_template(
1052            "/download/{accountId}/{blobId}/{name}",
1053            &[
1054                ("accountId", "acc1"),
1055                ("blobId", "blob-123"),
1056                ("name", "my file.png"),
1057            ],
1058        )
1059        .expect("must succeed");
1060        assert_eq!(result, "/download/acc1/blob-123/my%20file.png");
1061    }
1062
1063    // Oracle: RFC 3986 §2.1 — slash 0x2F encodes as %2F
1064    #[test]
1065    fn expand_with_slash_in_type() {
1066        let result = expand_url_template(
1067            "/dl/{accountId}/{blobId}/{name}?accept={type}",
1068            &[
1069                ("accountId", "a"),
1070                ("blobId", "b"),
1071                ("name", "x.jpg"),
1072                ("type", "image/png"),
1073            ],
1074        )
1075        .expect("must succeed");
1076        assert_eq!(result, "/dl/a/b/x.jpg?accept=image%2Fpng");
1077    }
1078
1079    // Oracle: expand_url_template must error when a template variable has no
1080    // entry in vars — the template comes from the server's Session document,
1081    // so an unrecognized variable name indicates a server-side bug.
1082    #[test]
1083    fn expand_unknown_variable_returns_error() {
1084        let err = expand_url_template(
1085            "https://example.com/upload/{accountId}/{unknownVar}/",
1086            &[("accountId", "acc1")],
1087        )
1088        .expect_err("must fail when template variable is not supplied");
1089        assert!(
1090            matches!(err, crate::error::ClientError::InvalidSession(_)),
1091            "expected InvalidSession, got {err:?}"
1092        );
1093    }
1094
1095    // Oracle: expand_url_template must error on a template with an unmatched
1096    // '{' — no corresponding '}' exists. The expected error is InvalidSession
1097    // because templates come from the server's Session document.
1098    #[test]
1099    fn expand_unmatched_open_brace_returns_error() {
1100        let err = expand_url_template("https://example.com/{unclosed", &[])
1101            .expect_err("unmatched '{' must return an error");
1102        assert!(
1103            matches!(err, crate::error::ClientError::InvalidSession(_)),
1104            "expected InvalidSession for unmatched brace, got {err:?}"
1105        );
1106    }
1107
1108    // Oracle: vars entries whose name does not appear in the template are
1109    // silently ignored — extra vars are benign (caller may pass a superset).
1110    #[test]
1111    fn expand_unused_var_is_ignored() {
1112        let result = expand_url_template(
1113            "https://example.com/upload/{accountId}/",
1114            &[("accountId", "acc1"), ("extraVar", "value")],
1115        )
1116        .expect("extra vars must be silently ignored");
1117        assert_eq!(result, "https://example.com/upload/acc1/");
1118    }
1119
1120    // Oracle: RFC 3986 §2.3 — for inputs containing only unreserved
1121    // characters (alphanumeric / `-` / `.` / `_` / `~`), percent_encode
1122    // MUST return Cow::Borrowed without allocating. Verified by the
1123    // matches! check against the Cow::Borrowed variant.
1124    #[test]
1125    fn percent_encode_unreserved_input_borrows() {
1126        let input = "abc.DEF_123-xyz~tilde";
1127        let result = percent_encode(input);
1128        assert!(matches!(result, Cow::Borrowed(_)));
1129        assert_eq!(result.as_ref(), input);
1130    }
1131
1132    // Oracle: RFC 3986 §2.1 — inputs that contain any byte outside the
1133    // unreserved set MUST be returned as Cow::Owned with each non-unreserved
1134    // byte percent-escaped using uppercase hex (§2.1 mandates uppercase).
1135    #[test]
1136    fn percent_encode_with_space_is_owned_and_uppercase() {
1137        let input = "hello world";
1138        let result = percent_encode(input);
1139        assert!(matches!(result, Cow::Owned(_)));
1140        assert_eq!(result.as_ref(), "hello%20world");
1141    }
1142
1143    // Oracle: RFC 3986 §2.1 — slash 0x2F encodes as %2F (uppercase hex).
1144    // Mixed-content input MUST preserve the unreserved prefix verbatim
1145    // and escape only the disallowed bytes.
1146    #[test]
1147    fn percent_encode_mixed_input_preserves_prefix() {
1148        let input = "image/png";
1149        let result = percent_encode(input);
1150        assert!(matches!(result, Cow::Owned(_)));
1151        assert_eq!(result.as_ref(), "image%2Fpng");
1152    }
1153
1154    // Oracle: degenerate empty input. An empty string has no bytes requiring
1155    // escape, so percent_encode MUST borrow it without allocation.
1156    #[test]
1157    fn percent_encode_empty_input_borrows() {
1158        let result = percent_encode("");
1159        assert!(matches!(result, Cow::Borrowed(_)));
1160        assert_eq!(result.as_ref(), "");
1161    }
1162
1163    // Oracle: tests/fixtures/blob/upload_response.json — hand-written fixture
1164    // derived from RFC 8620 §6.1 blob upload response shape; not produced by
1165    // the code under test.
1166    #[test]
1167    fn blob_upload_response_deserializes() {
1168        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
1169            .join("tests/fixtures/blob/upload_response.json");
1170        let text =
1171            std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("cannot read fixture: {e}"));
1172        let resp: BlobUploadResponse =
1173            serde_json::from_str(&text).expect("fixture must deserialize as BlobUploadResponse");
1174
1175        assert_eq!(resp.account_id, "account1");
1176        assert_eq!(resp.blob_id, "Gbc4c377-c8c3-4b48-b2bb-8c1e4cfb8b2a");
1177        assert_eq!(resp.content_type, "image/png");
1178        assert_eq!(resp.size, 48291);
1179        // The typed Sha256 wrapper carries the canonical lowercase
1180        // hex string; compare via `as_str` to keep the assertion
1181        // independent of the wrapper's Debug shape.
1182        assert_eq!(
1183            resp.sha256.as_ref().map(jmap_cid_types::Sha256::as_str),
1184            Some("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")
1185        );
1186    }
1187
1188    /// `BlobUploadResponse.extra` captures unknown fields on deserialize.
1189    #[test]
1190    fn blob_upload_response_preserves_vendor_extras() {
1191        let raw = serde_json::json!({
1192            "accountId": "account1",
1193            "blobId": "Gbc4c377-c8c3-4b48-b2bb-8c1e4cfb8b2a",
1194            "type": "image/png",
1195            "size": 48291,
1196            "acmeCorpScanResult": "clean"
1197        });
1198        let obj: BlobUploadResponse =
1199            serde_json::from_value(raw).expect("BlobUploadResponse must deserialize");
1200        assert_eq!(
1201            obj.extra.get("acmeCorpScanResult").and_then(|v| v.as_str()),
1202            Some("clean")
1203        );
1204    }
1205
1206    // bd:JMAP-v9py.13 oracle.
1207    //
1208    // The fixture hex digest is hand-typed from RFC 6234 §8.5 ("abc"
1209    // test vector, formatted lowercase). It is NOT derived from the
1210    // code under test, satisfying the workspace test-integrity rule
1211    // that test oracles must be independent.
1212
1213    /// Deserialize a Blob/upload response carrying a canonical
1214    /// 64-char lowercase-hex sha256 → field parses as
1215    /// Some(Sha256(_)) preserving the wire string.
1216    #[test]
1217    fn blob_upload_response_deserializes_sha256_typed() {
1218        let raw = serde_json::json!({
1219            "accountId": "account1",
1220            "blobId": "blob1",
1221            "type": "text/plain",
1222            "size": 3,
1223            "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
1224        });
1225        let obj: BlobUploadResponse = serde_json::from_value(raw).expect("must deserialize");
1226        let s = obj.sha256.expect("sha256 must be Some");
1227        assert_eq!(
1228            s.as_str(),
1229            "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
1230        );
1231    }
1232
1233    /// Serialize a BlobUploadResponse with `sha256: None` → the
1234    /// `sha256` key must be ABSENT from the wire output per
1235    /// `skip_serializing_if = "Option::is_none"`. This is the
1236    /// contract for servers not advertising the CID capability.
1237    #[test]
1238    fn blob_upload_response_serializes_without_sha256_when_none() {
1239        let resp = BlobUploadResponse {
1240            account_id: Id::from("a1"),
1241            blob_id: Id::from("b1"),
1242            content_type: "text/plain".to_owned(),
1243            size: 0,
1244            sha256: None,
1245            extra: serde_json::Map::new(),
1246        };
1247        let v = serde_json::to_value(&resp).expect("must serialize");
1248        let obj = v.as_object().expect("object");
1249        assert!(
1250            !obj.contains_key("sha256"),
1251            "None must elide the sha256 key: {v:?}"
1252        );
1253    }
1254
1255    /// Round-trip preservation: a server that omits the sha256
1256    /// field deserializes into `None`, and serializing back
1257    /// produces an output without a `sha256` key. This is the
1258    /// shape RFC 8620 §6.1 compliant servers (without the CID
1259    /// extension) produce; verify we don't accidentally inject a
1260    /// null or empty sha256 on round-trip.
1261    #[test]
1262    fn blob_upload_response_no_sha256_round_trip() {
1263        let raw = serde_json::json!({
1264            "accountId": "account1",
1265            "blobId": "blob1",
1266            "type": "text/plain",
1267            "size": 3
1268        });
1269        let obj: BlobUploadResponse = serde_json::from_value(raw).expect("must deserialize");
1270        assert!(obj.sha256.is_none());
1271
1272        let re = serde_json::to_value(&obj).expect("must serialize");
1273        let map = re.as_object().expect("object");
1274        assert!(
1275            !map.contains_key("sha256"),
1276            "round-trip must not introduce sha256 key: {re:?}"
1277        );
1278    }
1279
1280    /// A non-conformant server sending uppercase hex now fails to
1281    /// deserialize (typed `Sha256` is strict lowercase-only per
1282    /// draft-atwood-jmap-cid-00 §2). Pinning the strict behavior
1283    /// per bd:JMAP-v9py.13's design; the inter-op question is
1284    /// tracked separately at bd:JMAP-noz7.
1285    #[test]
1286    fn blob_upload_response_rejects_uppercase_sha256() {
1287        let raw = serde_json::json!({
1288            "accountId": "account1",
1289            "blobId": "blob1",
1290            "type": "text/plain",
1291            "size": 3,
1292            "sha256": "BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD"
1293        });
1294        let err = serde_json::from_value::<BlobUploadResponse>(raw)
1295            .expect_err("uppercase hex must fail to deserialize");
1296        let msg = err.to_string();
1297        assert!(
1298            msg.contains("non-lowercase-hex") || msg.contains("at") || msg.contains("hex"),
1299            "error must explain the hex constraint: {msg}"
1300        );
1301    }
1302
1303    // bd:JMAP-6r7c.61 — behavioral round-trip for the constant-time hex
1304    // digest comparison helper. We cannot assert side-channel resistance
1305    // from a unit test (only the compiler/CPU pipeline can); these tests
1306    // assert the semantic equality contract still holds after swapping
1307    // `String ==` for `subtle::ConstantTimeEq::ct_eq`. Oracle: NIST
1308    // FIPS 180-4 Appendix A example 1 (SHA-256 of "abc") and the
1309    // widely-published SHA-256 of the empty string (RFC 6234
1310    // implementations, NIST CAVS test vectors) — both hand-typed from
1311    // external sources, not computed by this crate.
1312    #[test]
1313    fn hex_digest_eq_matches_identical_canonical_digests() {
1314        // NIST FIPS 180-4 Appendix A example 1: SHA-256("abc").
1315        let abc_sha256_nist = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
1316        assert!(hex_digest_eq(abc_sha256_nist, abc_sha256_nist));
1317    }
1318
1319    #[test]
1320    fn hex_digest_eq_rejects_one_byte_mismatch() {
1321        let abc_sha256_nist = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
1322        // Flip the final nibble (d -> c). Distance: 1 hex char, well
1323        // inside what a byte-by-byte == would have caught.
1324        let one_byte_off = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ac";
1325        assert!(!hex_digest_eq(abc_sha256_nist, one_byte_off));
1326    }
1327
1328    #[test]
1329    fn hex_digest_eq_rejects_complete_mismatch() {
1330        // NIST FIPS 180-4 Appendix A example 1: SHA-256("abc").
1331        let abc_sha256_nist = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad";
1332        // SHA-256 of the empty string (widely-published canonical value;
1333        // RFC 6234, NIST CAVS, etc.).
1334        let empty_sha256_canonical =
1335            "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
1336        assert!(!hex_digest_eq(abc_sha256_nist, empty_sha256_canonical));
1337    }
1338}