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}