diff --git a/CHANGELOG.md b/CHANGELOG.md index 20c125d..5b4b4de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ - `Base64Slice`, the borrowed counterpart of `Base64Vec`, analogous to `str` and `String`. +- `HexSlice` and `HexVec`, a borrowed/owned pair of hex types for + variable-length bytes, analogous to `str` and `String`. ### Changed @@ -19,6 +21,9 @@ require both `base64` and `alloc`. `alloc` is enabled by default; if you use `default-features = false` with `features = ["base64"]`, add `"alloc"` to keep `Base64Vec`. +- **Breaking:** `ParseHexError` has a new `OddLength` variant, returned when parsing a + `HexVec` from a string with an odd number of characters. This is a breaking + change for code that matches exhaustively on `ParseHexError`. - `Base64Slice` and `Base64Vec` now format with `Display` and `Debug` without allocating an intermediate `String`. diff --git a/README.md b/README.md index 8bcd345..9bfae33 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,10 @@ including automatic opt-in replacement with * [`HexArray`] encodes a fixed-length byte array as a hex string. (Requires the `hex` feature.) +* [`HexSlice`] encodes a borrowed byte slice as a hex string. + (Requires the `hex` feature.) +* [`HexVec`] encodes a variable-length byte vector as a hex + string. (Requires the `hex` and `alloc` features.) * [`Base64Vec`] encodes a variable-length byte vector as a base64 string. (Requires the `base64` and `alloc` features.) * [`Base64Slice`] encodes a borrowed byte slice as a base64 string. @@ -108,11 +112,12 @@ The closest alternatives are: ## Features -* **`hex`**: enables [`HexArray`]. *Enabled by default.* +* **`hex`**: enables [`HexArray`] and [`HexSlice`], and [`HexVec`] + together with `alloc`. *Enabled by default.* * **`base64`**: enables [`Base64Slice`], and [`Base64Vec`] together with `alloc`. *Enabled by default.* -* **`alloc`**: enables `alloc` support (required by [`Base64Vec`]). - *Enabled by default.* +* **`alloc`**: enables `alloc` support (required by [`Base64Vec`] and + [`HexVec`]). *Enabled by default.* * **`serde`**: implements `Serialize` and `Deserialize` for enabled types. *Not enabled by default.* * **`schemars08`**: derives `JsonSchema` for enabled types. @@ -124,6 +129,8 @@ All features other than `schemars08` are compatible with `no_std`. [`Base64Vec`]: https://docs.rs/byte-wrapper/0.1.1/byte_wrapper/base64_vec/struct.Base64Vec.html "struct byte_wrapper::base64_vec::Base64Vec" [CBOR]: https://cbor.io/ [`JsonSchema`]: https://docs.rs/schemars/0.8/schemars/trait.JsonSchema.html +[`HexSlice`]: https://docs.rs/byte-wrapper/0.1.1/byte_wrapper/hex_slice/struct.HexSlice.html "struct byte_wrapper::hex_slice::HexSlice" +[`HexVec`]: https://docs.rs/byte-wrapper/0.1.1/byte_wrapper/hex_vec/struct.HexVec.html "struct byte_wrapper::hex_vec::HexVec" [`Base64Slice`]: https://docs.rs/byte-wrapper/0.1.1/byte_wrapper/base64_slice/struct.Base64Slice.html "struct byte_wrapper::base64_slice::Base64Slice" [`serde-human-bytes`]: https://docs.rs/serde-human-bytes [`serde-encoded-bytes`]: https://docs.rs/serde-encoded-bytes diff --git a/src/hex_array.rs b/src/hex_array.rs index 6244fa7..04de96a 100644 --- a/src/hex_array.rs +++ b/src/hex_array.rs @@ -3,12 +3,8 @@ //! The [`HexArray`] newtype wrapper. -use core::{ - array::TryFromSliceError, - error, - fmt::{self, Write}, - str::FromStr, -}; +use crate::{HexSlice, hex_slice::HexDisplay}; +use core::{array::TryFromSliceError, error, fmt, str::FromStr}; /// A byte array that displays and parses as hex. /// @@ -58,28 +54,8 @@ impl HexArray { } } -/// Formats a byte slice as lower-case hex. -/// -/// This is used both for serialization (via `Display`) and as the -/// inner value in `HexArray`'s `Debug` output (via `Debug`). -struct HexDisplay<'a>(&'a [u8]); - -impl fmt::Display for HexDisplay<'_> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - for byte in self.0 { - write!(f, "{byte:02x}")?; - } - Ok(()) - } -} - -impl fmt::Debug for HexDisplay<'_> { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - fmt::Display::fmt(self, f) - } -} - -/// Error returned by [`HexArray::from_str`]. +/// Error returned by [`HexArray::from_str`] and +/// [`HexVec::from_str`](crate::HexVec). #[derive(Debug, Clone, PartialEq, Eq)] pub enum ParseHexError { /// The input string had the wrong length. @@ -89,6 +65,11 @@ pub enum ParseHexError { /// Actual number of characters in the input. actual: usize, }, + /// The input string had an odd number of characters. + OddLength { + /// Actual number of characters in the input. + actual: usize, + }, /// The input contained an invalid hex character. InvalidHexCharacter { /// The invalid character. @@ -108,6 +89,12 @@ impl fmt::Display for ParseHexError { expected, actual, ) } + ParseHexError::OddLength { actual } => { + write!( + f, + "expected an even number of hex characters, got {actual}", + ) + } ParseHexError::InvalidHexCharacter { c, index } => { write!(f, "invalid hex character '{}' at index {}", c, index,) } @@ -158,63 +145,21 @@ impl fmt::Debug for HexArray { } } -/// Writes hex bytes with padding/alignment support. -/// -/// `write_byte` is called for each byte in the slice to allow -/// callers to choose between lowercase and uppercase hex. -fn fmt_hex_padded( - bytes: &[u8], - f: &mut fmt::Formatter<'_>, - write_byte: fn(&mut fmt::Formatter<'_>, u8) -> fmt::Result, -) -> fmt::Result { - let content_len = bytes.len() * 2; - - match f.width() { - Some(width) if width > content_len => { - let padding = width - content_len; - let fill = f.fill(); - let (pre, post) = match f.align() { - Some(fmt::Alignment::Left) => (0, padding), - Some(fmt::Alignment::Right) | None => (padding, 0), - Some(fmt::Alignment::Center) => { - (padding / 2, padding - padding / 2) - } - }; - for _ in 0..pre { - f.write_char(fill)?; - } - for &byte in bytes { - write_byte(f, byte)?; - } - for _ in 0..post { - f.write_char(fill)?; - } - Ok(()) - } - Some(_) | None => { - for &byte in bytes { - write_byte(f, byte)?; - } - Ok(()) - } - } -} - impl fmt::Display for HexArray { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - fmt_hex_padded(&self.0, f, |f, b| write!(f, "{b:02x}")) + fmt::Display::fmt(HexSlice::new(&self.0), f) } } impl fmt::LowerHex for HexArray { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - fmt_hex_padded(&self.0, f, |f, b| write!(f, "{b:02x}")) + fmt::LowerHex::fmt(HexSlice::new(&self.0), f) } } impl fmt::UpperHex for HexArray { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - fmt_hex_padded(&self.0, f, |f, b| write!(f, "{b:02X}")) + fmt::UpperHex::fmt(HexSlice::new(&self.0), f) } } @@ -273,29 +218,14 @@ impl TryFrom<&[u8]> for HexArray { #[cfg(feature = "serde")] mod serde_impls { - use super::{HexArray, HexDisplay}; + use super::HexArray; + use crate::HexSlice; use core::fmt; use serde_core::{ - Deserializer, + Deserializer, Serialize, de::{Expected, SeqAccess, Visitor}, }; - /// Serializes a byte slice as lower-case hex if human-readable, - /// or as raw bytes if not. - fn serialize_lower( - bytes: &[u8], - serializer: S, - ) -> Result - where - S: serde_core::Serializer, - { - if serializer.is_human_readable() { - serializer.collect_str(&HexDisplay(bytes)) - } else { - serializer.serialize_bytes(bytes) - } - } - struct HexExpected; impl Expected for HexExpected { @@ -447,7 +377,7 @@ mod serde_impls { where S: serde_core::Serializer, { - serialize_lower(bytes, serializer) + HexSlice::new(bytes).serialize(serializer) } /// Deserializes a byte array from hex if the format is @@ -486,7 +416,7 @@ mod serde_impls { where S: serde_core::Serializer, { - serialize_lower(&self.0, serializer) + HexSlice::new(&self.0).serialize(serializer) } } diff --git a/src/hex_slice.rs b/src/hex_slice.rs new file mode 100644 index 0000000..fb9a7a0 --- /dev/null +++ b/src/hex_slice.rs @@ -0,0 +1,185 @@ +// Copyright (c) The byte-wrapper Contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! The [`HexSlice`] newtype wrapper. + +use core::fmt::{self, Write}; + +/// A byte slice that displays as hex. +/// +/// The borrowed, dynamically-sized equivalent to +/// [`HexVec`](crate::HexVec), akin to `str`'s relationship to `String`. +/// +/// `HexSlice` wraps `[u8]`, providing [`Display`](fmt::Display), +/// [`LowerHex`](fmt::LowerHex), and [`UpperHex`](fmt::UpperHex) +/// implementations that use hexadecimal encoding. +/// +/// With the **`serde`** feature enabled, it also implements +/// `Serialize` (hex strings in human-readable formats, raw bytes in +/// binary formats). +/// +/// # Examples +/// +/// ``` +/// use byte_wrapper::HexSlice; +/// +/// const BYTES: [u8; 4] = [0x01, 0x02, 0xab, 0xff]; +/// const H: &HexSlice = HexSlice::new(&BYTES); +/// assert_eq!(H.to_string(), "0102abff"); +/// ``` +#[derive(PartialEq, Eq, PartialOrd, Ord, Hash)] +#[repr(transparent)] +pub struct HexSlice([u8]); + +impl HexSlice { + /// Creates a new `HexSlice` from a byte slice. + #[inline] + #[must_use] + pub const fn new(bytes: &[u8]) -> &Self { + // SAFETY: + // + // * `repr(transparent)` gives `HexSlice` the layout and validity of + // `[u8]`. + // * An unsized-to-unsized pointer cast retains the length metadata and + // provenance. + // * The output borrow inherits the input's lifetime and shared-ness, + // and `HexSlice` has no interior mutability. + unsafe { &*(bytes as *const [u8] as *const Self) } + } + + /// Returns the inner byte slice. + #[inline] + #[must_use] + pub const fn as_bytes(&self) -> &[u8] { + &self.0 + } +} + +/// Formats a byte slice as lower-case hex, ignoring width and fill. +/// +/// This is used both for serialization (via `Display`) and as the +/// inner value in the hex types' `Debug` output (via `Debug`). +pub(crate) struct HexDisplay<'a>(pub(crate) &'a [u8]); + +impl fmt::Display for HexDisplay<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for byte in self.0 { + write!(f, "{byte:02x}")?; + } + Ok(()) + } +} + +impl fmt::Debug for HexDisplay<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, f) + } +} + +/// Writes hex bytes with padding/alignment support. +/// +/// `write_byte` is called for each byte in the slice to allow +/// callers to choose between lowercase and uppercase hex. +fn fmt_hex_padded( + bytes: &[u8], + f: &mut fmt::Formatter<'_>, + write_byte: fn(&mut fmt::Formatter<'_>, u8) -> fmt::Result, +) -> fmt::Result { + let content_len = bytes.len() * 2; + + match f.width() { + Some(width) if width > content_len => { + let padding = width - content_len; + let fill = f.fill(); + let (pre, post) = match f.align() { + Some(fmt::Alignment::Left) => (0, padding), + Some(fmt::Alignment::Right) | None => (padding, 0), + Some(fmt::Alignment::Center) => { + (padding / 2, padding - padding / 2) + } + }; + for _ in 0..pre { + f.write_char(fill)?; + } + for &byte in bytes { + write_byte(f, byte)?; + } + for _ in 0..post { + f.write_char(fill)?; + } + Ok(()) + } + Some(_) | None => { + for &byte in bytes { + write_byte(f, byte)?; + } + Ok(()) + } + } +} + +impl fmt::Debug for HexSlice { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("HexSlice").field(&HexDisplay(&self.0)).finish() + } +} + +impl fmt::Display for HexSlice { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::LowerHex::fmt(self, f) + } +} + +impl fmt::LowerHex for HexSlice { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt_hex_padded(&self.0, f, |f, b| write!(f, "{b:02x}")) + } +} + +impl fmt::UpperHex for HexSlice { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt_hex_padded(&self.0, f, |f, b| write!(f, "{b:02X}")) + } +} + +impl core::ops::Deref for HexSlice { + type Target = [u8]; + + #[inline] + fn deref(&self) -> &Self::Target { + &self.0 + } +} + +impl AsRef<[u8]> for HexSlice { + #[inline] + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +impl AsRef for HexSlice { + #[inline] + fn as_ref(&self) -> &HexSlice { + self + } +} + +#[cfg(feature = "serde")] +mod serde_impls { + use super::{HexDisplay, HexSlice}; + + #[cfg_attr(doc_cfg, doc(cfg(feature = "serde")))] + impl serde_core::Serialize for HexSlice { + fn serialize(&self, serializer: S) -> Result + where + S: serde_core::Serializer, + { + if serializer.is_human_readable() { + serializer.collect_str(&HexDisplay(&self.0)) + } else { + serializer.serialize_bytes(&self.0) + } + } + } +} diff --git a/src/hex_vec.rs b/src/hex_vec.rs new file mode 100644 index 0000000..273fae3 --- /dev/null +++ b/src/hex_vec.rs @@ -0,0 +1,354 @@ +// Copyright (c) The byte-wrapper Contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +//! The [`HexVec`] newtype wrapper. + +use crate::{ + HexSlice, ParseHexError, hex_array::decode_hex_into, hex_slice::HexDisplay, +}; +use alloc::{borrow::ToOwned, vec, vec::Vec}; +use core::{borrow::Borrow, fmt, str::FromStr}; + +/// A byte vector that displays and parses as hex. +/// +/// `HexVec` wraps `Vec`, providing [`Display`](fmt::Display), +/// [`FromStr`], [`LowerHex`](fmt::LowerHex), and +/// [`UpperHex`](fmt::UpperHex) implementations that use hexadecimal +/// encoding. +/// +/// With the **`serde`** feature enabled, it also implements +/// `Serialize` and `Deserialize` (hex strings in human-readable +/// formats, raw bytes in binary formats), and can be used with +/// `#[serde(with = "HexVec")]` on `Vec` fields. +/// +/// # Examples +/// +/// ``` +/// use byte_wrapper::HexVec; +/// +/// let h = HexVec::new(vec![0x01, 0x02, 0xab, 0xff]); +/// assert_eq!(h.to_string(), "0102abff"); +/// +/// let parsed: HexVec = "0102abff".parse().unwrap(); +/// assert_eq!(parsed, h); +/// ``` +#[must_use] +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +pub struct HexVec(pub Vec); + +impl HexVec { + /// Creates a new `HexVec` from a byte vector. + #[inline] + pub fn new(bytes: Vec) -> Self { + Self(bytes) + } + + /// Returns the inner byte vector. + #[inline] + #[must_use] + pub fn into_inner(self) -> Vec { + self.0 + } + + #[inline] + fn as_hex_slice(&self) -> &HexSlice { + HexSlice::new(&self.0) + } +} + +impl FromStr for HexVec { + type Err = ParseHexError; + + fn from_str(s: &str) -> Result { + if !s.len().is_multiple_of(2) { + return Err(ParseHexError::OddLength { actual: s.len() }); + } + let mut out = vec![0u8; s.len() / 2]; + decode_hex_into(s, &mut out)?; + Ok(Self(out)) + } +} + +impl fmt::Debug for HexVec { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_tuple("HexVec").field(&HexDisplay(&self.0)).finish() + } +} + +impl fmt::Display for HexVec { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self.as_hex_slice(), f) + } +} + +impl fmt::LowerHex for HexVec { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::LowerHex::fmt(self.as_hex_slice(), f) + } +} + +impl fmt::UpperHex for HexVec { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::UpperHex::fmt(self.as_hex_slice(), f) + } +} + +impl core::ops::Deref for HexVec { + type Target = HexSlice; + + #[inline] + fn deref(&self) -> &Self::Target { + self.as_hex_slice() + } +} + +impl Borrow for HexVec { + #[inline] + fn borrow(&self) -> &HexSlice { + self.as_hex_slice() + } +} + +impl ToOwned for HexSlice { + type Owned = HexVec; + + #[inline] + fn to_owned(&self) -> Self::Owned { + HexVec(self.as_bytes().to_vec()) + } +} + +impl AsRef for HexVec { + #[inline] + fn as_ref(&self) -> &HexSlice { + self.as_hex_slice() + } +} + +impl AsRef<[u8]> for HexVec { + #[inline] + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +impl AsMut<[u8]> for HexVec { + #[inline] + fn as_mut(&mut self) -> &mut [u8] { + &mut self.0 + } +} + +impl From> for HexVec { + #[inline] + fn from(bytes: Vec) -> Self { + Self(bytes) + } +} + +impl From for Vec { + #[inline] + fn from(hex_vec: HexVec) -> Self { + hex_vec.0 + } +} + +impl PartialEq for HexVec { + #[inline] + fn eq(&self, other: &HexSlice) -> bool { + self.as_hex_slice() == other + } +} + +impl PartialEq<&HexSlice> for HexVec { + #[inline] + fn eq(&self, other: &&HexSlice) -> bool { + self.as_hex_slice() == *other + } +} + +impl PartialEq for HexSlice { + #[inline] + fn eq(&self, other: &HexVec) -> bool { + self == other.as_hex_slice() + } +} + +impl PartialEq for &HexSlice { + #[inline] + fn eq(&self, other: &HexVec) -> bool { + *self == other.as_hex_slice() + } +} + +#[cfg(feature = "serde")] +mod serde_impls { + use super::HexVec; + use crate::HexSlice; + use alloc::vec::Vec; + use core::fmt; + use serde_core::{Deserializer, Serialize, Serializer, de::Visitor}; + + fn deserialize_hex_vec<'de, D>(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + use serde_core::de::Error; + + if deserializer.is_human_readable() { + struct HexVecVisitor; + + impl<'de2> Visitor<'de2> for HexVecVisitor { + type Value = HexVec; + + fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "a hex string with an even number of hex digits") + } + + fn visit_str(self, data: &str) -> Result + where + E: Error, + { + data.parse().map_err(Error::custom) + } + } + + deserializer.deserialize_str(HexVecVisitor) + } else { + crate::serde_util::deserialize_byte_buf(deserializer).map(HexVec) + } + } + + impl HexVec { + /// Serializes a byte vector as hex if the format is + /// human-readable, or as raw bytes otherwise. + /// + /// Intended for use with `#[serde(with = "HexVec")]`. + /// + /// # Examples + /// + /// ``` + /// use byte_wrapper::HexVec; + /// use serde::{Deserialize, Serialize}; + /// + /// #[derive(Serialize, Deserialize)] + /// struct Blob { + /// #[serde(with = "HexVec")] + /// data: Vec, + /// } + /// + /// let b = Blob { data: vec![1, 2, 3] }; + /// let json = serde_json::to_string(&b).unwrap(); + /// assert_eq!(json, r#"{"data":"010203"}"#); + /// ``` + #[cfg_attr(doc_cfg, doc(cfg(feature = "serde")))] + pub fn serialize( + bytes: &[u8], + serializer: S, + ) -> Result + where + S: Serializer, + { + HexSlice::new(bytes).serialize(serializer) + } + + /// Deserializes a byte vector from hex if the format is + /// human-readable, or as raw bytes otherwise. + /// + /// Intended for use with `#[serde(with = "HexVec")]`. + /// + /// # Examples + /// + /// ``` + /// use byte_wrapper::HexVec; + /// use serde::{Deserialize, Serialize}; + /// + /// #[derive(Serialize, Deserialize)] + /// struct Blob { + /// #[serde(with = "HexVec")] + /// data: Vec, + /// } + /// + /// let b: Blob = serde_json::from_str(r#"{"data":"010203"}"#).unwrap(); + /// assert_eq!(b.data, [1, 2, 3]); + /// ``` + #[cfg_attr(doc_cfg, doc(cfg(feature = "serde")))] + pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> + where + D: Deserializer<'de>, + { + deserialize_hex_vec(deserializer).map(HexVec::into_inner) + } + } + + #[cfg_attr(doc_cfg, doc(cfg(feature = "serde")))] + impl serde_core::Serialize for HexVec { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + self.as_hex_slice().serialize(serializer) + } + } + + #[cfg_attr(doc_cfg, doc(cfg(feature = "serde")))] + impl<'de> serde_core::Deserialize<'de> for HexVec { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserialize_hex_vec(deserializer) + } + } +} + +#[cfg(feature = "schemars08")] +mod schemars_impls { + use super::HexVec; + use crate::{HexSlice, schemars_util::x_rust_type_extension}; + use alloc::{boxed::Box, string::String}; + use schemars08::{ + JsonSchema, + r#gen::SchemaGenerator, + schema::{InstanceType, Schema, SchemaObject, StringValidation}, + }; + + impl JsonSchema for HexVec { + fn schema_name() -> String { + "HexVec".into() + } + + fn is_referenceable() -> bool { + false + } + + fn json_schema(_generator: &mut SchemaGenerator) -> Schema { + Schema::Object(SchemaObject { + instance_type: Some(InstanceType::String.into()), + string: Some(Box::new(StringValidation { + pattern: Some("^([0-9a-fA-F]{2})*$".into()), + ..Default::default() + })), + extensions: x_rust_type_extension("HexVec"), + ..Default::default() + }) + } + } + + // HexSlice shares HexVec's schema (including `x-rust-type` set to + // `HexVec`) since generated code needs owned types. (This also covers + // `Cow<'_, HexSlice>`.) + impl JsonSchema for HexSlice { + fn schema_name() -> String { + HexVec::schema_name() + } + + fn is_referenceable() -> bool { + HexVec::is_referenceable() + } + + fn json_schema(generator: &mut SchemaGenerator) -> Schema { + HexVec::json_schema(generator) + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 8d0f752..2668585 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -26,6 +26,10 @@ //! //! * [`HexArray`] encodes a fixed-length byte array as a hex //! string. (Requires the `hex` feature.) +//! * [`HexSlice`] encodes a borrowed byte slice as a hex string. +//! (Requires the `hex` feature.) +//! * [`HexVec`] encodes a variable-length byte vector as a hex +//! string. (Requires the `hex` and `alloc` features.) //! * [`Base64Vec`] encodes a variable-length byte vector as a base64 //! string. (Requires the `base64` and `alloc` features.) //! * [`Base64Slice`] encodes a borrowed byte slice as a base64 string. @@ -121,11 +125,12 @@ //! //! # Features //! -//! - **`hex`**: enables [`HexArray`]. *Enabled by default.* +//! - **`hex`**: enables [`HexArray`] and [`HexSlice`], and [`HexVec`] +//! together with `alloc`. *Enabled by default.* //! - **`base64`**: enables [`Base64Slice`], and [`Base64Vec`] together with //! `alloc`. *Enabled by default.* -//! - **`alloc`**: enables `alloc` support (required by [`Base64Vec`]). -//! *Enabled by default.* +//! - **`alloc`**: enables `alloc` support (required by [`Base64Vec`] and +//! [`HexVec`]). *Enabled by default.* //! - **`serde`**: implements `Serialize` and `Deserialize` for //! enabled types. *Not enabled by default.* //! - **`schemars08`**: derives `JsonSchema` for enabled types. @@ -146,9 +151,17 @@ mod base64_slice; mod base64_vec; #[cfg(feature = "hex")] mod hex_array; +#[cfg(feature = "hex")] +mod hex_slice; +#[cfg(all(feature = "hex", feature = "alloc"))] +mod hex_vec; #[cfg(all(feature = "schemars08", any(feature = "base64", feature = "hex")))] mod schemars_util; -#[cfg(all(feature = "serde", feature = "base64", feature = "alloc"))] +#[cfg(all( + feature = "serde", + feature = "alloc", + any(feature = "base64", feature = "hex") +))] mod serde_util; #[cfg(feature = "base64")] @@ -157,3 +170,7 @@ pub use base64_slice::Base64Slice; pub use base64_vec::{Base64Vec, ParseBase64Error}; #[cfg(feature = "hex")] pub use hex_array::{HexArray, ParseHexError}; +#[cfg(feature = "hex")] +pub use hex_slice::HexSlice; +#[cfg(all(feature = "hex", feature = "alloc"))] +pub use hex_vec::HexVec; diff --git a/tests/integration/hex_vec.rs b/tests/integration/hex_vec.rs new file mode 100644 index 0000000..ff56262 --- /dev/null +++ b/tests/integration/hex_vec.rs @@ -0,0 +1,305 @@ +// Copyright (c) The byte-wrapper Contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use crate::helpers::draw_related_bytes; +use byte_wrapper::{HexSlice, HexVec, ParseHexError}; +use hegel::{TestCase, generators as gs}; +use std::{ + borrow::{Borrow, Cow}, + collections::{BTreeMap, HashMap}, + hash::{BuildHasher, RandomState}, +}; + +const BYTES: [u8; 4] = [0x01, 0x02, 0xab, 0xff]; +const CONST_SLICE: &HexSlice = HexSlice::new(&BYTES); +const CONST_EMPTY: &HexSlice = HexSlice::new(&[]); +const CONST_COW: Cow<'static, HexSlice> = Cow::Borrowed(HexSlice::new(&BYTES)); +static STATIC_SLICE: &HexSlice = HexSlice::new(&BYTES); + +#[test] +fn hex_slice_new_preserves_bytes() { + assert_eq!(CONST_SLICE.as_bytes(), &BYTES); + assert_eq!(CONST_SLICE.len(), 4); + assert_eq!(STATIC_SLICE.as_bytes(), &BYTES); + + assert_eq!(CONST_EMPTY.as_bytes(), &[] as &[u8]); + assert!(CONST_EMPTY.is_empty()); + + let backing = [0u8, 1, 2, 3, 4, 5]; + let middle = HexSlice::new(&backing[2..5]); + assert_eq!(middle.as_bytes(), &[2, 3, 4]); + assert!(std::ptr::eq(middle.as_bytes(), &backing[2..5])); +} + +#[test] +fn hex_slice_const_matches_parsed() { + let parsed: HexVec = "0102abff".parse().expect("parsed"); + assert_eq!(parsed, CONST_SLICE); + assert_eq!(CONST_SLICE, parsed); + assert_eq!(*parsed, *CONST_SLICE); + assert_eq!(CONST_COW, Cow::::Owned(parsed)); +} + +// -- FromStr tests -- + +#[test] +fn hex_vec_from_str() { + let h: HexVec = "01020304".parse().expect("parsed"); + assert_eq!(h, HexVec::new(vec![0x01, 0x02, 0x03, 0x04])); + + // Uppercase input. + let h: HexVec = "ABCD".parse().expect("parsed uppercase"); + assert_eq!(h, HexVec::new(vec![0xab, 0xcd])); + assert_eq!(h.to_string(), "abcd"); + + // Mixed case. + let h: HexVec = "aBcD".parse().expect("parsed mixed case"); + assert_eq!(h, HexVec::new(vec![0xab, 0xcd])); + + // Empty. + let h: HexVec = "".parse().expect("parsed empty"); + assert_eq!(h, HexVec::new(vec![])); + assert!(h.is_empty()); +} + +#[test] +fn hex_vec_from_str_roundtrip() { + for bytes in + [vec![], vec![0x00], vec![0xff, 0x00, 0x7f], (0..=255).collect()] + { + let h = HexVec::new(bytes); + let s = h.to_string(); + assert_eq!(s.len(), h.len() * 2); + let parsed: HexVec = s.parse().expect("parsed Display output"); + assert_eq!(parsed, h, "roundtrip of {s}"); + } +} + +#[test] +fn hex_vec_from_str_odd_length() { + for (input, actual) in [("0", 1), ("012", 3), ("0g1", 3)] { + let err = input.parse::().expect_err("odd length"); + assert_eq!(err, ParseHexError::OddLength { actual }, "input {input}"); + } + + let err = "012".parse::().expect_err("odd length"); + assert_eq!( + err.to_string(), + "expected an even number of hex characters, got 3", + ); +} + +#[test] +fn hex_vec_from_str_invalid_chars() { + let err = "0g020304".parse::().expect_err("invalid hex char"); + assert_eq!(err, ParseHexError::InvalidHexCharacter { c: 'g', index: 1 }); + assert!(err.to_string().contains("'g'"), "got: {err}"); + + let err = "01 2".parse::().expect_err("space"); + assert_eq!(err, ParseHexError::InvalidHexCharacter { c: ' ', index: 2 }); + + let err = "0x01".parse::().expect_err("0x prefix"); + assert_eq!(err, ParseHexError::InvalidHexCharacter { c: 'x', index: 1 }); +} + +// -- Display and Debug formatting tests -- + +#[test] +fn hex_vec_display() { + let h = HexVec::new(vec![0x01, 0x02, 0xab, 0xff]); + assert_eq!(format!("{h}"), "0102abff"); + assert_eq!(format!("{}", &*h), "0102abff"); + assert_eq!(format!("{CONST_SLICE}"), "0102abff"); + + // Empty. + let empty = HexVec::default(); + assert_eq!(format!("{empty}"), ""); + assert_eq!(format!("{CONST_EMPTY}"), ""); + + let h = HexVec::new(vec![0xab, 0xcd]); + let s = HexSlice::new(&[0xab, 0xcd]); + + // Right alignment (the default). + assert_eq!(format!("{h:>10}"), " abcd"); + assert_eq!(format!("{s:10}"), " abcd"); + + // Left alignment. + assert_eq!(format!("{h:<10}"), "abcd "); + assert_eq!(format!("{s:<10}"), "abcd "); + + // Center alignment (even and odd padding). + assert_eq!(format!("{h:^10}"), " abcd "); + assert_eq!(format!("{s:^9}"), " abcd "); + + // Custom fill character. + assert_eq!(format!("{h:_>10}"), "______abcd"); + + // Width smaller than or equal to content: no truncation. + assert_eq!(format!("{h:2}"), "abcd"); + assert_eq!(format!("{s:4}"), "abcd"); +} + +#[test] +fn hex_vec_lower_upper_hex() { + let h = HexVec::new(vec![0x01, 0xab, 0xff]); + assert_eq!(format!("{h:x}"), "01abff"); + assert_eq!(format!("{h:X}"), "01ABFF"); + assert_eq!(format!("{h:_^10X}"), "__01ABFF__"); + assert_eq!(format!("{:x}", &*h), "01abff"); + assert_eq!(format!("{:>10X}", &*h), " 01ABFF"); +} + +#[test] +fn hex_vec_debug() { + let h = HexVec::new(vec![0x01, 0x02, 0xab, 0xff]); + assert_eq!(format!("{h:?}"), "HexVec(0102abff)"); + assert_eq!(format!("{CONST_SLICE:?}"), "HexSlice(0102abff)"); + + // Empty. + assert_eq!(format!("{:?}", HexVec::default()), "HexVec()"); + assert_eq!(format!("{CONST_EMPTY:?}"), "HexSlice()"); + + // Alternate flag. + let h = HexVec::new(vec![0xab, 0xcd]); + assert_eq!(format!("{h:#?}"), "HexVec(\n abcd,\n)"); +} + +#[test] +fn hex_vec_conversions() { + let h = HexVec::from(vec![1, 2, 3]); + let slice: &HexSlice = &h; + assert_eq!(slice.as_bytes(), &[1, 2, 3]); + assert_eq!(AsRef::<[u8]>::as_ref(&h), &[1, 2, 3]); + assert_eq!(AsRef::::as_ref(&h), slice); + assert_eq!(AsRef::<[u8]>::as_ref(slice), &[1, 2, 3]); + + let owned: HexVec = slice.to_owned(); + assert_eq!(owned, h); + + let mut h = h; + h.as_mut()[0] = 0xff; + h.0.push(4); + assert_eq!(h.to_string(), "ff020304"); + assert_eq!(Vec::from(h.clone()), vec![0xff, 2, 3, 4]); + assert_eq!(h.into_inner(), vec![0xff, 2, 3, 4]); +} + +#[test] +fn hex_slice_cow() { + let borrowed: Cow<'static, HexSlice> = Cow::Borrowed(CONST_SLICE); + let owned: Cow<'static, HexSlice> = + Cow::Owned("0102ABFF".parse().expect("parsed")); + assert_eq!(borrowed, owned); + assert_eq!(borrowed.to_string(), owned.to_string()); + assert_eq!(borrowed.clone().into_owned(), owned.clone().into_owned()); + + let other: Cow<'static, HexSlice> = Cow::Owned(HexVec::new(vec![0x01])); + assert_ne!(borrowed, other); +} + +#[test] +fn hex_slice_map_lookup() { + // `HexSlice` lets maps keyed by `HexVec` be queried with `&HexSlice`. + let mut map = HashMap::new(); + map.insert(HexVec::new(BYTES.to_vec()), "four"); + map.insert(HexVec::default(), "empty"); + assert_eq!(map.get(CONST_SLICE), Some(&"four")); + assert_eq!(map.get(CONST_EMPTY), Some(&"empty")); + assert_eq!(map.get(HexSlice::new(&[0x01])), None); + + let mut btree = BTreeMap::new(); + btree.insert(HexVec::new(vec![0x02]), 2); + btree.insert(HexVec::new(vec![0x01, 0xff]), 1); + btree.insert(HexVec::new(vec![]), 0); + assert_eq!(btree.get(HexSlice::new(&[0x01, 0xff])), Some(&1)); + let keys: Vec = btree.keys().map(|k| k.to_string()).collect(); + assert_eq!(keys, ["", "01ff", "02"]); +} + +fn draw_pair(tc: &TestCase) -> (HexVec, HexVec) { + let (a, b) = draw_related_bytes(tc); + (HexVec::new(a), HexVec::new(b)) +} + +#[hegel::test] +fn borrow_preserves_eq(tc: TestCase) { + let (a, b) = draw_pair(&tc); + let (a_slice, b_slice): (&HexSlice, &HexSlice) = (a.borrow(), b.borrow()); + assert_eq!(a == b, a_slice == b_slice); +} + +#[hegel::test] +fn borrow_preserves_ord(tc: TestCase) { + let (a, b) = draw_pair(&tc); + let (a_slice, b_slice): (&HexSlice, &HexSlice) = (a.borrow(), b.borrow()); + assert_eq!(a.cmp(&b), a_slice.cmp(b_slice)); +} + +#[hegel::test] +fn borrow_preserves_hash(tc: TestCase) { + let v = HexVec::new(tc.draw(gs::binary())); + let slice: &HexSlice = v.borrow(); + let state = RandomState::new(); + assert_eq!(state.hash_one(&v), state.hash_one(slice)); +} + +#[hegel::test] +fn to_owned_round_trips(tc: TestCase) { + let bytes = tc.draw(gs::binary()); + let slice = HexSlice::new(&bytes); + let owned: HexVec = slice.to_owned(); + let reborrowed: &HexSlice = owned.borrow(); + assert_eq!(reborrowed, slice); + assert_eq!(owned.as_bytes(), bytes.as_slice()); +} + +#[hegel::test] +fn owned_and_borrowed_formatting_agree(tc: TestCase) { + let v = HexVec::new(tc.draw(gs::binary())); + let slice: &HexSlice = v.borrow(); + assert_eq!(v.to_string(), slice.to_string()); + assert_eq!(format!("{v:x}"), format!("{slice:x}")); + assert_eq!(format!("{v:X}"), format!("{slice:X}")); +} + +#[hegel::test] +fn from_str_inverts_display(tc: TestCase) { + let v = HexVec::new(tc.draw(gs::binary())); + let parsed: HexVec = v.to_string().parse().expect("Display output parses"); + assert_eq!(parsed, v); +} + +#[hegel::test] +fn from_str_accepts_uppercase(tc: TestCase) { + let v = HexVec::new(tc.draw(gs::binary())); + let parsed: HexVec = + format!("{v:X}").parse().expect("UpperHex output parses"); + assert_eq!(parsed, v); +} + +#[hegel::test] +fn from_str_rejects_odd_length(tc: TestCase) { + let v = HexVec::new(tc.draw(gs::binary())); + let mut encoded = v.to_string(); + encoded.push(tc.draw(gs::sampled_from(vec!['0', 'a', 'F']))); + assert_eq!( + encoded.parse::(), + Err(ParseHexError::OddLength { actual: encoded.len() }), + ); +} + +#[hegel::test] +fn from_str_rejects_non_hex_character(tc: TestCase) { + let v = HexVec::new(tc.draw(gs::binary().min_size(1))); + let encoded = v.to_string(); + let index = tc.draw(gs::integers::().max_value(encoded.len() - 1)); + let c = tc.draw(gs::sampled_from(vec!['g', 'G', 'x', ' ', '-', '+', '\n'])); + let mut corrupted = encoded.into_bytes(); + corrupted[index] = c as u8; + let corrupted = + String::from_utf8(corrupted).expect("ASCII stays valid UTF-8"); + assert_eq!( + corrupted.parse::(), + Err(ParseHexError::InvalidHexCharacter { c, index }), + ); +} diff --git a/tests/integration/hex_vec_schemars.rs b/tests/integration/hex_vec_schemars.rs new file mode 100644 index 0000000..c121e4e --- /dev/null +++ b/tests/integration/hex_vec_schemars.rs @@ -0,0 +1,79 @@ +// Copyright (c) The byte-wrapper Contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use byte_wrapper::{HexSlice, HexVec}; +use schemars08::{self as schemars, JsonSchema, schema_for}; +use std::borrow::Cow; + +fn hex_vec_property() -> serde_json::Value { + serde_json::json!({ + "type": "string", + "pattern": "^([0-9a-fA-F]{2})*$", + "x-rust-type": { + "crate": "byte-wrapper", + "version": "0.1.0", + "path": "byte_wrapper::HexVec" + } + }) +} + +#[test] +fn hex_vec_schema() { + let schema = schema_for!(HexVec); + let actual = serde_json::to_value(&schema).expect("serialized"); + let mut expected = hex_vec_property(); + let object = expected.as_object_mut().expect("property is an object"); + object.insert( + "$schema".into(), + "http://json-schema.org/draft-07/schema#".into(), + ); + object.insert("title".into(), "HexVec".into()); + assert_eq!(actual, expected); + + let slice_schema = schema_for!(HexSlice); + assert_eq!( + serde_json::to_value(&slice_schema).expect("serialized"), + actual, + "HexSlice and HexVec share a schema", + ); +} + +#[expect(dead_code)] +#[derive(JsonSchema)] +struct WithHexVecAttr { + #[schemars(with = "HexVec")] + data: Vec, +} + +#[expect(dead_code)] +#[derive(JsonSchema)] +struct WithHexVecDirect { + data: HexVec, +} + +#[expect(dead_code)] +#[derive(JsonSchema)] +struct WithHexSliceCow { + data: Cow<'static, HexSlice>, +} + +#[test] +fn with_hex_vec_fields() { + for (title, schema) in [ + ("WithHexVecAttr", schema_for!(WithHexVecAttr)), + ("WithHexVecDirect", schema_for!(WithHexVecDirect)), + ("WithHexSliceCow", schema_for!(WithHexSliceCow)), + ] { + let actual = serde_json::to_value(&schema).expect("serialized"); + let expected = serde_json::json!({ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": title, + "type": "object", + "required": ["data"], + "properties": { + "data": hex_vec_property(), + } + }); + assert_eq!(actual, expected, "schema for {title}"); + } +} diff --git a/tests/integration/hex_vec_serde.rs b/tests/integration/hex_vec_serde.rs new file mode 100644 index 0000000..2ba6bda --- /dev/null +++ b/tests/integration/hex_vec_serde.rs @@ -0,0 +1,228 @@ +// Copyright (c) The byte-wrapper Contributors +// SPDX-License-Identifier: MIT OR Apache-2.0 + +use byte_wrapper::{HexSlice, HexVec}; +use hex_literal::hex; +use serde::{Deserialize, Serialize}; +use std::borrow::Cow; + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +struct WithHexVecAttr { + #[serde(with = "HexVec")] + data: Vec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +struct WithHexVecDirect { + data: HexVec, +} + +#[derive(Clone, Debug, Eq, PartialEq, Deserialize, Serialize)] +struct WithHexSliceCow { + data: Cow<'static, HexSlice>, +} + +#[derive(Clone, Debug, Eq, PartialEq, Serialize)] +struct WithHexSliceRef<'a> { + data: &'a HexSlice, +} + +static FIXTURE: [u8; 16] = hex!("0123456789abcdef0123456789abcdef"); +const FIXTURE_SLICE: &HexSlice = HexSlice::new(&FIXTURE); + +static AS_JSON: &str = r#"{"data":"0123456789abcdef0123456789abcdef"}"#; +static AS_JSON_UPPER: &str = r#"{"data":"0123456789ABCDEF0123456789ABCDEF"}"#; +static AS_CBOR: [u8; 23] = + hex!("a1646461746150 0123456789abcdef0123456789abcdef"); + +#[test] +fn hex_vec_serde_roundtrip() { + let fixture = WithHexVecAttr { data: FIXTURE.to_vec() }; + + let json = + serde_json::to_string(&fixture).expect("serializing as JSON succeeded"); + assert_eq!(json, AS_JSON, "JSON matched"); + + let json_roundtrip: WithHexVecAttr = serde_json::from_str(&json) + .expect("JSON roundtrip deserialization succeeded"); + assert_eq!(fixture, json_roundtrip, "JSON roundtrip matched"); + + let mut cbor = Vec::new(); + ciborium::ser::into_writer(&fixture, &mut cbor) + .expect("serializing as CBOR succeeded"); + assert_eq!(cbor, AS_CBOR, "CBOR matched"); + + let cbor_roundtrip: WithHexVecAttr = ciborium::de::from_reader(&cbor[..]) + .expect("CBOR roundtrip deserialization succeeded"); + assert_eq!(fixture, cbor_roundtrip, "CBOR roundtrip matched"); +} + +#[test] +fn hex_vec_direct() { + let fixture = WithHexVecDirect { data: HexVec::new(FIXTURE.to_vec()) }; + + let json = serde_json::to_string(&fixture).expect("serialized"); + assert_eq!(json, AS_JSON); + + let roundtrip: WithHexVecDirect = + serde_json::from_str(&json).expect("deserialized"); + assert_eq!(fixture, roundtrip); + + let mut cbor = Vec::new(); + ciborium::ser::into_writer(&fixture, &mut cbor).expect("serialized CBOR"); + assert_eq!(cbor, AS_CBOR); + let roundtrip: WithHexVecDirect = + ciborium::de::from_reader(&cbor[..]).expect("deserialized CBOR"); + assert_eq!(fixture, roundtrip); +} + +#[test] +fn hex_vec_serde_empty() { + let fixture = WithHexVecDirect { data: HexVec::default() }; + let json = serde_json::to_string(&fixture).expect("serialized"); + assert_eq!(json, r#"{"data":""}"#); + let roundtrip: WithHexVecDirect = + serde_json::from_str(&json).expect("deserialized"); + assert_eq!(fixture, roundtrip); + + let attr: WithHexVecAttr = + serde_json::from_str(r#"{"data":""}"#).expect("deserialized attr"); + assert_eq!(attr, WithHexVecAttr { data: vec![] }); +} + +#[test] +fn hex_vec_serde_uppercase() { + let direct: WithHexVecDirect = + serde_json::from_str(AS_JSON_UPPER).expect("deserialized uppercase"); + assert_eq!(direct.data, FIXTURE_SLICE); + assert_eq!( + serde_json::to_string(&direct).expect("serialized"), + AS_JSON, + "serialization is lowercase", + ); + + let attr: WithHexVecAttr = serde_json::from_str(AS_JSON_UPPER) + .expect("deserialized uppercase with attr"); + assert_eq!(attr.data, FIXTURE); +} + +#[test] +fn hex_vec_serde_rejects_invalid() { + for (json, needle) in [ + (r#"{"data":"012"}"#, "expected an even number of hex characters"), + (r#"{"data":"0g"}"#, "invalid hex character 'g' at index 1"), + ] { + let err = serde_json::from_str::(json) + .expect_err("invalid hex rejected"); + assert!(err.to_string().contains(needle), "{json}: got {err}"); + + let err = serde_json::from_str::(json) + .expect_err("invalid hex rejected with attr"); + assert!(err.to_string().contains(needle), "{json}: got {err}"); + + let err = serde_json::from_str::(json) + .expect_err("invalid hex rejected for Cow"); + assert!(err.to_string().contains(needle), "{json}: got {err}"); + } +} + +#[test] +fn hex_vec_json_array_rejected() { + let json = r#"{"data":[1,2,3,4]}"#; + + let err = serde_json::from_str::(json) + .expect_err("JSON array should not deserialize as HexVec"); + let msg = err.to_string(); + assert!(msg.contains("hex string"), "got: {msg}"); + + let err = serde_json::from_str::(json) + .expect_err("JSON array should not deserialize as direct HexVec"); + let msg = err.to_string(); + assert!(msg.contains("hex string"), "got: {msg}"); +} + +#[test] +fn hex_vec_deserialize_from_seq() { + let cbor_array = hex!("a1 6464617461 84 01020304"); + + let direct: WithHexVecDirect = ciborium::de::from_reader(&cbor_array[..]) + .expect("deserialized HexVec from CBOR array"); + assert_eq!(direct.data, HexVec::new(vec![1, 2, 3, 4])); + + let with_attr: WithHexVecAttr = ciborium::de::from_reader(&cbor_array[..]) + .expect("deserialized Vec with HexVec from CBOR array"); + assert_eq!(with_attr.data, [1, 2, 3, 4]); +} + +#[test] +fn hex_slice_serialize() { + let fixture = WithHexSliceRef { data: FIXTURE_SLICE }; + let json = serde_json::to_string(&fixture).expect("serialized"); + assert_eq!(json, AS_JSON); + + let mut cbor = Vec::new(); + ciborium::ser::into_writer(&fixture, &mut cbor).expect("serialized CBOR"); + assert_eq!(cbor, AS_CBOR); + + let empty = serde_json::to_string(HexSlice::new(&[])).expect("serialized"); + assert_eq!(empty, r#""""#); +} + +#[test] +fn hex_slice_cow_serde() { + let borrowed = WithHexSliceCow { data: Cow::Borrowed(FIXTURE_SLICE) }; + let json = serde_json::to_string(&borrowed).expect("serialized"); + assert_eq!(json, AS_JSON); + + let deserialized: WithHexSliceCow = + serde_json::from_str(AS_JSON_UPPER).expect("deserialized Cow"); + assert!( + matches!(deserialized.data, Cow::Owned(_)), + "Cow<'static, HexSlice> deserializes to the owned variant", + ); + assert_eq!(deserialized, borrowed); + + let top_level: Cow<'static, HexSlice> = + serde_json::from_str(r#""abcd""#).expect("deserialized bare Cow"); + assert_eq!(top_level, Cow::Borrowed(HexSlice::new(&[0xab, 0xcd]))); + + let mut cbor = Vec::new(); + ciborium::ser::into_writer(&borrowed, &mut cbor).expect("serialized CBOR"); + assert_eq!(cbor, AS_CBOR); + let roundtrip: WithHexSliceCow = + ciborium::de::from_reader(&cbor[..]).expect("deserialized CBOR"); + assert_eq!(roundtrip, borrowed); +} + +/// Tests that deserializing large byte buffers works with ciborium. +/// +/// ciborium's deserialize_bytes only accepts definite-length byte strings that +/// fit its 4096-byte scratch buffer, so we must call `deserialize_byte_buf` +/// instead. +#[test] +fn hex_vec_cbor_large_and_indefinite_length() { + let large: Vec = (0..=255).cycle().take(4097).collect(); + let attr = WithHexVecAttr { data: large.clone() }; + let direct = WithHexVecDirect { data: HexVec::new(large) }; + + let mut cbor = Vec::new(); + ciborium::ser::into_writer(&attr, &mut cbor) + .expect("serializing large value as CBOR succeeded"); + let attr_roundtrip: WithHexVecAttr = ciborium::de::from_reader(&cbor[..]) + .expect("large CBOR byte string deserialized via attr"); + assert_eq!(attr, attr_roundtrip); + let direct_roundtrip: WithHexVecDirect = + ciborium::de::from_reader(&cbor[..]) + .expect("large CBOR byte string deserialized directly"); + assert_eq!(direct, direct_roundtrip); + + // This is the CBOR encoding for {"data": (_ h'0102' h'03')}, an + // indefinite-length byte string split into two chunks. + let indefinite = hex!("a1 6464617461 5f 42 0102 41 03 ff"); + let attr: WithHexVecAttr = ciborium::de::from_reader(&indefinite[..]) + .expect("indefinite-length CBOR byte string deserialized via attr"); + assert_eq!(attr.data, [1, 2, 3]); + let direct: WithHexVecDirect = ciborium::de::from_reader(&indefinite[..]) + .expect("indefinite-length CBOR byte string deserialized directly"); + assert_eq!(direct.data, HexVec::new(vec![1, 2, 3])); +} diff --git a/tests/integration/main.rs b/tests/integration/main.rs index b379797..0cbec69 100644 --- a/tests/integration/main.rs +++ b/tests/integration/main.rs @@ -13,7 +13,7 @@ mod base64_slice; mod base64_slice_fmt; #[cfg(all(feature = "base64", feature = "alloc", feature = "serde"))] mod base64_slice_serde; -#[cfg(all(feature = "base64", feature = "alloc"))] +#[cfg(all(feature = "alloc", any(feature = "base64", feature = "hex")))] mod helpers; #[cfg(feature = "hex")] mod hex; @@ -21,5 +21,11 @@ mod hex; mod hex_schemars; #[cfg(all(feature = "hex", feature = "serde"))] mod hex_serde; +#[cfg(all(feature = "hex", feature = "alloc"))] +mod hex_vec; +#[cfg(all(feature = "schemars08", feature = "hex"))] +mod hex_vec_schemars; +#[cfg(all(feature = "hex", feature = "alloc", feature = "serde"))] +mod hex_vec_serde; #[cfg(feature = "schemars08")] mod typify_roundtrip; diff --git a/tests/integration/typify_roundtrip.rs b/tests/integration/typify_roundtrip.rs index 1ffa343..acb5c3a 100644 --- a/tests/integration/typify_roundtrip.rs +++ b/tests/integration/typify_roundtrip.rs @@ -59,8 +59,9 @@ mod base64_tests { #[cfg(feature = "hex")] mod hex_tests { - use byte_wrapper::HexArray; + use byte_wrapper::{HexArray, HexSlice, HexVec}; use schemars08::{self as schemars, JsonSchema, schema_for}; + use std::borrow::Cow; #[expect(dead_code)] #[derive(JsonSchema)] @@ -92,6 +93,52 @@ mod hex_tests { &code, ); } + + #[expect(dead_code)] + #[derive(JsonSchema)] + struct HasHexVecDirect { + data: HexVec, + } + + #[test] + fn hex_vec_direct() { + let code = super::typify_output(&schema_for!(HasHexVecDirect)); + expectorate::assert_contents( + "tests/output/typify-hex-vec-direct.rs", + &code, + ); + } + + #[expect(dead_code)] + #[derive(JsonSchema)] + struct HasHexVecWithAttr { + #[schemars(with = "HexVec")] + data: Vec, + } + + #[test] + fn hex_vec_with_attr() { + let code = super::typify_output(&schema_for!(HasHexVecWithAttr)); + expectorate::assert_contents( + "tests/output/typify-hex-vec-with-attr.rs", + &code, + ); + } + + #[expect(dead_code)] + #[derive(JsonSchema)] + struct HasHexSliceCow { + data: Cow<'static, HexSlice>, + } + + #[test] + fn hex_slice_cow() { + let code = super::typify_output(&schema_for!(HasHexSliceCow)); + expectorate::assert_contents( + "tests/output/typify-hex-slice-cow.rs", + &code, + ); + } } /// Feed a root schema into typify with byte-wrapper configured as a diff --git a/tests/output/typify-hex-slice-cow.rs b/tests/output/typify-hex-slice-cow.rs new file mode 100644 index 0000000..60bf185 --- /dev/null +++ b/tests/output/typify-hex-slice-cow.rs @@ -0,0 +1,61 @@ +/// Error types. +pub mod error { + /// Error from a `TryFrom` or `FromStr` implementation. + pub struct ConversionError(::std::borrow::Cow<'static, str>); + impl ::std::error::Error for ConversionError {} + impl ::std::fmt::Display for ConversionError { + fn fmt( + &self, + f: &mut ::std::fmt::Formatter<'_>, + ) -> Result<(), ::std::fmt::Error> { + ::std::fmt::Display::fmt(&self.0, f) + } + } + impl ::std::fmt::Debug for ConversionError { + fn fmt( + &self, + f: &mut ::std::fmt::Formatter<'_>, + ) -> Result<(), ::std::fmt::Error> { + ::std::fmt::Debug::fmt(&self.0, f) + } + } + impl From<&'static str> for ConversionError { + fn from(value: &'static str) -> Self { + Self(value.into()) + } + } + impl From for ConversionError { + fn from(value: String) -> Self { + Self(value.into()) + } + } +} +///`HasHexSliceCow` +/// +///
JSON schema +/// +/// ```json +///{ +/// "title": "HasHexSliceCow", +/// "type": "object", +/// "required": [ +/// "data" +/// ], +/// "properties": { +/// "data": { +/// "type": "string", +/// "pattern": "^([0-9a-fA-F]{2})*$", +/// "x-rust-type": { +/// "crate": "byte-wrapper", +/// "path": "byte_wrapper::HexVec", +/// "version": "0.1.0" +/// } +/// } +/// } +///} +/// ``` +///
+#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct HasHexSliceCow { + pub data: ::byte_wrapper::HexVec, +} diff --git a/tests/output/typify-hex-vec-direct.rs b/tests/output/typify-hex-vec-direct.rs new file mode 100644 index 0000000..474f0ea --- /dev/null +++ b/tests/output/typify-hex-vec-direct.rs @@ -0,0 +1,61 @@ +/// Error types. +pub mod error { + /// Error from a `TryFrom` or `FromStr` implementation. + pub struct ConversionError(::std::borrow::Cow<'static, str>); + impl ::std::error::Error for ConversionError {} + impl ::std::fmt::Display for ConversionError { + fn fmt( + &self, + f: &mut ::std::fmt::Formatter<'_>, + ) -> Result<(), ::std::fmt::Error> { + ::std::fmt::Display::fmt(&self.0, f) + } + } + impl ::std::fmt::Debug for ConversionError { + fn fmt( + &self, + f: &mut ::std::fmt::Formatter<'_>, + ) -> Result<(), ::std::fmt::Error> { + ::std::fmt::Debug::fmt(&self.0, f) + } + } + impl From<&'static str> for ConversionError { + fn from(value: &'static str) -> Self { + Self(value.into()) + } + } + impl From for ConversionError { + fn from(value: String) -> Self { + Self(value.into()) + } + } +} +///`HasHexVecDirect` +/// +///
JSON schema +/// +/// ```json +///{ +/// "title": "HasHexVecDirect", +/// "type": "object", +/// "required": [ +/// "data" +/// ], +/// "properties": { +/// "data": { +/// "type": "string", +/// "pattern": "^([0-9a-fA-F]{2})*$", +/// "x-rust-type": { +/// "crate": "byte-wrapper", +/// "path": "byte_wrapper::HexVec", +/// "version": "0.1.0" +/// } +/// } +/// } +///} +/// ``` +///
+#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct HasHexVecDirect { + pub data: ::byte_wrapper::HexVec, +} diff --git a/tests/output/typify-hex-vec-with-attr.rs b/tests/output/typify-hex-vec-with-attr.rs new file mode 100644 index 0000000..54941b3 --- /dev/null +++ b/tests/output/typify-hex-vec-with-attr.rs @@ -0,0 +1,61 @@ +/// Error types. +pub mod error { + /// Error from a `TryFrom` or `FromStr` implementation. + pub struct ConversionError(::std::borrow::Cow<'static, str>); + impl ::std::error::Error for ConversionError {} + impl ::std::fmt::Display for ConversionError { + fn fmt( + &self, + f: &mut ::std::fmt::Formatter<'_>, + ) -> Result<(), ::std::fmt::Error> { + ::std::fmt::Display::fmt(&self.0, f) + } + } + impl ::std::fmt::Debug for ConversionError { + fn fmt( + &self, + f: &mut ::std::fmt::Formatter<'_>, + ) -> Result<(), ::std::fmt::Error> { + ::std::fmt::Debug::fmt(&self.0, f) + } + } + impl From<&'static str> for ConversionError { + fn from(value: &'static str) -> Self { + Self(value.into()) + } + } + impl From for ConversionError { + fn from(value: String) -> Self { + Self(value.into()) + } + } +} +///`HasHexVecWithAttr` +/// +///
JSON schema +/// +/// ```json +///{ +/// "title": "HasHexVecWithAttr", +/// "type": "object", +/// "required": [ +/// "data" +/// ], +/// "properties": { +/// "data": { +/// "type": "string", +/// "pattern": "^([0-9a-fA-F]{2})*$", +/// "x-rust-type": { +/// "crate": "byte-wrapper", +/// "path": "byte_wrapper::HexVec", +/// "version": "0.1.0" +/// } +/// } +/// } +///} +/// ``` +///
+#[derive(::serde::Deserialize, ::serde::Serialize, Clone, Debug)] +pub struct HasHexVecWithAttr { + pub data: ::byte_wrapper::HexVec, +}