Skip to main content

jmap_base_client/
blob.rs

1//! Blob upload/download operations and supporting types (RFC 8620 §6.1, §6.2)
2
3use std::borrow::Cow;
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}