diff --git a/src/disc/mod.rs b/src/disc/mod.rs index bca3d8f..da04c79 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -2435,7 +2435,7 @@ impl Disc { pub fn aacs_disc_hash(&self) -> String { self.aacs .as_ref() - .map(|a| a.disc_hash.trim_start_matches("0x").to_string()) + .map(|a| crate::hex::strip_hex_prefix(&a.disc_hash).to_string()) .unwrap_or_default() } diff --git a/src/hex.rs b/src/hex.rs index a94e49b..9b2859a 100644 --- a/src/hex.rs +++ b/src/hex.rs @@ -16,7 +16,7 @@ /// (case-insensitive), then requires an even run of ASCII hex digits. Any /// non-hex byte, or an odd length, yields `None`. pub fn parse_hex_bytes(s: &str) -> Option> { - let body = strip_prefix(s.trim()); + let body = strip_hex_prefix(s.trim()); let bytes = body.as_bytes(); // Empty → empty Vec (a legitimately-empty variable-length field); odd length // is malformed. (`parse_hex_fixed` enforces a concrete length separately.) @@ -34,7 +34,7 @@ pub fn parse_hex_bytes(s: &str) -> Option> { /// prefix; requires EXACTLY `2*N` ASCII hex digits after it. `None` on any /// non-hex byte or a length mismatch. pub fn parse_hex_fixed(s: &str) -> Option<[u8; N]> { - let body = strip_prefix(s.trim()); + let body = strip_hex_prefix(s.trim()); let bytes = body.as_bytes(); if bytes.len() != 2 * N { return None; @@ -46,8 +46,33 @@ pub fn parse_hex_fixed(s: &str) -> Option<[u8; N]> { Some(out) } -/// Strip a single leading `0x` / `0X` if present (case-insensitive). -fn strip_prefix(s: &str) -> &str { +/// Parse a hex string into a `u16`. Accepts an optional `0x`/`0X` prefix +/// (case-insensitive) via the same [`strip_hex_prefix`] the byte parsers use. +/// `None` on any non-hex content or overflow. +/// +/// Exists so callers never hand-roll `from_str_radix(s.trim_start_matches("0x"), 16)` +/// — a **case-sensitive** strip that silently dropped an uppercase-`0X` value. +/// (That reintroduced-in-keydb bug is exactly what this module was built to kill; +/// the integer fields now share the one prefix rule.) +pub fn parse_hex_u16(s: &str) -> Option { + u16::from_str_radix(strip_hex_prefix(s.trim()), 16).ok() +} + +/// Parse a hex string into a `u32`. See [`parse_hex_u16`]. +pub fn parse_hex_u32(s: &str) -> Option { + u32::from_str_radix(strip_hex_prefix(s.trim()), 16).ok() +} + +/// Parse a hex string into a `u8`. See [`parse_hex_u16`]. +pub fn parse_hex_u8(s: &str) -> Option { + u8::from_str_radix(strip_hex_prefix(s.trim()), 16).ok() +} + +/// Strip a single leading `0x` / `0X` if present (case-insensitive). Public so +/// callers that only need the prefix rule (e.g. normalizing a disc hash) reuse +/// the one definition instead of hand-rolling a case-sensitive +/// `trim_start_matches("0x")`. +pub fn strip_hex_prefix(s: &str) -> &str { s.strip_prefix("0x") .or_else(|| s.strip_prefix("0X")) .unwrap_or(s) @@ -95,6 +120,23 @@ mod tests { assert_eq!(parse_hex_fixed::<16>(&s), None); } + #[test] + fn hex_ints_accept_both_prefix_cases_and_bare() { + // The regression the keydb device-key bug hit: uppercase `0X` must parse + // identically to `0x` and to a bare value. + assert_eq!(parse_hex_u16("0x0001"), Some(1)); + assert_eq!(parse_hex_u16("0X0001"), Some(1)); + assert_eq!(parse_hex_u16("0001"), Some(1)); + assert_eq!(parse_hex_u16(" 0XABCD "), Some(0xABCD)); + assert_eq!(parse_hex_u32("0X00000002"), Some(2)); + assert_eq!(parse_hex_u32("deadbeef"), Some(0xDEAD_BEEF)); + assert_eq!(parse_hex_u8("0X03"), Some(3)); + assert_eq!(parse_hex_u8("ff"), Some(0xFF)); + // Overflow / non-hex → None. + assert_eq!(parse_hex_u8("0x1FF"), None); + assert_eq!(parse_hex_u16("0xzz"), None); + } + #[test] fn bytes_variable_length_and_odd_rejected() { assert_eq!(parse_hex_bytes("0xAABBCC"), Some(vec![0xAA, 0xBB, 0xCC]));