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}