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}