Skip to main content

jmap_cid_types/
digest.rs

1//! `Sha256` — the SHA-256 digest wire shape defined by
2//! draft-atwood-jmap-cid-00 §2.
3//!
4//! Spec text (§2 Conventions):
5//!
6//! > `sha256-value = 64( %x30-39 / %x61-66 )`
7//! > ; lowercase hex, exactly 64 chars
8//!
9//! Wire format is the bare 64-character lowercase hex string — NOT a
10//! wrapped JSON object. Round-trips bit-for-bit when the input is
11//! already a canonical (lowercase) hex string. Uppercase hex,
12//! non-hex characters, and any length other than 64 are rejected at
13//! deserialize and at [`Sha256::from_hex`].
14//!
15//! This crate intentionally carries no SHA-256 *computation* — only
16//! the wire shape. Servers / consumers compute the digest themselves
17//! (typically via [`sha2`](https://crates.io/crates/sha2) or
18//! [`ring`](https://crates.io/crates/ring)) and pass the 32 raw
19//! bytes via [`From`]`<[u8; 32]>` / [`From`]`<&[u8; 32]>` for
20//! [`Sha256`] to format the wire value.
21
22use serde::{Deserialize, Serialize, Serializer};
23use std::fmt;
24
25/// Parse error produced by [`Sha256::from_hex`] and the [`Sha256`]
26/// `Deserialize` impl.
27///
28/// The enum is `#[non_exhaustive]` at the type level and every
29/// variant is `#[non_exhaustive]` so future field additions and
30/// variant additions both remain semver-additive (bd:JMAP-sf5h.21
31/// ratified the single-tier shape — no wrapper struct is needed
32/// because no extra context lives on the wrapper).
33#[derive(Debug, Clone, PartialEq, Eq)]
34#[non_exhaustive]
35pub enum Sha256DigestError {
36    /// The candidate had a length other than 64 UTF-8 bytes.
37    ///
38    /// `got` is the actual byte length. The spec ABNF
39    /// (`64( %x30-39 / %x61-66 )`) is fixed-length and admits no
40    /// other length.
41    ///
42    /// `got` is `u32` (saturating, capped at `u32::MAX`) rather
43    /// than `usize` to avoid leaking a platform-dependent
44    /// integer width into the public error contract — the value
45    /// is realistically bounded by the input string length and any
46    /// candidate over 4 GiB would have been rejected earlier in the
47    /// parsing pipeline.
48    ///
49    /// Per-variant `#[non_exhaustive]` so future field additions
50    /// remain semver-additive.
51    #[non_exhaustive]
52    WrongLength {
53        /// The candidate's actual byte length, saturated at
54        /// `u32::MAX` for inputs larger than 4 GiB.
55        got: u32,
56    },
57    /// The candidate contained a byte outside the lowercase-hex set
58    /// `[0-9 a-f]` at the given 0-based position.
59    ///
60    /// Uppercase hex is intentionally rejected — the spec ABNF
61    /// `%x61-66` is the lowercase subset only.
62    ///
63    /// `at` is `u32` (saturating, capped at `u32::MAX`) rather
64    /// than `usize`; `byte` is the offending UTF-8 byte itself so
65    /// the diagnostic can report `0x41 ('A')` for an uppercase
66    /// candidate without the caller having to re-index the input.
67    ///
68    /// Per-variant `#[non_exhaustive]` so future field additions
69    /// remain semver-additive.
70    #[non_exhaustive]
71    NonHexLowercase {
72        /// 0-based byte index of the first offending character,
73        /// saturated at `u32::MAX` for inputs larger than 4 GiB.
74        at: u32,
75        /// The offending UTF-8 byte value at `at`.
76        byte: u8,
77    },
78}
79
80impl fmt::Display for Sha256DigestError {
81    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
82        match self {
83            Self::WrongLength { got } => {
84                write!(
85                    f,
86                    "sha256 digest must be exactly 64 lowercase hex chars (got {got})"
87                )
88            }
89            Self::NonHexLowercase { at, byte } => {
90                write!(
91                    f,
92                    "sha256 digest contains a non-lowercase-hex byte 0x{byte:02x} at byte {at}"
93                )
94            }
95        }
96    }
97}
98
99impl std::error::Error for Sha256DigestError {}
100
101/// SHA-256 digest carried on the JMAP wire as a 64-character
102/// lowercase hex string (draft-atwood-jmap-cid-00 §2).
103///
104/// # Example
105///
106/// ```
107/// use jmap_cid_types::Sha256;
108///
109/// // The SHA-256 of the empty string (FIPS 180-4 published vector).
110/// let hex = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
111/// let d: Sha256 = hex.parse()?;
112/// assert_eq!(d.as_str(), hex);
113///
114/// // Round-trips bit-for-bit through serde as a bare JSON string.
115/// let json = serde_json::to_string(&d)?;
116/// assert_eq!(json, format!("\"{hex}\""));
117/// let d2: Sha256 = serde_json::from_str(&json)?;
118/// assert_eq!(d, d2);
119/// # Ok::<(), Box<dyn std::error::Error>>(())
120/// ```
121///
122/// # Construction
123///
124/// All four ABNF-validating paths share the same parse logic from
125/// [`Sha256::from_hex`]:
126///
127/// - [`Sha256::from_hex`] — validate a `&str` candidate.
128/// - [`TryFrom<&str>`](Sha256#impl-TryFrom<%26str>-for-Sha256) /
129///   [`TryFrom<String>`](Sha256#impl-TryFrom<String>-for-Sha256) —
130///   validate via `try_into()`.
131/// - [`FromStr::from_str`](std::str::FromStr) — validate via
132///   `.parse::<Sha256>()`.
133/// - `serde::Deserialize` — validates via `TryFrom<String>` per
134///   the `#[serde(try_from = "String")]` attribute.
135///
136/// One infallible conversion:
137///
138/// - [`From`]`<[u8; 32]>` / [`From`]`<&[u8; 32]>` for [`Sha256`] —
139///   format 32 raw digest bytes into the canonical lowercase-hex
140///   string. Infallible because every byte produces two
141///   lowercase-hex nibbles by construction. The input is the
142///   *output* of a hash function (the raw digest), NOT arbitrary
143///   input data to be hashed — this crate carries no hash
144///   computation. See the [`From<&[u8; 32]>`](#impl-From%3C%26%5Bu8;+32%5D%3E-for-Sha256)
145///   impl for byte-ordering details.
146///
147/// # Wire format
148///
149/// The bare 64-character hex string. `#[serde(try_from = "String")]`
150/// routes every deserialize path through the validating
151/// `TryFrom<String>` impl, so every deserialize path applies the
152/// same ABNF check `Sha256::from_hex` does. Serialize uses a
153/// manual `impl Serialize` that emits the inner hex string via
154/// `serializer.collect_str` (canonical-template parity with the
155/// `jmap-types::Id` newtype; see bd:JMAP-sf5h.20).
156///
157/// # Allocation bounds
158///
159/// **`Sha256` does not bound allocation before validation.** The
160/// `#[serde(try_from = "String")]` adapter routes every deserialize
161/// path through `TryFrom<String>`, which means `serde_json` (and any
162/// other serde data format) **materialises the full input string
163/// before** the 64-byte ABNF cap rejects oversize values. A hostile
164/// JMAP peer responding with `{"sha256": "aaaa…"}` where the payload
165/// is gigabytes of `'a'` will force the consumer to allocate
166/// `O(payload-length)` bytes per deserialize attempt before the
167/// `WrongLength` error fires.
168///
169/// Consumers deserialising `Sha256` (directly or transitively, e.g.
170/// via [`jmap-base-client`]'s `BlobUploadResponse.sha256` field) from
171/// an untrusted or partially-trusted JMAP peer MUST enforce a
172/// **body-size limit at the transport layer**:
173///
174/// - `reqwest::Response::bytes()` does not bound by default; use
175///   `Response::bytes_stream()` plus a wrapping `take(N)` byte cap,
176///   or check `Response::content_length()` against a policy maximum
177///   before reading.
178/// - `tokio_tungstenite` WebSocket frames default to a 64 MiB limit
179///   per frame (`WebSocketConfig::max_frame_size`); JMAP push frames
180///   carrying a digest field do not need 64 MiB and the limit can be
181///   tightened.
182/// - Hand-rolled HTTP clients reading a `Body` MUST cap with a
183///   `take(N)` adapter before calling `serde_json::from_reader`.
184///
185/// In-scope threats:
186///
187/// - A JMAP client connected to a malicious or compromised JMAP
188///   server. The server controls the response body.
189/// - A JMAP middleware (federation peer, mirror, cache) processing
190///   responses from a peer it does not fully trust.
191/// - A JMAP client behind a proxy that rewrites response bodies
192///   (less likely on TLS, but possible on metadata or via injection).
193///
194/// Out of scope: a trusted server returning oversize bodies by
195/// accident. Transport-layer bounding handles both cases uniformly.
196///
197/// This crate intentionally does not switch to a custom
198/// `Deserialize` impl with `Visitor::visit_str` length pre-check —
199/// the workspace canonical pattern keeps validation centralised in
200/// `Sha256::from_hex` so the ABNF check is a single source of truth
201/// (see bd:JMAP-sf5h.9 for the decision record). Self-bounding at
202/// the type level would shift validation responsibility into the
203/// `Deserialize` impl and away from `from_hex`; the workspace prefers
204/// transport-layer bounding over per-type bounding because the
205/// transport bound applies uniformly to **every** field on a
206/// hostile response, not just `sha256` ones.
207///
208/// [`jmap-base-client`]: https://docs.rs/jmap-base-client
209///
210/// # Equality and threat model
211///
212/// `PartialEq` and `Eq` on `Sha256` inherit the standard
213/// **variable-time** `String` compare — short-circuits on the
214/// first byte mismatch. This is appropriate for the in-scope
215/// JMAP CID use case (`draft-atwood-jmap-cid-00`): the digest is
216/// the SHA-256 of a **public blob** broadcast to every party
217/// with read access, so there is no secret comparison and the
218/// timing channel reveals nothing an attacker does not already
219/// have.
220///
221/// Consumers repurposing `Sha256` for **secret-derived
222/// comparisons** — MAC-style verification, commitment opening,
223/// capability tokens — MUST use a constant-time compare via
224/// [`subtle::ConstantTimeEq`] on the underlying bytes. Extract
225/// via [`Sha256::as_str`]`().as_bytes()`. The variable-time
226/// short-circuit otherwise leaks the prefix length of a matching
227/// digest to a remote attacker on the classic Bleichenbacher-class
228/// timing pattern.
229///
230/// This crate intentionally does not ship a constant-time
231/// comparison helper because the workspace policy is that
232/// MAC/HMAC-shaped verification belongs in a separate type
233/// (`MacTag`, `SecretDigest`) so the choice of constant-time
234/// equality is part of the type contract, not a per-call-site
235/// opt-in on a wire-format type.
236///
237/// [`subtle::ConstantTimeEq`]: https://docs.rs/subtle/latest/subtle/trait.ConstantTimeEq.html
238#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize)]
239#[serde(try_from = "String")]
240#[non_exhaustive]
241pub struct Sha256(String);
242
243// Manual Serialize impl — matches the canonical Id newtype pattern
244// in crate-jmap-types/src/id.rs and eliminates the previous
245// `#[serde(into = "String")]` adapter (which forced a public
246// `From<Sha256> for String` impl that duplicated the `into_inner`
247// inherent method on the public surface — bd:JMAP-sf5h.20).
248//
249// `collect_str` writes the inner hex string directly into the
250// serializer without an intermediate `String` clone, matching the
251// zero-extra-allocation posture the type already has on
252// deserialize via `TryFrom<String>`.
253impl Serialize for Sha256 {
254    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
255        serializer.collect_str(&self.0)
256    }
257}
258
259impl Sha256 {
260    /// Parse a candidate hex string into a [`Sha256`].
261    ///
262    /// Returns [`Sha256DigestError`] when the candidate is not
263    /// exactly 64 bytes long or contains any byte outside the
264    /// lowercase-hex set `[0-9 a-f]`. Errors report position so a
265    /// caller can surface a precise diagnostic.
266    pub fn from_hex(s: &str) -> Result<Self, Sha256DigestError> {
267        Self::validate(s)?;
268        Ok(Self(s.to_owned()))
269    }
270
271    /// ABNF check (`64( %x30-39 / %x61-66 )`) shared by every
272    /// construction path. Returns `Ok(())` if `s` is exactly 64
273    /// bytes of lowercase hex.
274    fn validate(s: &str) -> Result<(), Sha256DigestError> {
275        let bytes = s.as_bytes();
276        if bytes.len() != 64 {
277            return Err(Sha256DigestError::WrongLength {
278                got: u32::try_from(bytes.len()).unwrap_or(u32::MAX),
279            });
280        }
281        // ABNF `%x30-39 / %x61-66` — '0'..='9' or 'a'..='f'.
282        for (i, b) in bytes.iter().enumerate() {
283            let ok = b.is_ascii_digit() || (b'a'..=b'f').contains(b);
284            if !ok {
285                return Err(Sha256DigestError::NonHexLowercase {
286                    // `i` is bounded by the 64-byte length check
287                    // above; the cast never saturates here, but
288                    // u32::try_from preserves the type-contract
289                    // posture if the length check is ever
290                    // relaxed.
291                    at: u32::try_from(i).unwrap_or(u32::MAX),
292                    byte: *b,
293                });
294            }
295        }
296        Ok(())
297    }
298
299    /// Borrow the inner 64-character lowercase-hex string.
300    pub fn as_str(&self) -> &str {
301        &self.0
302    }
303
304    /// Consume the value and return the inner `String`.
305    pub fn into_inner(self) -> String {
306        self.0
307    }
308}
309
310impl fmt::Display for Sha256 {
311    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
312        f.write_str(&self.0)
313    }
314}
315
316impl AsRef<str> for Sha256 {
317    fn as_ref(&self) -> &str {
318        &self.0
319    }
320}
321
322impl TryFrom<String> for Sha256 {
323    type Error = Sha256DigestError;
324    /// Validate the owned `String` against the ABNF and **move** it
325    /// into the [`Sha256`] on success — no second allocation. This
326    /// is the path `#[serde(try_from = "String")]` takes, so every
327    /// `serde_json::from_str::<Sha256>(...)` deserialize avoids the
328    /// double-alloc that `from_hex(&s)` would incur.
329    fn try_from(s: String) -> Result<Self, Self::Error> {
330        Self::validate(&s)?;
331        Ok(Self(s))
332    }
333}
334
335impl TryFrom<&str> for Sha256 {
336    type Error = Sha256DigestError;
337    fn try_from(s: &str) -> Result<Self, Self::Error> {
338        Self::from_hex(s)
339    }
340}
341
342impl std::str::FromStr for Sha256 {
343    type Err = Sha256DigestError;
344    fn from_str(s: &str) -> Result<Self, Self::Err> {
345        Self::from_hex(s)
346    }
347}
348
349impl From<&[u8; 32]> for Sha256 {
350    /// Format 32 raw digest bytes as a canonical lowercase-hex
351    /// [`Sha256`]. Infallible because every byte produces two
352    /// lowercase-hex nibbles by construction.
353    ///
354    /// The input is the **output of a SHA-256 hash function** —
355    /// e.g. `sha2::Sha256::digest(data).into()` — not the data to
356    /// be hashed. This crate intentionally carries no hash
357    /// computation.
358    ///
359    /// # Byte ordering
360    ///
361    /// The 32 input bytes are taken in the canonical
362    /// **FIPS 180-4 SHA-256 output order**: the most significant
363    /// byte of the digest first. `b[0]` occupies positions 0..=1
364    /// of the resulting hex string and `b[31]` occupies positions
365    /// 62..=63. This matches the output of `sha2::Sha256::digest`,
366    /// `ring::digest::digest(&SHA256, ...)`, and
367    /// `openssl::sha::sha256`. Consumers feeding digest output
368    /// from a non-standard source (HSM in non-standard endianness,
369    /// pre-reversed archive format) must reorder bytes before
370    /// the conversion or the wire value will be wrong.
371    fn from(b: &[u8; 32]) -> Self {
372        use std::fmt::Write as _;
373        // 32 bytes → 64 hex chars. Pre-size the buffer to avoid
374        // reallocations; the `{:02x}` formatter writes two
375        // lowercase-hex nibbles per byte using std's well-tested
376        // hex formatter.
377        let mut out = String::with_capacity(64);
378        for byte in b {
379            // write! to a String never fails (the String never
380            // returns Err from its fmt::Write impl) — the .expect
381            // documents that the only Err path is unreachable.
382            write!(out, "{byte:02x}").expect("write! to String is infallible");
383        }
384        Self(out)
385    }
386}
387
388impl From<[u8; 32]> for Sha256 {
389    /// Ergonomic owned-array conversion. Delegates to
390    /// [`From`]`<&[u8; 32]>` for [`Sha256`] — see that impl for
391    /// byte-ordering details. Provided so callers with an owned
392    /// digest array (e.g. `sha2::Sha256::digest(data).into()`)
393    /// can write `Sha256::from(bytes)` or `bytes.into()` without
394    /// reaching for `&bytes` at the call site.
395    fn from(b: [u8; 32]) -> Self {
396        Self::from(&b)
397    }
398}
399
400// Sibling-of-Id newtype: matches the impl_string_newtype! macro
401// surface from `jmap-types/src/id.rs` (PartialEq<str>,
402// PartialEq<&str>, Borrow<str>) so `Sha256` reads like every other
403// wire-format newtype in the workspace. The infallible From<String>
404// / From<&str> impls are deliberately omitted — `Sha256`'s ABNF
405// is closed (exactly 64 lowercase hex chars), unlike Id's open
406// SAFE-CHAR set, so construction is strictly fallible. See
407// bd:JMAP-sf5h.7 for the decision record.
408impl PartialEq<str> for Sha256 {
409    fn eq(&self, other: &str) -> bool {
410        self.0 == other
411    }
412}
413
414impl PartialEq<&str> for Sha256 {
415    fn eq(&self, other: &&str) -> bool {
416        self.0 == *other
417    }
418}
419
420impl std::borrow::Borrow<str> for Sha256 {
421    fn borrow(&self) -> &str {
422        &self.0
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    // ABNF-positive: a canonical lowercase-hex digest round-trips
431    // bit-for-bit through Serialize/Deserialize and `from_hex`.
432    const VALID: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
433
434    #[test]
435    fn from_hex_accepts_valid_lowercase_64_chars() {
436        let d = Sha256::from_hex(VALID).expect("valid digest");
437        assert_eq!(d.as_str(), VALID);
438        assert_eq!(format!("{d}"), VALID);
439    }
440
441    #[test]
442    fn from_hex_rejects_uppercase() {
443        // Same digest, uppercased — the ABNF is lowercase-only.
444        // The offending byte is the first character of the upper-cased
445        // VALID; oracle: 'E' = 0x45 (first char of the SHA-256 zero
446        // vector is 'e', upper-cased to 'E').
447        let upper = VALID.to_ascii_uppercase();
448        let err = Sha256::from_hex(&upper).expect_err("uppercase rejected");
449        match err {
450            Sha256DigestError::NonHexLowercase { at: 0, byte: 0x45 } => {}
451            other => panic!("expected NonHexLowercase {{ at: 0, byte: 0x45 }}, got {other:?}"),
452        }
453    }
454
455    #[test]
456    fn from_hex_rejects_uppercase_mid_string() {
457        // First 31 chars lowercase, byte at index 31 uppercase 'A'
458        // (0x41), remainder lowercase — exercises the
459        // position-tracking branch of the validator.
460        let mut s = String::from(&VALID[..31]);
461        s.push('A');
462        s.push_str(&VALID[32..]);
463        let err = Sha256::from_hex(&s).expect_err("uppercase mid-string rejected");
464        match err {
465            Sha256DigestError::NonHexLowercase { at: 31, byte: 0x41 } => {}
466            other => panic!("expected NonHexLowercase {{ at: 31, byte: 0x41 }}, got {other:?}"),
467        }
468    }
469
470    #[test]
471    fn from_hex_rejects_short_length() {
472        let s = &VALID[..63];
473        let err = Sha256::from_hex(s).expect_err("63 chars rejected");
474        assert_eq!(err, Sha256DigestError::WrongLength { got: 63 });
475    }
476
477    #[test]
478    fn from_hex_rejects_long_length() {
479        let mut s = String::from(VALID);
480        s.push('0');
481        let err = Sha256::from_hex(&s).expect_err("65 chars rejected");
482        assert_eq!(err, Sha256DigestError::WrongLength { got: 65 });
483    }
484
485    #[test]
486    fn from_hex_rejects_empty() {
487        let err = Sha256::from_hex("").expect_err("empty rejected");
488        assert_eq!(err, Sha256DigestError::WrongLength { got: 0 });
489    }
490
491    #[test]
492    fn from_hex_rejects_non_hex_character() {
493        // 63 valid chars then a non-hex 'g' (0x67) at index 63.
494        let mut s = String::from(&VALID[..63]);
495        s.push('g');
496        let err = Sha256::from_hex(&s).expect_err("non-hex 'g' rejected");
497        assert_eq!(
498            err,
499            Sha256DigestError::NonHexLowercase { at: 63, byte: 0x67 }
500        );
501    }
502
503    #[test]
504    fn from_borrowed_array_formats_canonical_lowercase_hex() {
505        // SHA-256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
506        // (NIST FIPS 180-4 published vector — independent oracle, NOT
507        // derived from this crate). The vector is hand-copied into
508        // the test rather than computed via sha2::Sha256::digest() at
509        // test time, because the latter would close the oracle loop:
510        // any nibble-ordering or character-table bug in the
511        // From<&[u8; 32]> impl would emit the same wrong digest the
512        // test expects. See bd:JMAP-sf5h.8 for the decision record.
513        let bytes: [u8; 32] = [
514            0xe3, 0xb0, 0xc4, 0x42, 0x98, 0xfc, 0x1c, 0x14, 0x9a, 0xfb, 0xf4, 0xc8, 0x99, 0x6f,
515            0xb9, 0x24, 0x27, 0xae, 0x41, 0xe4, 0x64, 0x9b, 0x93, 0x4c, 0xa4, 0x95, 0x99, 0x1b,
516            0x78, 0x52, 0xb8, 0x55,
517        ];
518        let d = Sha256::from(&bytes);
519        assert_eq!(d.as_str(), VALID);
520    }
521
522    #[test]
523    fn from_owned_array_delegates_to_borrowed_path() {
524        // The owned-array From<[u8; 32]> impl must produce the same
525        // wire string as the borrowed-array From<&[u8; 32]> impl for
526        // the same input — they share a single nibble-formatting
527        // path. Same FIPS 180-4 vector as the borrowed-path test.
528        let bytes: [u8; 32] = [
529            0xe3, 0xb0, 0xc4, 0x42, 0x98, 0xfc, 0x1c, 0x14, 0x9a, 0xfb, 0xf4, 0xc8, 0x99, 0x6f,
530            0xb9, 0x24, 0x27, 0xae, 0x41, 0xe4, 0x64, 0x9b, 0x93, 0x4c, 0xa4, 0x95, 0x99, 0x1b,
531            0x78, 0x52, 0xb8, 0x55,
532        ];
533        // `.into()` exercises the From<[u8; 32]> path.
534        let d: Sha256 = bytes.into();
535        assert_eq!(d.as_str(), VALID);
536        // And both impls agree.
537        assert_eq!(d, Sha256::from(&bytes));
538    }
539
540    #[test]
541    fn from_array_deadbeef_pattern() {
542        // First four bytes 0xde 0xad 0xbe 0xef, rest zero — verifies
543        // nibble ordering and lowercase-hex character set.
544        let mut bytes = [0u8; 32];
545        bytes[0] = 0xde;
546        bytes[1] = 0xad;
547        bytes[2] = 0xbe;
548        bytes[3] = 0xef;
549        let d = Sha256::from(&bytes);
550        assert!(d.as_str().starts_with("deadbeef"));
551        assert_eq!(d.as_str().len(), 64);
552        // All trailing nibbles should be '0' since the bytes are zero.
553        for c in d.as_str().chars().skip(8) {
554            assert_eq!(c, '0');
555        }
556    }
557
558    #[test]
559    fn serialize_emits_bare_hex_string() {
560        let d = Sha256::from_hex(VALID).unwrap();
561        let json = serde_json::to_string(&d).unwrap();
562        // Bare string, double-quoted, no wrapper object.
563        assert_eq!(json, format!("\"{VALID}\""));
564    }
565
566    #[test]
567    fn deserialize_accepts_valid_hex_string() {
568        let json = format!("\"{VALID}\"");
569        let d: Sha256 = serde_json::from_str(&json).unwrap();
570        assert_eq!(d.as_str(), VALID);
571    }
572
573    #[test]
574    fn deserialize_rejects_uppercase() {
575        let json = format!("\"{}\"", VALID.to_ascii_uppercase());
576        let err = serde_json::from_str::<Sha256>(&json)
577            .expect_err("uppercase digest rejected at deserialize");
578        let msg = err.to_string();
579        assert!(
580            msg.contains("non-lowercase-hex"),
581            "expected lowercase-hex error, got: {msg}"
582        );
583    }
584
585    #[test]
586    fn deserialize_rejects_wrong_length() {
587        let json = "\"abc\"";
588        let err =
589            serde_json::from_str::<Sha256>(json).expect_err("short digest rejected at deserialize");
590        let msg = err.to_string();
591        assert!(
592            msg.contains("64") && msg.contains("got 3"),
593            "expected wrong-length error mentioning 64 and got 3, got: {msg}"
594        );
595    }
596
597    #[test]
598    fn round_trip_through_json_value() {
599        // Round-trip via serde_json::Value to exercise the same path
600        // a Blob upload response would take.
601        let d = Sha256::from_hex(VALID).unwrap();
602        let v: serde_json::Value = serde_json::to_value(&d).unwrap();
603        assert_eq!(v, serde_json::Value::String(VALID.to_string()));
604        let d2: Sha256 = serde_json::from_value(v).unwrap();
605        assert_eq!(d, d2);
606    }
607
608    #[test]
609    fn into_inner_yields_owned_string() {
610        let d = Sha256::from_hex(VALID).unwrap();
611        let s: String = d.into_inner();
612        assert_eq!(s, VALID);
613    }
614
615    #[test]
616    fn as_ref_str_borrows_inner() {
617        let d = Sha256::from_hex(VALID).unwrap();
618        let s: &str = d.as_ref();
619        assert_eq!(s, VALID);
620    }
621
622    #[test]
623    fn from_str_works() {
624        let d: Sha256 = VALID.parse().unwrap();
625        assert_eq!(d.as_str(), VALID);
626    }
627
628    #[test]
629    fn try_from_string_moves_buffer_not_clones() {
630        // TryFrom<String> should MOVE the owned String into the
631        // Sha256, not validate then clone. Verify by checking that
632        // the resulting Sha256's underlying byte buffer has the
633        // same address as the input String's buffer — pointer
634        // identity proves no second allocation occurred.
635        let input = VALID.to_owned();
636        let input_ptr = input.as_ptr();
637        let d: Sha256 = input.try_into().expect("valid digest");
638        assert_eq!(
639            d.as_str().as_ptr(),
640            input_ptr,
641            "TryFrom<String> must move the owned buffer; pointer mismatch \
642             indicates a re-allocation"
643        );
644    }
645
646    #[test]
647    fn try_from_string_validates() {
648        let d: Sha256 = VALID.to_string().try_into().unwrap();
649        assert_eq!(d.as_str(), VALID);
650        let err: Result<Sha256, _> = "bogus".to_string().try_into();
651        assert!(err.is_err());
652    }
653
654    #[test]
655    fn partial_eq_str_compares_against_string_slice() {
656        // Sibling-of-Id pattern (crate-jmap-types/src/id.rs): a wire
657        // newtype compares directly against &str without forcing the
658        // caller to write .as_str() at the call site.
659        let d = Sha256::from_hex(VALID).unwrap();
660        assert!(d == *VALID);
661        assert!(d != *"deadbeef");
662    }
663
664    #[test]
665    fn partial_eq_ref_str_compares_against_borrowed_slice() {
666        let d = Sha256::from_hex(VALID).unwrap();
667        let s: &str = VALID;
668        assert!(d == s);
669        let other: &str = "0000000000000000000000000000000000000000000000000000000000000000";
670        assert!(d != other);
671    }
672
673    #[test]
674    fn borrow_str_enables_hashmap_lookup_by_str_key() {
675        // Without `impl Borrow<str> for Sha256`, HashMap<Sha256, _>::get(&str)
676        // does not compile. This test demonstrates the lookup pattern works.
677        use std::borrow::Borrow;
678        use std::collections::HashMap;
679
680        let d = Sha256::from_hex(VALID).unwrap();
681        // Sanity: Borrow<str> yields the same bytes as as_str().
682        let borrowed: &str = d.borrow();
683        assert_eq!(borrowed, VALID);
684
685        let mut m: HashMap<Sha256, &'static str> = HashMap::new();
686        m.insert(d, "value");
687        // Look up by &str — this compiles only when Borrow<str> exists.
688        assert_eq!(m.get(VALID), Some(&"value"));
689    }
690
691    #[test]
692    fn error_display_includes_position_and_byte() {
693        // Oracle: hand-constructed variant; Display must surface
694        // both the byte position and the offending byte value so
695        // the diagnostic is actionable without re-indexing the
696        // input.
697        let err = Sha256DigestError::NonHexLowercase { at: 17, byte: 0x41 };
698        let msg = err.to_string();
699        assert!(msg.contains("byte 17"), "{msg}");
700        assert!(msg.contains("0x41"), "{msg}");
701        let err = Sha256DigestError::WrongLength { got: 65 };
702        assert!(err.to_string().contains("65"));
703    }
704}