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}