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#[non_exhaustive]
34#[derive(Debug, Clone, PartialEq, Eq)]
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#[non_exhaustive]
239#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize)]
240#[serde(try_from = "String")]
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    /// The 32 raw digest bytes, most significant first: the inverse
310    /// of [`From`]`<[u8; 32]>` for [`Sha256`].
311    pub fn to_bytes(&self) -> [u8; 32] {
312        let hex = self.0.as_bytes();
313        let mut out = [0u8; 32];
314        for (i, byte) in out.iter_mut().enumerate() {
315            *byte = (nibble(hex[2 * i]) << 4) | nibble(hex[2 * i + 1]);
316        }
317        out
318    }
319}
320
321/// Value of one lowercase hex digit. Only called on [`Sha256`]'s
322/// validated `[0-9a-f]` text.
323fn nibble(c: u8) -> u8 {
324    match c {
325        b'0'..=b'9' => c - b'0',
326        _ => c - b'a' + 10,
327    }
328}
329
330impl fmt::Display for Sha256 {
331    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
332        f.write_str(&self.0)
333    }
334}
335
336impl AsRef<str> for Sha256 {
337    fn as_ref(&self) -> &str {
338        &self.0
339    }
340}
341
342impl TryFrom<String> for Sha256 {
343    type Error = Sha256DigestError;
344    /// Validate the owned `String` against the ABNF and **move** it
345    /// into the [`Sha256`] on success — no second allocation. This
346    /// is the path `#[serde(try_from = "String")]` takes, so every
347    /// `serde_json::from_str::<Sha256>(...)` deserialize avoids the
348    /// double-alloc that `from_hex(&s)` would incur.
349    fn try_from(s: String) -> Result<Self, Self::Error> {
350        Self::validate(&s)?;
351        Ok(Self(s))
352    }
353}
354
355impl TryFrom<&str> for Sha256 {
356    type Error = Sha256DigestError;
357    fn try_from(s: &str) -> Result<Self, Self::Error> {
358        Self::from_hex(s)
359    }
360}
361
362impl std::str::FromStr for Sha256 {
363    type Err = Sha256DigestError;
364    fn from_str(s: &str) -> Result<Self, Self::Err> {
365        Self::from_hex(s)
366    }
367}
368
369impl From<&[u8; 32]> for Sha256 {
370    /// Format 32 raw digest bytes as a canonical lowercase-hex
371    /// [`Sha256`]. Infallible because every byte produces two
372    /// lowercase-hex nibbles by construction.
373    ///
374    /// The input is the **output of a SHA-256 hash function** —
375    /// e.g. `sha2::Sha256::digest(data).into()` — not the data to
376    /// be hashed. This crate intentionally carries no hash
377    /// computation.
378    ///
379    /// # Byte ordering
380    ///
381    /// The 32 input bytes are taken in the canonical
382    /// **FIPS 180-4 SHA-256 output order**: the most significant
383    /// byte of the digest first. `b[0]` occupies positions 0..=1
384    /// of the resulting hex string and `b[31]` occupies positions
385    /// 62..=63. This matches the output of `sha2::Sha256::digest`,
386    /// `ring::digest::digest(&SHA256, ...)`, and
387    /// `openssl::sha::sha256`. Consumers feeding digest output
388    /// from a non-standard source (HSM in non-standard endianness,
389    /// pre-reversed archive format) must reorder bytes before
390    /// the conversion or the wire value will be wrong.
391    fn from(b: &[u8; 32]) -> Self {
392        use std::fmt::Write as _;
393        // 32 bytes → 64 hex chars. Pre-size the buffer to avoid
394        // reallocations; the `{:02x}` formatter writes two
395        // lowercase-hex nibbles per byte using std's well-tested
396        // hex formatter.
397        let mut out = String::with_capacity(64);
398        for byte in b {
399            // write! to a String never fails (the String never
400            // returns Err from its fmt::Write impl) — the .expect
401            // documents that the only Err path is unreachable.
402            write!(out, "{byte:02x}").expect("write! to String is infallible");
403        }
404        Self(out)
405    }
406}
407
408impl From<[u8; 32]> for Sha256 {
409    /// Ergonomic owned-array conversion. Delegates to
410    /// [`From`]`<&[u8; 32]>` for [`Sha256`] — see that impl for
411    /// byte-ordering details. Provided so callers with an owned
412    /// digest array (e.g. `sha2::Sha256::digest(data).into()`)
413    /// can write `Sha256::from(bytes)` or `bytes.into()` without
414    /// reaching for `&bytes` at the call site.
415    fn from(b: [u8; 32]) -> Self {
416        Self::from(&b)
417    }
418}
419
420// Sibling-of-Id newtype: matches the impl_string_newtype! macro
421// surface from `jmap-types/src/id.rs` (PartialEq<str>,
422// PartialEq<&str>, Borrow<str>) so `Sha256` reads like every other
423// wire-format newtype in the workspace. The infallible From<String>
424// / From<&str> impls are deliberately omitted — `Sha256`'s ABNF
425// is closed (exactly 64 lowercase hex chars), unlike Id's open
426// SAFE-CHAR set, so construction is strictly fallible. See
427// bd:JMAP-sf5h.7 for the decision record.
428impl PartialEq<str> for Sha256 {
429    fn eq(&self, other: &str) -> bool {
430        self.0 == other
431    }
432}
433
434impl PartialEq<&str> for Sha256 {
435    fn eq(&self, other: &&str) -> bool {
436        self.0 == *other
437    }
438}
439
440impl std::borrow::Borrow<str> for Sha256 {
441    fn borrow(&self) -> &str {
442        &self.0
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use super::*;
449
450    // ABNF-positive: a canonical lowercase-hex digest round-trips
451    // bit-for-bit through Serialize/Deserialize and `from_hex`.
452    const VALID: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
453
454    #[test]
455    fn from_hex_accepts_valid_lowercase_64_chars() {
456        let d = Sha256::from_hex(VALID).expect("valid digest");
457        assert_eq!(d.as_str(), VALID);
458        assert_eq!(format!("{d}"), VALID);
459    }
460
461    #[test]
462    fn from_hex_rejects_uppercase() {
463        // Same digest, uppercased — the ABNF is lowercase-only.
464        // The offending byte is the first character of the upper-cased
465        // VALID; oracle: 'E' = 0x45 (first char of the SHA-256 zero
466        // vector is 'e', upper-cased to 'E').
467        let upper = VALID.to_ascii_uppercase();
468        let err = Sha256::from_hex(&upper).expect_err("uppercase rejected");
469        match err {
470            Sha256DigestError::NonHexLowercase { at: 0, byte: 0x45 } => {}
471            other => panic!("expected NonHexLowercase {{ at: 0, byte: 0x45 }}, got {other:?}"),
472        }
473    }
474
475    #[test]
476    fn from_hex_rejects_uppercase_mid_string() {
477        // First 31 chars lowercase, byte at index 31 uppercase 'A'
478        // (0x41), remainder lowercase — exercises the
479        // position-tracking branch of the validator.
480        let mut s = String::from(&VALID[..31]);
481        s.push('A');
482        s.push_str(&VALID[32..]);
483        let err = Sha256::from_hex(&s).expect_err("uppercase mid-string rejected");
484        match err {
485            Sha256DigestError::NonHexLowercase { at: 31, byte: 0x41 } => {}
486            other => panic!("expected NonHexLowercase {{ at: 31, byte: 0x41 }}, got {other:?}"),
487        }
488    }
489
490    #[test]
491    fn from_hex_rejects_short_length() {
492        let s = &VALID[..63];
493        let err = Sha256::from_hex(s).expect_err("63 chars rejected");
494        assert_eq!(err, Sha256DigestError::WrongLength { got: 63 });
495    }
496
497    #[test]
498    fn from_hex_rejects_long_length() {
499        let mut s = String::from(VALID);
500        s.push('0');
501        let err = Sha256::from_hex(&s).expect_err("65 chars rejected");
502        assert_eq!(err, Sha256DigestError::WrongLength { got: 65 });
503    }
504
505    #[test]
506    fn from_hex_rejects_empty() {
507        let err = Sha256::from_hex("").expect_err("empty rejected");
508        assert_eq!(err, Sha256DigestError::WrongLength { got: 0 });
509    }
510
511    #[test]
512    fn from_hex_rejects_non_hex_character() {
513        // 63 valid chars then a non-hex 'g' (0x67) at index 63.
514        let mut s = String::from(&VALID[..63]);
515        s.push('g');
516        let err = Sha256::from_hex(&s).expect_err("non-hex 'g' rejected");
517        assert_eq!(
518            err,
519            Sha256DigestError::NonHexLowercase { at: 63, byte: 0x67 }
520        );
521    }
522
523    #[test]
524    fn from_borrowed_array_formats_canonical_lowercase_hex() {
525        // SHA-256("") = e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
526        // (NIST FIPS 180-4 published vector — independent oracle, NOT
527        // derived from this crate). The vector is hand-copied into
528        // the test rather than computed via sha2::Sha256::digest() at
529        // test time, because the latter would close the oracle loop:
530        // any nibble-ordering or character-table bug in the
531        // From<&[u8; 32]> impl would emit the same wrong digest the
532        // test expects. See bd:JMAP-sf5h.8 for the decision record.
533        let bytes: [u8; 32] = [
534            0xe3, 0xb0, 0xc4, 0x42, 0x98, 0xfc, 0x1c, 0x14, 0x9a, 0xfb, 0xf4, 0xc8, 0x99, 0x6f,
535            0xb9, 0x24, 0x27, 0xae, 0x41, 0xe4, 0x64, 0x9b, 0x93, 0x4c, 0xa4, 0x95, 0x99, 0x1b,
536            0x78, 0x52, 0xb8, 0x55,
537        ];
538        let d = Sha256::from(&bytes);
539        assert_eq!(d.as_str(), VALID);
540    }
541
542    #[test]
543    fn from_owned_array_delegates_to_borrowed_path() {
544        // The owned-array From<[u8; 32]> impl must produce the same
545        // wire string as the borrowed-array From<&[u8; 32]> impl for
546        // the same input — they share a single nibble-formatting
547        // path. Same FIPS 180-4 vector as the borrowed-path test.
548        let bytes: [u8; 32] = [
549            0xe3, 0xb0, 0xc4, 0x42, 0x98, 0xfc, 0x1c, 0x14, 0x9a, 0xfb, 0xf4, 0xc8, 0x99, 0x6f,
550            0xb9, 0x24, 0x27, 0xae, 0x41, 0xe4, 0x64, 0x9b, 0x93, 0x4c, 0xa4, 0x95, 0x99, 0x1b,
551            0x78, 0x52, 0xb8, 0x55,
552        ];
553        // `.into()` exercises the From<[u8; 32]> path.
554        let d: Sha256 = bytes.into();
555        assert_eq!(d.as_str(), VALID);
556        // And both impls agree.
557        assert_eq!(d, Sha256::from(&bytes));
558    }
559
560    #[test]
561    fn from_array_deadbeef_pattern() {
562        // First four bytes 0xde 0xad 0xbe 0xef, rest zero — verifies
563        // nibble ordering and lowercase-hex character set.
564        let mut bytes = [0u8; 32];
565        bytes[0] = 0xde;
566        bytes[1] = 0xad;
567        bytes[2] = 0xbe;
568        bytes[3] = 0xef;
569        let d = Sha256::from(&bytes);
570        assert!(d.as_str().starts_with("deadbeef"));
571        assert_eq!(d.as_str().len(), 64);
572        // All trailing nibbles should be '0' since the bytes are zero.
573        for c in d.as_str().chars().skip(8) {
574            assert_eq!(c, '0');
575        }
576    }
577
578    #[test]
579    fn serialize_emits_bare_hex_string() {
580        let d = Sha256::from_hex(VALID).unwrap();
581        let json = serde_json::to_string(&d).unwrap();
582        // Bare string, double-quoted, no wrapper object.
583        assert_eq!(json, format!("\"{VALID}\""));
584    }
585
586    #[test]
587    fn deserialize_accepts_valid_hex_string() {
588        let json = format!("\"{VALID}\"");
589        let d: Sha256 = serde_json::from_str(&json).unwrap();
590        assert_eq!(d.as_str(), VALID);
591    }
592
593    #[test]
594    fn deserialize_rejects_uppercase() {
595        let json = format!("\"{}\"", VALID.to_ascii_uppercase());
596        let err = serde_json::from_str::<Sha256>(&json)
597            .expect_err("uppercase digest rejected at deserialize");
598        let msg = err.to_string();
599        assert!(
600            msg.contains("non-lowercase-hex"),
601            "expected lowercase-hex error, got: {msg}"
602        );
603    }
604
605    #[test]
606    fn deserialize_rejects_wrong_length() {
607        let json = "\"abc\"";
608        let err =
609            serde_json::from_str::<Sha256>(json).expect_err("short digest rejected at deserialize");
610        let msg = err.to_string();
611        assert!(
612            msg.contains("64") && msg.contains("got 3"),
613            "expected wrong-length error mentioning 64 and got 3, got: {msg}"
614        );
615    }
616
617    #[test]
618    fn round_trip_through_json_value() {
619        // Round-trip via serde_json::Value to exercise the same path
620        // a Blob upload response would take.
621        let d = Sha256::from_hex(VALID).unwrap();
622        let v: serde_json::Value = serde_json::to_value(&d).unwrap();
623        assert_eq!(v, serde_json::Value::String(VALID.to_string()));
624        let d2: Sha256 = serde_json::from_value(v).unwrap();
625        assert_eq!(d, d2);
626    }
627
628    #[test]
629    fn into_inner_yields_owned_string() {
630        let d = Sha256::from_hex(VALID).unwrap();
631        let s: String = d.into_inner();
632        assert_eq!(s, VALID);
633    }
634
635    #[test]
636    fn as_ref_str_borrows_inner() {
637        let d = Sha256::from_hex(VALID).unwrap();
638        let s: &str = d.as_ref();
639        assert_eq!(s, VALID);
640    }
641
642    #[test]
643    fn from_str_works() {
644        let d: Sha256 = VALID.parse().unwrap();
645        assert_eq!(d.as_str(), VALID);
646    }
647
648    #[test]
649    fn try_from_string_moves_buffer_not_clones() {
650        // TryFrom<String> should MOVE the owned String into the
651        // Sha256, not validate then clone. Verify by checking that
652        // the resulting Sha256's underlying byte buffer has the
653        // same address as the input String's buffer — pointer
654        // identity proves no second allocation occurred.
655        let input = VALID.to_owned();
656        let input_ptr = input.as_ptr();
657        let d: Sha256 = input.try_into().expect("valid digest");
658        assert_eq!(
659            d.as_str().as_ptr(),
660            input_ptr,
661            "TryFrom<String> must move the owned buffer; pointer mismatch \
662             indicates a re-allocation"
663        );
664    }
665
666    #[test]
667    fn try_from_string_validates() {
668        let d: Sha256 = VALID.to_string().try_into().unwrap();
669        assert_eq!(d.as_str(), VALID);
670        let err: Result<Sha256, _> = "bogus".to_string().try_into();
671        assert!(err.is_err());
672    }
673
674    #[test]
675    fn partial_eq_str_compares_against_string_slice() {
676        // Sibling-of-Id pattern (crate-jmap-types/src/id.rs): a wire
677        // newtype compares directly against &str without forcing the
678        // caller to write .as_str() at the call site.
679        let d = Sha256::from_hex(VALID).unwrap();
680        assert!(d == *VALID);
681        assert!(d != *"deadbeef");
682    }
683
684    #[test]
685    fn partial_eq_ref_str_compares_against_borrowed_slice() {
686        let d = Sha256::from_hex(VALID).unwrap();
687        let s: &str = VALID;
688        assert!(d == s);
689        let other: &str = "0000000000000000000000000000000000000000000000000000000000000000";
690        assert!(d != other);
691    }
692
693    #[test]
694    fn borrow_str_enables_hashmap_lookup_by_str_key() {
695        // Without `impl Borrow<str> for Sha256`, HashMap<Sha256, _>::get(&str)
696        // does not compile. This test demonstrates the lookup pattern works.
697        use std::borrow::Borrow;
698        use std::collections::HashMap;
699
700        let d = Sha256::from_hex(VALID).unwrap();
701        // Sanity: Borrow<str> yields the same bytes as as_str().
702        let borrowed: &str = d.borrow();
703        assert_eq!(borrowed, VALID);
704
705        let mut m: HashMap<Sha256, &'static str> = HashMap::new();
706        m.insert(d, "value");
707        // Look up by &str — this compiles only when Borrow<str> exists.
708        assert_eq!(m.get(VALID), Some(&"value"));
709    }
710
711    #[test]
712    fn error_display_includes_position_and_byte() {
713        // Oracle: hand-constructed variant; Display must surface
714        // both the byte position and the offending byte value so
715        // the diagnostic is actionable without re-indexing the
716        // input.
717        let err = Sha256DigestError::NonHexLowercase { at: 17, byte: 0x41 };
718        let msg = err.to_string();
719        assert!(msg.contains("byte 17"), "{msg}");
720        assert!(msg.contains("0x41"), "{msg}");
721        let err = Sha256DigestError::WrongLength { got: 65 };
722        assert!(err.to_string().contains("65"));
723    }
724}