jmap_base_client/blob.rs
1//! Blob upload/download operations and supporting types (RFC 8620 §6.1, §6.2)
2
3use std::borrow::Cow;
4
5use futures::StreamExt as _;
6use jmap_types::Id;
7use reqwest::header::{HeaderValue, CONTENT_TYPE};
8use serde::{Deserialize, Serialize};
9use sha2::{Digest, Sha256};
10
11use crate::client::JmapClient;
12use crate::error::ClientError;
13
14/// Parameters for [`JmapClient::download_blob`].
15///
16/// Use a struct literal to avoid confusion between the six string-typed fields:
17///
18/// ```rust,ignore
19/// client.download_blob(DownloadBlobParams {
20/// download_url_template: &session.download_url,
21/// account_id: "A13824",
22/// blob_id: "Gbc4c...",
23/// name: "attachment.pdf",
24/// accept_type: Some("application/pdf"),
25/// expected_sha256: None,
26/// }).await?;
27/// ```
28#[derive(Debug, Clone, Copy)]
29pub struct DownloadBlobParams<'a> {
30 /// URL template from `Session.download_url`.
31 pub download_url_template: &'a str,
32 /// Account ID that owns the blob.
33 pub account_id: &'a str,
34 /// Server-assigned blob identifier.
35 pub blob_id: &'a str,
36 /// Human-readable filename for the `{name}` template variable.
37 pub name: &'a str,
38 /// Optional accept type for the `{type}` template variable (e.g. `"image/png"`).
39 /// Pass `None` when no content-type preference is needed; `{type}` expands to an
40 /// empty string.
41 pub accept_type: Option<&'a str>,
42 /// Optional expected SHA-256 hex digest for integrity verification.
43 /// Pass `None` to skip the check.
44 pub expected_sha256: Option<&'a str>,
45}
46
47/// Response body returned by a successful blob upload (RFC 8620 §6.1).
48#[non_exhaustive]
49#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
50#[serde(rename_all = "camelCase")]
51pub struct BlobUploadResponse {
52 /// The account the blob was uploaded to.
53 pub account_id: Id,
54 /// Server-assigned opaque blob identifier.
55 pub blob_id: Id,
56 /// Media type of the uploaded blob as determined by the server.
57 #[serde(rename = "type")]
58 pub content_type: String,
59 /// Size of the uploaded blob in bytes.
60 pub size: u64,
61 /// SHA-256 digest of the uploaded blob, present only when the
62 /// server advertises the `urn:ietf:params:jmap:cid` capability
63 /// (draft-atwood-jmap-cid-00 §3).
64 ///
65 /// The wire format is a 64-character lowercase-hex string per
66 /// the draft's ABNF (`%x30-39 / %x61-66`). The typed
67 /// [`jmap_cid_types::Sha256`] enforces that shape on
68 /// deserialize: a server response carrying a sha256 field that
69 /// is not exactly 64 bytes of lowercase hex will fail to parse
70 /// and surface as [`ClientError::Parse`]. Servers that do not
71 /// implement the CID extension omit the field; the typed
72 /// representation here is `None`.
73 ///
74 /// History: bd:JMAP-v9py.13 promoted this field from a permissive
75 /// `Option<String>` to the typed `Option<jmap_cid_types::Sha256>`.
76 /// The previous implementation tolerated uppercase hex via a
77 /// permissive `is_valid_sha256_hex` validator that accepted
78 /// ASCII hex of either case plus a normalize-to-lowercase
79 /// step before integrity comparison. The typed path is strict;
80 /// the inter-op question (whether to recover the uppercase
81 /// tolerance via a custom Deserialize wrapper) is tracked by
82 /// bd:JMAP-noz7.
83 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub sha256: Option<jmap_cid_types::Sha256>,
85
86 /// Catch-all for vendor / site / private extension fields not covered
87 /// by the typed fields above. Preserves unknown fields across
88 /// deserialize/serialize round-trip per workspace extras-preservation
89 /// policy (see workspace AGENTS.md).
90 #[serde(flatten, default, skip_serializing_if = "serde_json::Map::is_empty")]
91 pub extra: serde_json::Map<String, serde_json::Value>,
92}
93
94/// Expand a RFC 6570 Level-1 URI template by substituting variables.
95///
96/// For each `(name, value)` pair in `vars`, replaces `{name}` in `template`
97/// with the percent-encoded form of `value`. Encoding follows RFC 3986
98/// unreserved characters (ALPHA / DIGIT / `-` / `.` / `_` / `~`), which pass
99/// through unchanged; all other bytes are encoded as `%XX` with uppercase hex
100/// (RFC 3986 §2.1 requires uppercase).
101///
102/// Entries in `vars` whose name does not appear in the template are silently
103/// ignored. A variable that appears in the template but has no entry in `vars`
104/// is an error (`ClientError::InvalidSession`) because templates come from the
105/// server's Session document — an unexpected variable indicates a server bug.
106///
107/// # RFC 6570 Level-1
108/// Only simple-string expansion is supported. Reserved-expansion (`{+var}`)
109/// and other Level-2+ operators are not supported.
110///
111/// # Usage with `subscribe_events`
112///
113/// [`Session::event_source_url`] is a URI template with variables `types`,
114/// `closeafter`, and `ping`. Expand it before calling
115/// [`JmapClient::subscribe_events`]:
116///
117/// ```rust,ignore
118/// let url = expand_url_template(
119/// &session.event_source_url,
120/// &[
121/// ("types", "Email,Mailbox"),
122/// ("closeafter", "state"),
123/// ("ping", "0"),
124/// ],
125/// )?;
126/// client.subscribe_events(&url, None).await?;
127/// ```
128///
129/// [`Session::event_source_url`]: crate::request::Session::event_source_url
130/// [`JmapClient::subscribe_events`]: crate::client::JmapClient::subscribe_events
131pub fn expand_url_template(template: &str, vars: &[(&str, &str)]) -> Result<String, ClientError> {
132 let mut result = String::with_capacity(template.len() + 64);
133 let mut rest = template;
134 while let Some(open) = rest.find('{') {
135 result.push_str(&rest[..open]);
136 rest = &rest[open + 1..];
137 let close = rest.find('}').ok_or_else(|| {
138 ClientError::InvalidSession("URL template has unmatched '{'".to_owned())
139 })?;
140 let name = &rest[..close];
141 rest = &rest[close + 1..];
142 if let Some((_, value)) = vars.iter().find(|(n, _)| *n == name) {
143 result.push_str(&percent_encode(value));
144 } else {
145 return Err(ClientError::InvalidSession(format!(
146 "URL template variable not supplied: {{{name}}}"
147 )));
148 }
149 }
150 result.push_str(rest);
151 Ok(result)
152}
153
154/// Percent-encode a string value per RFC 3986 §2.3 unreserved character set.
155///
156/// The return type is [`Cow<'_, str>`](Cow) so the common JMAP case — inputs
157/// that contain only unreserved characters (alphanumeric / `-` / `.` / `_` /
158/// `~`) such as account ids and blob ids — borrows the input slice and
159/// performs no allocation. Inputs that contain any byte requiring percent-
160/// escape allocate a fresh string with the encoded form.
161fn percent_encode(value: &str) -> Cow<'_, str> {
162 // Fast path: scan for the first byte that needs escaping. If none, no
163 // allocation is required.
164 let first_escape = value.bytes().position(|b| !is_unreserved(b));
165 let Some(first) = first_escape else {
166 return Cow::Borrowed(value);
167 };
168
169 // Allocate only when at least one byte needs escaping. Reserve at least
170 // enough capacity for the unchanged prefix plus the three-byte encoding
171 // of the first escaped byte; further escapes will grow as needed.
172 let mut out = String::with_capacity(value.len() + 2);
173 out.push_str(&value[..first]);
174 for byte in value.as_bytes()[first..].iter().copied() {
175 if is_unreserved(byte) {
176 out.push(char::from(byte));
177 } else {
178 out.push('%');
179 out.push(hex_nibble_upper(byte >> 4));
180 out.push(hex_nibble_upper(byte & 0x0f));
181 }
182 }
183 Cow::Owned(out)
184}
185
186/// Returns `true` if `byte` is in the RFC 3986 §2.3 unreserved set.
187fn is_unreserved(byte: u8) -> bool {
188 byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'.' || byte == b'_' || byte == b'~'
189}
190
191/// Returns the uppercase hex character for `nibble` (0–15).
192/// Used for percent-encoding (RFC 3986 §2.1 requires uppercase).
193fn hex_nibble_upper(nibble: u8) -> char {
194 match nibble {
195 0..=9 => char::from(b'0' + nibble),
196 10..=15 => char::from(b'A' + nibble - 10),
197 _ => unreachable!("nibble must be 0–15"),
198 }
199}
200
201/// Returns the lowercase hex character for `nibble` (0–15).
202/// Used for SHA-256 hex output (JMAP-CID §1 requires lowercase).
203fn hex_nibble_lower(nibble: u8) -> char {
204 match nibble {
205 0..=9 => char::from(b'0' + nibble),
206 10..=15 => char::from(b'a' + nibble - 10),
207 _ => unreachable!("nibble must be 0–15"),
208 }
209}
210
211impl JmapClient {
212 /// Upload raw bytes to the JMAP blob store (RFC 8620 §6.1).
213 ///
214 /// `upload_url_template` is from `Session.upload_url`; `{accountId}` is
215 /// substituted before the request. `content_type` is sent as the
216 /// `Content-Type` header. If the server returns a `sha256` field
217 /// (JMAP-CID capability), it is verified against the locally-computed
218 /// digest and `ClientError::BlobIntegrityMismatch` is returned on mismatch.
219 pub async fn upload_blob(
220 &self,
221 upload_url_template: &str,
222 account_id: &str,
223 data: bytes::Bytes,
224 content_type: &str,
225 ) -> Result<BlobUploadResponse, ClientError> {
226 crate::client::require_http_url(upload_url_template)?;
227 let ct_hv =
228 HeaderValue::from_str(content_type).map_err(ClientError::from_invalid_header)?;
229 let url = expand_url_template(upload_url_template, &[("accountId", account_id)])?;
230
231 // Compute SHA-256 and capture size before handing ownership of data
232 // to the request body. local_size is used to cross-check the server's
233 // reported `size` after upload (bd:JMAP-6lsm.8) — when the server
234 // does not return sha256 (most do not), size is the only signal that
235 // the bytes we sent are the bytes the server stored.
236 let local_sha256 = compute_sha256_hex(&data);
237 let local_size = data.len() as u64;
238
239 let req = self.inject_auth(
240 self.http
241 .post(&url)
242 .header(CONTENT_TYPE, ct_hv)
243 .timeout(self.config.request_timeout)
244 .body(data),
245 );
246
247 let resp = req.send().await.map_err(ClientError::from_reqwest)?;
248 let status = resp.status();
249 Self::check_auth_status(status)?;
250 let resp = resp.error_for_status().map_err(ClientError::from_reqwest)?;
251
252 let upload_limit = self.config.max_upload_response_body;
253
254 if let Some(len) = resp.content_length() {
255 if len > upload_limit {
256 return Err(ClientError::ResponseTooLarge {
257 actual: len,
258 limit: upload_limit,
259 });
260 }
261 }
262 let bytes = resp.bytes().await.map_err(ClientError::from_reqwest)?;
263 if bytes.len() as u64 > upload_limit {
264 return Err(ClientError::ResponseTooLarge {
265 actual: bytes.len() as u64,
266 limit: upload_limit,
267 });
268 }
269 let upload_resp: BlobUploadResponse =
270 serde_json::from_slice(&bytes).map_err(ClientError::Parse)?;
271
272 // Defense-in-depth: cross-check the server's reported size against the
273 // bytes we actually uploaded (bd:JMAP-6lsm.8). When sha256 is present
274 // (below) it makes a size mismatch implausible because length is
275 // implicit in the digest, but most servers do not advertise the
276 // JMAP-CID sha256 capability — in that case `size` is the only
277 // signal that the upload was complete and intact. A mismatch
278 // surfaces as UnexpectedResponse rather than a typed variant; a
279 // future minor release may add ClientError::BlobSizeMismatch for
280 // structured matching, but keeping the variant set stable in 0.1.x
281 // is the conservative choice.
282 if upload_resp.size != local_size {
283 return Err(ClientError::UnexpectedResponse(format!(
284 "blob upload size mismatch: client uploaded {local_size} bytes, server reports \
285 {server_size} bytes",
286 server_size = upload_resp.size,
287 )));
288 }
289
290 if let Some(ref server_sha256) = upload_resp.sha256 {
291 // The typed `jmap_cid_types::Sha256` deserialize already
292 // enforces the 64-character lowercase-hex ABNF; reaching
293 // this branch implies the wire value is canonical. We
294 // can compare the locally-computed digest (also
295 // canonical lowercase hex from `compute_sha256_hex`)
296 // against the typed value's string form directly.
297 //
298 // Uppercase-hex from a non-conformant server is rejected
299 // at deserialize per draft-atwood-jmap-cid-00 §2 ABNF and
300 // surfaces as ClientError::Parse before this branch runs.
301 if local_sha256 != server_sha256.as_str() {
302 return Err(ClientError::BlobIntegrityMismatch {
303 expected: local_sha256,
304 actual: server_sha256.as_str().to_owned(),
305 });
306 }
307 }
308
309 Ok(upload_resp)
310 }
311
312 /// Download a blob by ID (RFC 8620 §6.2).
313 ///
314 /// Template variables `{accountId}`, `{blobId}`, `{name}`, and `{type}` are
315 /// substituted from the corresponding fields of `params` before the GET
316 /// request. `{type}` expands to an empty string when `params.accept_type`
317 /// is `None`; templates that include `?accept={type}` produce `?accept=`.
318 /// If the server does not tolerate an empty `?accept=` parameter, omit
319 /// `{type}` from the `download_url` template in the Session document.
320 ///
321 /// If `params.expected_sha256` is `Some`, the downloaded bytes are verified
322 /// against the hex digest and `ClientError::BlobIntegrityMismatch` is
323 /// returned on mismatch.
324 pub async fn download_blob(
325 &self,
326 params: DownloadBlobParams<'_>,
327 ) -> Result<bytes::Bytes, ClientError> {
328 let DownloadBlobParams {
329 download_url_template,
330 account_id,
331 blob_id,
332 name,
333 accept_type,
334 expected_sha256,
335 } = params;
336 crate::client::require_http_url(download_url_template)?;
337 let vars = [
338 ("accountId", account_id),
339 ("blobId", blob_id),
340 ("name", name),
341 // Always supply {type} — even as empty string — so templates
342 // containing `?accept={type}` expand cleanly rather than triggering
343 // the unexpanded-placeholder error.
344 ("type", accept_type.unwrap_or("")),
345 ];
346 let url = expand_url_template(download_url_template, &vars)?;
347
348 let req = self.inject_auth(self.http.get(&url).timeout(self.config.request_timeout));
349
350 let resp = req.send().await.map_err(ClientError::from_reqwest)?;
351 let status = resp.status();
352 Self::check_auth_status(status)?;
353 let resp = resp.error_for_status().map_err(ClientError::from_reqwest)?;
354
355 let download_limit = self.config.max_download_body;
356
357 // Early rejection on Content-Length header. Does not replace the streaming
358 // check below: Content-Length can lie or be absent.
359 if let Some(len) = resp.content_length() {
360 if len > download_limit {
361 return Err(ClientError::ResponseTooLarge {
362 actual: len,
363 limit: download_limit,
364 });
365 }
366 }
367
368 // Stream body chunk-by-chunk and enforce the cap before each accumulation.
369 // This prevents buffering a response that exceeds the limit when Content-Length
370 // is absent or lying — without this, resp.bytes().await would buffer the full
371 // response before the check could fire.
372 let mut stream = resp.bytes_stream();
373 let mut body: Vec<u8> = Vec::new();
374 while let Some(chunk) = stream.next().await {
375 let chunk = chunk.map_err(ClientError::from_reqwest)?;
376 let new_len = body.len() as u64 + chunk.len() as u64;
377 if new_len > download_limit {
378 return Err(ClientError::ResponseTooLarge {
379 actual: new_len,
380 limit: download_limit,
381 });
382 }
383 body.extend_from_slice(&chunk);
384 }
385 let bytes = bytes::Bytes::from(body);
386
387 if let Some(expected) = expected_sha256 {
388 if !is_valid_sha256_hex(expected) {
389 return Err(ClientError::InvalidArgument(format!(
390 "expected_sha256 is not 64-char hex: {expected:?}"
391 )));
392 }
393 let actual = compute_sha256_hex(&bytes);
394 // Normalize expected to lowercase; callers may hold uppercase hex from
395 // a server or external source. Both represent the same digest.
396 let expected_lower = expected.to_ascii_lowercase();
397 if actual != expected_lower {
398 return Err(ClientError::BlobIntegrityMismatch {
399 expected: expected_lower,
400 actual,
401 });
402 }
403 }
404
405 Ok(bytes)
406 }
407}
408
409/// Returns `true` if `s` is exactly 64 hex characters (uppercase or lowercase).
410///
411/// Callers are responsible for producing the appropriate [`ClientError`] variant:
412/// - server-provided digest (upload response `sha256` field) → [`ClientError::InvalidSession`]
413/// - caller-supplied expected digest (`download_blob` `expected_sha256` arg) → [`ClientError::InvalidArgument`]
414fn is_valid_sha256_hex(s: &str) -> bool {
415 s.len() == 64 && s.bytes().all(|b| b.is_ascii_hexdigit())
416}
417
418/// Compute SHA-256 of `data` and return as 64-char lowercase hex string.
419fn compute_sha256_hex(data: &[u8]) -> String {
420 let hash = Sha256::digest(data);
421 hash.iter().fold(String::with_capacity(64), |mut s, b| {
422 s.push(hex_nibble_lower(*b >> 4));
423 s.push(hex_nibble_lower(*b & 0x0f));
424 s
425 })
426}
427
428#[cfg(test)]
429mod tests {
430 use super::*;
431
432 // Oracle: RFC 6570 §1.2 — simple substitution with unreserved-only value
433 #[test]
434 fn expand_upload_url() {
435 let result = expand_url_template(
436 "https://example.com/upload/{accountId}/",
437 &[("accountId", "account1")],
438 )
439 .expect("must succeed");
440 assert_eq!(result, "https://example.com/upload/account1/");
441 }
442
443 // Oracle: RFC 3986 §2.1 — space 0x20 encodes as %20
444 #[test]
445 fn expand_download_url_with_spaces() {
446 let result = expand_url_template(
447 "/download/{accountId}/{blobId}/{name}",
448 &[
449 ("accountId", "acc1"),
450 ("blobId", "blob-123"),
451 ("name", "my file.png"),
452 ],
453 )
454 .expect("must succeed");
455 assert_eq!(result, "/download/acc1/blob-123/my%20file.png");
456 }
457
458 // Oracle: RFC 3986 §2.1 — slash 0x2F encodes as %2F
459 #[test]
460 fn expand_with_slash_in_type() {
461 let result = expand_url_template(
462 "/dl/{accountId}/{blobId}/{name}?accept={type}",
463 &[
464 ("accountId", "a"),
465 ("blobId", "b"),
466 ("name", "x.jpg"),
467 ("type", "image/png"),
468 ],
469 )
470 .expect("must succeed");
471 assert_eq!(result, "/dl/a/b/x.jpg?accept=image%2Fpng");
472 }
473
474 // Oracle: expand_url_template must error when a template variable has no
475 // entry in vars — the template comes from the server's Session document,
476 // so an unrecognized variable name indicates a server-side bug.
477 #[test]
478 fn expand_unknown_variable_returns_error() {
479 let err = expand_url_template(
480 "https://example.com/upload/{accountId}/{unknownVar}/",
481 &[("accountId", "acc1")],
482 )
483 .expect_err("must fail when template variable is not supplied");
484 assert!(
485 matches!(err, crate::error::ClientError::InvalidSession(_)),
486 "expected InvalidSession, got {err:?}"
487 );
488 }
489
490 // Oracle: expand_url_template must error on a template with an unmatched
491 // '{' — no corresponding '}' exists. The expected error is InvalidSession
492 // because templates come from the server's Session document.
493 #[test]
494 fn expand_unmatched_open_brace_returns_error() {
495 let err = expand_url_template("https://example.com/{unclosed", &[])
496 .expect_err("unmatched '{' must return an error");
497 assert!(
498 matches!(err, crate::error::ClientError::InvalidSession(_)),
499 "expected InvalidSession for unmatched brace, got {err:?}"
500 );
501 }
502
503 // Oracle: vars entries whose name does not appear in the template are
504 // silently ignored — extra vars are benign (caller may pass a superset).
505 #[test]
506 fn expand_unused_var_is_ignored() {
507 let result = expand_url_template(
508 "https://example.com/upload/{accountId}/",
509 &[("accountId", "acc1"), ("extraVar", "value")],
510 )
511 .expect("extra vars must be silently ignored");
512 assert_eq!(result, "https://example.com/upload/acc1/");
513 }
514
515 // Oracle: RFC 3986 §2.3 — for inputs containing only unreserved
516 // characters (alphanumeric / `-` / `.` / `_` / `~`), percent_encode
517 // MUST return Cow::Borrowed without allocating. Verified by the
518 // matches! check against the Cow::Borrowed variant.
519 #[test]
520 fn percent_encode_unreserved_input_borrows() {
521 let input = "abc.DEF_123-xyz~tilde";
522 let result = percent_encode(input);
523 assert!(matches!(result, Cow::Borrowed(_)));
524 assert_eq!(result.as_ref(), input);
525 }
526
527 // Oracle: RFC 3986 §2.1 — inputs that contain any byte outside the
528 // unreserved set MUST be returned as Cow::Owned with each non-unreserved
529 // byte percent-escaped using uppercase hex (§2.1 mandates uppercase).
530 #[test]
531 fn percent_encode_with_space_is_owned_and_uppercase() {
532 let input = "hello world";
533 let result = percent_encode(input);
534 assert!(matches!(result, Cow::Owned(_)));
535 assert_eq!(result.as_ref(), "hello%20world");
536 }
537
538 // Oracle: RFC 3986 §2.1 — slash 0x2F encodes as %2F (uppercase hex).
539 // Mixed-content input MUST preserve the unreserved prefix verbatim
540 // and escape only the disallowed bytes.
541 #[test]
542 fn percent_encode_mixed_input_preserves_prefix() {
543 let input = "image/png";
544 let result = percent_encode(input);
545 assert!(matches!(result, Cow::Owned(_)));
546 assert_eq!(result.as_ref(), "image%2Fpng");
547 }
548
549 // Oracle: degenerate empty input. An empty string has no bytes requiring
550 // escape, so percent_encode MUST borrow it without allocation.
551 #[test]
552 fn percent_encode_empty_input_borrows() {
553 let result = percent_encode("");
554 assert!(matches!(result, Cow::Borrowed(_)));
555 assert_eq!(result.as_ref(), "");
556 }
557
558 // Oracle: tests/fixtures/blob/upload_response.json — hand-written fixture
559 // derived from RFC 8620 §6.1 blob upload response shape; not produced by
560 // the code under test.
561 #[test]
562 fn blob_upload_response_deserializes() {
563 let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
564 .join("tests/fixtures/blob/upload_response.json");
565 let text =
566 std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("cannot read fixture: {e}"));
567 let resp: BlobUploadResponse =
568 serde_json::from_str(&text).expect("fixture must deserialize as BlobUploadResponse");
569
570 assert_eq!(resp.account_id, "account1");
571 assert_eq!(resp.blob_id, "Gbc4c377-c8c3-4b48-b2bb-8c1e4cfb8b2a");
572 assert_eq!(resp.content_type, "image/png");
573 assert_eq!(resp.size, 48291);
574 // The typed Sha256 wrapper carries the canonical lowercase
575 // hex string; compare via `as_str` to keep the assertion
576 // independent of the wrapper's Debug shape.
577 assert_eq!(
578 resp.sha256.as_ref().map(jmap_cid_types::Sha256::as_str),
579 Some("9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08")
580 );
581 }
582
583 /// `BlobUploadResponse.extra` captures unknown fields on deserialize.
584 #[test]
585 fn blob_upload_response_preserves_vendor_extras() {
586 let raw = serde_json::json!({
587 "accountId": "account1",
588 "blobId": "Gbc4c377-c8c3-4b48-b2bb-8c1e4cfb8b2a",
589 "type": "image/png",
590 "size": 48291,
591 "acmeCorpScanResult": "clean"
592 });
593 let obj: BlobUploadResponse =
594 serde_json::from_value(raw).expect("BlobUploadResponse must deserialize");
595 assert_eq!(
596 obj.extra.get("acmeCorpScanResult").and_then(|v| v.as_str()),
597 Some("clean")
598 );
599 }
600
601 // bd:JMAP-v9py.13 oracle.
602 //
603 // The fixture hex digest is hand-typed from RFC 6234 §8.5 ("abc"
604 // test vector, formatted lowercase). It is NOT derived from the
605 // code under test, satisfying the workspace test-integrity rule
606 // that test oracles must be independent.
607
608 /// Deserialize a Blob/upload response carrying a canonical
609 /// 64-char lowercase-hex sha256 → field parses as
610 /// Some(Sha256(_)) preserving the wire string.
611 #[test]
612 fn blob_upload_response_deserializes_sha256_typed() {
613 let raw = serde_json::json!({
614 "accountId": "account1",
615 "blobId": "blob1",
616 "type": "text/plain",
617 "size": 3,
618 "sha256": "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
619 });
620 let obj: BlobUploadResponse = serde_json::from_value(raw).expect("must deserialize");
621 let s = obj.sha256.expect("sha256 must be Some");
622 assert_eq!(
623 s.as_str(),
624 "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
625 );
626 }
627
628 /// Serialize a BlobUploadResponse with `sha256: None` → the
629 /// `sha256` key must be ABSENT from the wire output per
630 /// `skip_serializing_if = "Option::is_none"`. This is the
631 /// contract for servers not advertising the CID capability.
632 #[test]
633 fn blob_upload_response_serializes_without_sha256_when_none() {
634 let resp = BlobUploadResponse {
635 account_id: Id::from("a1"),
636 blob_id: Id::from("b1"),
637 content_type: "text/plain".to_owned(),
638 size: 0,
639 sha256: None,
640 extra: serde_json::Map::new(),
641 };
642 let v = serde_json::to_value(&resp).expect("must serialize");
643 let obj = v.as_object().expect("object");
644 assert!(
645 !obj.contains_key("sha256"),
646 "None must elide the sha256 key: {v:?}"
647 );
648 }
649
650 /// Round-trip preservation: a server that omits the sha256
651 /// field deserializes into `None`, and serializing back
652 /// produces an output without a `sha256` key. This is the
653 /// shape RFC 8620 §6.1 compliant servers (without the CID
654 /// extension) produce; verify we don't accidentally inject a
655 /// null or empty sha256 on round-trip.
656 #[test]
657 fn blob_upload_response_no_sha256_round_trip() {
658 let raw = serde_json::json!({
659 "accountId": "account1",
660 "blobId": "blob1",
661 "type": "text/plain",
662 "size": 3
663 });
664 let obj: BlobUploadResponse = serde_json::from_value(raw).expect("must deserialize");
665 assert!(obj.sha256.is_none());
666
667 let re = serde_json::to_value(&obj).expect("must serialize");
668 let map = re.as_object().expect("object");
669 assert!(
670 !map.contains_key("sha256"),
671 "round-trip must not introduce sha256 key: {re:?}"
672 );
673 }
674
675 /// A non-conformant server sending uppercase hex now fails to
676 /// deserialize (typed `Sha256` is strict lowercase-only per
677 /// draft-atwood-jmap-cid-00 §2). Pinning the strict behavior
678 /// per bd:JMAP-v9py.13's design; the inter-op question is
679 /// tracked separately at bd:JMAP-noz7.
680 #[test]
681 fn blob_upload_response_rejects_uppercase_sha256() {
682 let raw = serde_json::json!({
683 "accountId": "account1",
684 "blobId": "blob1",
685 "type": "text/plain",
686 "size": 3,
687 "sha256": "BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD"
688 });
689 let err = serde_json::from_value::<BlobUploadResponse>(raw)
690 .expect_err("uppercase hex must fail to deserialize");
691 let msg = err.to_string();
692 assert!(
693 msg.contains("non-lowercase-hex") || msg.contains("at") || msg.contains("hex"),
694 "error must explain the hex constraint: {msg}"
695 );
696 }
697}