diff --git a/Cargo.lock b/Cargo.lock index 6670a7a1..5dca1c91 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -595,6 +595,7 @@ dependencies = [ "bitcoin-primitives", "bitcoin_hashes 1.2.0", "cfg-if", + "ciborium", "dash-types-marker", "hex-conservative 1.3.0", "rstest", diff --git a/pkgs/dev/Cargo.toml b/pkgs/dev/Cargo.toml index 02913c68..b8854fe0 100644 --- a/pkgs/dev/Cargo.toml +++ b/pkgs/dev/Cargo.toml @@ -26,11 +26,15 @@ std = [ "hex-conservative/std", ] full = ["std", "serde"] -serde = ["dep:serde", "dash-primitives/serde", "dash-types/serde"] +serde = [ + "dep:ciborium", + "dep:serde", + "dash-primitives/serde", + "dash-types/serde", +] bin = [ "full", "dep:chrono", - "dep:ciborium", "dep:clap", "dep:dash-num", "dep:dash-params", diff --git a/pkgs/dev/src/encode.rs b/pkgs/dev/src/encode.rs index cf14a90a..4a143539 100644 --- a/pkgs/dev/src/encode.rs +++ b/pkgs/dev/src/encode.rs @@ -27,3 +27,23 @@ pub fn arr_from_hex(s: &str) -> [u8; N] { pub fn vec_from_hex(s: &str) -> Vec { decode_to_vec(s).unwrap_or_else(|e| panic!("bad hex: {e}")) } + +/// Asserts `value` serializes to CBOR as the byte string `wire` and reads back. +/// +/// # Panics +/// +/// Panics on an encoding failure, a mismatched encoding, or a round-trip +/// mismatch. +#[cfg(all(feature = "std", feature = "serde"))] +pub fn assert_cbor_raw(value: &T, wire: &[u8]) +where + T: ::serde::Serialize + ::serde::de::DeserializeOwned + PartialEq + core::fmt::Debug, +{ + let (mut got, mut want) = (Vec::new(), Vec::new()); + ciborium::into_writer(value, &mut got).unwrap_or_else(|e| panic!("to_cbor: {e}")); + ciborium::into_writer(&ciborium::Value::Bytes(wire.to_vec()), &mut want).unwrap_or_else(|e| panic!("to_cbor: {e}")); + assert_eq!(got, want, "{value:?} is not a {}-byte cbor byte string", wire.len()); + + let restored: T = ciborium::from_reader(got.as_slice()).unwrap_or_else(|e| panic!("from_cbor: {e}")); + assert_eq!(restored, *value, "cbor round-trip"); +} diff --git a/pkgs/dev/src/lib.rs b/pkgs/dev/src/lib.rs index 115d1e91..a7afeb44 100644 --- a/pkgs/dev/src/lib.rs +++ b/pkgs/dev/src/lib.rs @@ -32,6 +32,7 @@ cfg_if::cfg_if! { pub use __private::Value; pub use corpus::{assert_serde_rt, Corpus}; + pub use encode::assert_cbor_raw; pub use json::{assert_json_rt, from_json, from_json_slice, json_rejects, to_json}; } } diff --git a/pkgs/num/CHANGELOG.md b/pkgs/num/CHANGELOG.md index 532055fc..376994fe 100644 --- a/pkgs/num/CHANGELOG.md +++ b/pkgs/num/CHANGELOG.md @@ -7,6 +7,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Changed + +- `HashBlob`, `make_hash!` types and `Arith256` serialize as their little-endian storage bytes for machine-readable + formats, JSON retains hex encoded big-endian string encoding. + +### Removed + +- `ParseHexError` has moved to `dash-types` as `dash_types::ParseHexError`. + ## [0.1.0-beta] - 2026-09-14 - Initial release. diff --git a/pkgs/num/src/arith256.rs b/pkgs/num/src/arith256.rs index 6a92660e..198f6a58 100644 --- a/pkgs/num/src/arith256.rs +++ b/pkgs/num/src/arith256.rs @@ -6,9 +6,9 @@ //! 256-bit unsigned arithmetic integer. -use crate::{Hash256, ParseHexError}; +use crate::Hash256; -use dash_types::Numeric; +use dash_types::{Numeric, ParseHexError}; use core::cmp::Ordering; use core::fmt; @@ -23,6 +23,11 @@ use core::str::FromStr; /// Stored as two `u128` limbs where `lo` holds bits \[0..128) and `hi` holds /// bits \[128..256). #[derive(Clone, Copy, Default, PartialEq, Eq, Hash)] +#[cfg_attr( + feature = "serde", + derive(::serde::Serialize, ::serde::Deserialize), + serde(from = "Hash256", into = "Hash256") +)] pub struct Arith256 { lo: u128, hi: u128, @@ -703,19 +708,3 @@ impl ShrAssign for Arith256 { *self = *self >> rhs; } } - -#[cfg(feature = "serde")] -impl serde::Serialize for Arith256 { - fn serialize(&self, serializer: S) -> Result { - let h = Hash256::from(*self); - h.serialize(serializer) - } -} - -#[cfg(feature = "serde")] -impl<'de> serde::Deserialize<'de> for Arith256 { - fn deserialize>(deserializer: D) -> Result { - let h = Hash256::deserialize(deserializer)?; - Ok(Self::from(h)) - } -} diff --git a/pkgs/num/src/compact.rs b/pkgs/num/src/compact.rs index 4b48d6dc..8d4ae8af 100644 --- a/pkgs/num/src/compact.rs +++ b/pkgs/num/src/compact.rs @@ -6,11 +6,11 @@ //! Compact difficulty target encoding. -use crate::{Arith256, ParseHexError}; +use crate::Arith256; #[cfg(feature = "codec")] use dash_types::impl_num; -use dash_types::Numeric; +use dash_types::{Numeric, ParseHexError}; use core::fmt; use core::str::FromStr; diff --git a/pkgs/num/src/hash.rs b/pkgs/num/src/hash.rs index 864949a5..078bb753 100644 --- a/pkgs/num/src/hash.rs +++ b/pkgs/num/src/hash.rs @@ -6,51 +6,21 @@ //! Fixed-size opaque hash blob types. +use dash_types::__private::__write_hex as write_hex; #[cfg(feature = "codec")] use dash_types::impl_type; -use dash_types::{type_cvrt, Numeric}; -use hex_conservative::{BytesToHexIter, Case, HexSliceToBytesIter}; +#[cfg(feature = "serde")] +use dash_types::serialize::hex as serde_hex; +use dash_types::{type_cvrt, Numeric, ParseHexError}; +use hex_conservative::{Case, HexSliceToBytesIter}; -use core::fmt::{self, Write as _}; +use core::fmt; use core::hash::Hash; use core::str::FromStr; /// Whitespace skipped before a hex prefix. const WHITESPACE: [char; 6] = [' ', '\x0c', '\n', '\r', '\t', '\x0b']; -/// Error returned when parsing a hex string fails. -#[derive(Clone, Debug, PartialEq, Eq)] -#[non_exhaustive] -pub enum ParseHexError { - /// The hex string has an odd number of characters. - OddLength, - /// The hex character count does not match the expected length. - InvalidLength { - /// Hex characters the target type accepts, twice its byte width. - expected: usize, - /// Hex characters supplied, after any prefix was stripped. - got: usize, - }, - /// A non-hex character was encountered. - InvalidChar(u8), -} - -impl fmt::Display for ParseHexError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::OddLength => write!(f, "hex string has odd length"), - Self::InvalidLength { expected, got } => { - write!(f, "expected {expected} hex chars, got {got}") - } - Self::InvalidChar(c) => { - write!(f, "invalid hex character: {:#04x}", c) - } - } - } -} - -impl core::error::Error for ParseHexError {} - /// Fixed-size opaque hash blob stored in little-endian byte order. #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct HashBlob([u8; N]); @@ -177,34 +147,22 @@ impl Default for HashBlob { /// Reversed hex (big-endian display, consensus format). impl fmt::Display for HashBlob { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write_hex(self.as_bytes(), Case::Lower, f) + write_hex(self.as_bytes(), true, Case::Lower, f) } } /// Big-endian hex, `N * 2` chars zero-padded. impl fmt::LowerHex for HashBlob { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write_hex(self.as_bytes(), Case::Lower, f) + write_hex(self.as_bytes(), true, Case::Lower, f) } } /// Big-endian hex (uppercase), `N * 2` chars zero-padded. impl fmt::UpperHex for HashBlob { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write_hex(self.as_bytes(), Case::Upper, f) - } -} - -/// Writes little-endian storage as big-endian hex, honouring `{:#x}`. -fn write_hex(bytes: &[u8], case: Case, f: &mut fmt::Formatter<'_>) -> fmt::Result { - if f.alternate() { - f.write_str(if case == Case::Lower { "0x" } else { "0X" })?; - } - for [hi, lo] in BytesToHexIter::new(bytes.iter().rev().copied(), case) { - f.write_char(char::from(hi))?; - f.write_char(char::from(lo))?; + write_hex(self.as_bytes(), true, Case::Upper, f) } - Ok(()) } impl fmt::Debug for HashBlob { @@ -236,18 +194,20 @@ impl AsRef<[u8; N]> for HashBlob { } } +/// Hex encoded big-endian string or little-endian bytes. #[cfg(feature = "serde")] impl ::serde::Serialize for HashBlob { fn serialize(&self, serializer: S) -> Result { - serializer.serialize_str(&::alloc::format!("{}", self)) + serde_hex::serialize_as(self.as_bytes(), self, serializer) } } +/// Hex encoded big-endian string through [`HashBlob::from_hex`], or `N` +/// little-endian bytes. #[cfg(feature = "serde")] impl<'de, const N: usize> ::serde::Deserialize<'de> for HashBlob { fn deserialize>(deserializer: D) -> Result { - let s = <::alloc::string::String as ::serde::Deserialize>::deserialize(deserializer)?; - Self::from_hex(&s).map_err(::serde::de::Error::custom) + serde_hex::deserialize_as(deserializer, |s: &str| Self::from_hex(s).map(|h| h.0)).map(Self) } } diff --git a/pkgs/num/src/lib.rs b/pkgs/num/src/lib.rs index ce7949e6..0f75d3e2 100644 --- a/pkgs/num/src/lib.rs +++ b/pkgs/num/src/lib.rs @@ -31,7 +31,7 @@ pub mod __private { pub use crate::arith256::Arith256; pub use crate::compact::{CompactTarget, DecodedTarget}; -pub use crate::hash::{Hash160, Hash256, HashBlob, ParseHexError}; +pub use crate::hash::{Hash160, Hash256, HashBlob}; // TODO(kwvg): move to mod __deps with crate-level export pub use dash_types::Numeric; diff --git a/pkgs/num/src/util.rs b/pkgs/num/src/util.rs index 244563ac..ea5fbfe9 100644 --- a/pkgs/num/src/util.rs +++ b/pkgs/num/src/util.rs @@ -118,7 +118,7 @@ macro_rules! make_hash { /// Parse from a big-endian hex string. #[inline] - pub fn from_hex(s: &str) -> Result { + pub fn from_hex(s: &str) -> Result { <$crate::HashBlob<$len>>::from_hex(s).map(Self) } } @@ -191,7 +191,7 @@ macro_rules! make_hash { } impl ::core::str::FromStr for $name { - type Err = $crate::ParseHexError; + type Err = $crate::__private::dash_types::ParseHexError; fn from_str(s: &str) -> Result { Self::from_hex(s) diff --git a/pkgs/num/tests/hash.rs b/pkgs/num/tests/hash.rs index 63f65007..c43ac0a9 100644 --- a/pkgs/num/tests/hash.rs +++ b/pkgs/num/tests/hash.rs @@ -8,8 +8,8 @@ #![expect(clippy::unwrap_used, reason = "test code")] -use dash_num::{Hash160, Hash256, ParseHexError}; -use dash_types::Numeric; +use dash_num::{Hash160, Hash256}; +use dash_types::{Numeric, ParseHexError}; use hex_literal::hex; use rstest::*; diff --git a/pkgs/num/tests/serde.rs b/pkgs/num/tests/serde.rs index 1dea7dab..e5fce87f 100644 --- a/pkgs/num/tests/serde.rs +++ b/pkgs/num/tests/serde.rs @@ -6,7 +6,7 @@ //! Serde roundtrip tests for all types. -use dash_dev::{assert_json_rt, from_json, json_rejects, to_json}; +use dash_dev::{assert_cbor_raw, assert_json_rt, from_json, json_rejects, to_json}; use dash_num::{Arith256, CompactTarget, Hash160, Hash256}; use dash_types::Numeric; use hex_literal::hex; @@ -21,6 +21,13 @@ fn hash256_json_roundtrip() { assert_json_rt(&Hash256::from_lendian([0xff; 32])); } +#[test] +fn hash_cbor_carries_storage_bytes() { + let bytes = hex!("9c524adbcf5611122b29125e5d35d2d22281aab533f00832d556b1f9eae51d7d"); + assert_cbor_raw(&Hash256::from_lendian(bytes), &bytes); + assert_cbor_raw(&Arith256::from(Hash256::from_lendian(bytes)), &bytes); +} + #[test] fn hash160_json_roundtrip() { let bytes = hex!("0102030405060708090a0b0c0d0e0f1011121314"); diff --git a/pkgs/p2p_core/src/command.rs b/pkgs/p2p_core/src/command.rs index 2b26a4ab..f3cc75dc 100644 --- a/pkgs/p2p_core/src/command.rs +++ b/pkgs/p2p_core/src/command.rs @@ -7,8 +7,8 @@ //! Twelve-byte null-padded command string for P2P message dispatch. use dash_primitives::hash_impl; -use dash_types::impl_bytes; use dash_types::type_id::TypeId; +use dash_types::{impl_bytes, type_cvrt}; use core::fmt; @@ -59,6 +59,8 @@ impl CommandString { } } +type_cvrt!(From<[u8; 12]> for CommandString, |bytes| Self(*bytes)); + impl fmt::Debug for CommandString { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "CommandString(\"{}\")", self.as_str()) diff --git a/pkgs/pkc/CHANGELOG.md b/pkgs/pkc/CHANGELOG.md index 294199ff..76e170c3 100644 --- a/pkgs/pkc/CHANGELOG.md +++ b/pkgs/pkc/CHANGELOG.md @@ -7,6 +7,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Changed + +- The following types serialize as raw bytes for machine-readable formats, following changes to `dash-types` and + `dash-num`. JSON and other human-readable formats are unaffected by this change. + + `BlsPkBytes`, `BlsSigBytes`, `BlsPkHash`, `BlsShareId`, `BlsIes{Blob,Multi}Bytes`, `BlsPublicKey`, `BlsSignature`, + `Bls{Pk,Sig}Share`, `BlsIes{Blob,Multi}`, `EcdsaPkBytes`, `EcdsaSigBytes`, `EcdsaRecSigBytes`, `EcdsaPkHash`, + `EcdsaPublicKey`, `EcdsaSignature`, `EcdsaRecSignature`, `EddsaPkBytes`, `EddsaSigBytes`, `EddsaPkHash`, + `EddsaPublicKey`, `EddsaSignature`. + ## [0.1.0-beta] - 2026-09-15 - Initial release. diff --git a/pkgs/pkc/src/bls/ies_bytes.rs b/pkgs/pkc/src/bls/ies_bytes.rs index 320365ae..477ef71d 100644 --- a/pkgs/pkc/src/bls/ies_bytes.rs +++ b/pkgs/pkc/src/bls/ies_bytes.rs @@ -301,7 +301,7 @@ cfg_if! { /// Decodes a whole hex image, rejecting any unconsumed tail. fn from_image<'de, T: BaseCodec, D: Deserializer<'de>>(deserializer: D) -> Result { - let bytes = serde_hex::deserialize(deserializer)?; + let bytes: Vec = serde_hex::deserialize(deserializer)?; let mut cursor = bytes.as_slice(); let value = T::decode(&mut cursor).map_err(DeError::custom)?; diff --git a/pkgs/pkc/src/bls/public_hash.rs b/pkgs/pkc/src/bls/public_hash.rs index edaf4785..dba56df8 100644 --- a/pkgs/pkc/src/bls/public_hash.rs +++ b/pkgs/pkc/src/bls/public_hash.rs @@ -22,7 +22,7 @@ make_bytes! { // nosemgrep: bytes-rev-means-hash #[expect(clippy::unwrap_used, reason = "test code")] mod tests { use crate::bls::tests::RSEED; - use crate::bls::{BlsScChia, BlsScIetf, BlsSecretKey}; + use crate::bls::{BlsPkHash, BlsScChia, BlsScIetf, BlsSecretKey}; use dash_types::Hashable; use rstest::rstest; @@ -35,4 +35,16 @@ mod tests { assert_ne!(chia.to_bytes(), ietf.to_bytes()); assert_ne!(Hashable::hash(&chia).as_bytes(), Hashable::hash(&ietf).as_bytes()); } + + #[rstest] + fn slice_conversion_checks_length() { + let bytes = [7u8; 33]; + + assert_eq!( + BlsPkHash::::try_from(&bytes[..32]).unwrap().as_bytes(), + &[7u8; 32] + ); + assert!(BlsPkHash::::try_from(&bytes[..]).is_err()); + assert!(BlsPkHash::::try_from(&bytes[..31]).is_err()); + } } diff --git a/pkgs/pkc/src/bls/public_ops.rs b/pkgs/pkc/src/bls/public_ops.rs index 391a0f76..ae200e65 100644 --- a/pkgs/pkc/src/bls/public_ops.rs +++ b/pkgs/pkc/src/bls/public_ops.rs @@ -569,7 +569,8 @@ mod tests { cfg_if! { if #[cfg(feature = "serde")] { - use dash_dev::{assert_json_rt, to_json}; + use dash_dev::{assert_cbor_raw, assert_json_rt, to_json}; + use dash_types::Hashable; #[rstest] fn serde_emits_hex_string() { @@ -588,6 +589,17 @@ mod tests { assert_json_rt(&BlsPublicKey::::from_bytes(&arr_from_hex(&pk_legacy)).unwrap()); assert_json_rt(&BlsPublicKey::::from_bytes(&arr_from_hex(&pk_ietf)).unwrap()); } + + #[rstest] + fn serde_carries_bytes_as_data() { + let (pk_legacy, _) = ser_pairs(SerType::PublicKey).swap_remove(0); + let pk = BlsPublicKey::::from_bytes(&arr_from_hex(&pk_legacy)).unwrap(); + let hash = Hashable::hash(&pk); + + // The hash renders reversed, but a binary format carries storage order. + assert_cbor_raw(&pk, &pk.to_bytes()); + assert_cbor_raw(&hash, hash.as_bytes()); + } } } } diff --git a/pkgs/pkc/src/ecdsa/public_bytes.rs b/pkgs/pkc/src/ecdsa/public_bytes.rs index 67a57729..0817ed63 100644 --- a/pkgs/pkc/src/ecdsa/public_bytes.rs +++ b/pkgs/pkc/src/ecdsa/public_bytes.rs @@ -193,7 +193,8 @@ cfg_if! { impl<'de> ::serde::Deserialize<'de> for EcdsaPkBytes { fn deserialize>(deserializer: D) -> Result { - Self::from_bytes(&serde_hex::deserialize(deserializer)?).ok_or_else(|| D::Error::custom("invalid public key")) + let bytes = serde_hex::deserialize::<_, Vec>(deserializer)?; + Self::from_bytes(&bytes).ok_or_else(|| D::Error::custom("invalid public key")) } } } diff --git a/pkgs/pkc/src/ecdsa/public_ops.rs b/pkgs/pkc/src/ecdsa/public_ops.rs index 7d96b91f..5136e2fa 100644 --- a/pkgs/pkc/src/ecdsa/public_ops.rs +++ b/pkgs/pkc/src/ecdsa/public_ops.rs @@ -285,9 +285,9 @@ mod tests { }; use crate::prelude::*; - #[cfg(feature = "serde")] - use dash_dev::assert_json_rt; use dash_dev::{arr_from_hex, Corpus}; + #[cfg(feature = "serde")] + use dash_dev::{assert_cbor_raw, assert_json_rt}; use dash_types::codec::{BaseCodec, Hashable}; use rstest::*; use serde::Deserialize; @@ -421,6 +421,7 @@ mod tests { #[rstest] fn serde_roundtrip(alice_pk: EcdsaPublicKey) { assert_json_rt(&alice_pk); + assert_cbor_raw(&alice_pk, EcdsaPkBytes::from(&alice_pk).as_bytes()); } #[rstest] diff --git a/pkgs/pkc/src/ecdsa/sig_bytes.rs b/pkgs/pkc/src/ecdsa/sig_bytes.rs index f396007c..559486d9 100644 --- a/pkgs/pkc/src/ecdsa/sig_bytes.rs +++ b/pkgs/pkc/src/ecdsa/sig_bytes.rs @@ -10,7 +10,6 @@ use crate::prelude::*; use bitcoin_hashes::sha256d; -use cfg_if::cfg_if; use dash_num::Hash256; #[cfg(feature = "codec")] use dash_types::codec::{read_bytes, BaseCodec, DecodeError, EncodeBuf}; @@ -27,7 +26,14 @@ pub const ECDSA_SIG_LEN: usize = 64; /// Raw compact ECDSA signature bytes (r || s, unvalidated scalars). #[derive(Clone, Copy, Eq, Hash, PartialEq)] #[cfg_attr(feature = "codec", derive(TypeId))] -pub struct EcdsaSigBytes([u8; ECDSA_SIG_LEN]); +#[cfg_attr( + feature = "serde", + derive(::serde::Serialize, ::serde::Deserialize), + serde(transparent) +)] +pub struct EcdsaSigBytes( + #[cfg_attr(feature = "serde", serde(with = "dash_types::serialize::hex"))] [u8; ECDSA_SIG_LEN], +); #[cfg(feature = "codec")] impl BaseCodec for EcdsaSigBytes { @@ -97,30 +103,6 @@ type_cvrt!(From<[u8; ECDSA_SIG_LEN]> for EcdsaSigBytes, |bytes| { Self(*bytes) }); -cfg_if! { - if #[cfg(feature = "serde")] { - use dash_types::serialize::hex as serde_hex; - use serde::de::Error as DeError; - use serde::{Deserializer, Serializer}; - - impl ::serde::Serialize for EcdsaSigBytes { - fn serialize(&self, serializer: S) -> Result { - serde_hex::serialize(&self.0, serializer) - } - } - - impl<'de> ::serde::Deserialize<'de> for EcdsaSigBytes { - fn deserialize>(deserializer: D) -> Result { - serde_hex::deserialize(deserializer)? - .as_slice() - .try_into() - .map(Self) - .map_err(|_| DeError::custom("invalid compact signature length")) - } - } - } -} - #[cfg(test)] #[expect(clippy::unwrap_used, reason = "test code")] mod tests { diff --git a/pkgs/pkc/src/ecdsa/sig_ops.rs b/pkgs/pkc/src/ecdsa/sig_ops.rs index 5d3497e1..08997fcf 100644 --- a/pkgs/pkc/src/ecdsa/sig_ops.rs +++ b/pkgs/pkc/src/ecdsa/sig_ops.rs @@ -174,7 +174,7 @@ mod tests { use crate::ecdsa::{EcdsaPublicKey, EcdsaSigBytes, EcdsaSignature}; #[cfg(feature = "serde")] - use dash_dev::assert_json_rt; + use dash_dev::{assert_cbor_raw, assert_json_rt}; use rstest::*; #[rstest] @@ -235,6 +235,7 @@ mod tests { #[rstest] fn serde_sig_roundtrip(alice_sig: EcdsaSignature) { assert_json_rt(&alice_sig); + assert_cbor_raw(&alice_sig, &alice_sig.to_bytes()); } #[rstest] diff --git a/pkgs/pkc/src/ecdsa/sig_rec_bytes.rs b/pkgs/pkc/src/ecdsa/sig_rec_bytes.rs index ffc56ba1..2b11f98a 100644 --- a/pkgs/pkc/src/ecdsa/sig_rec_bytes.rs +++ b/pkgs/pkc/src/ecdsa/sig_rec_bytes.rs @@ -241,7 +241,7 @@ cfg_if! { impl<'de> ::serde::Deserialize<'de> for EcdsaRecSigBytes { fn deserialize>(deserializer: D) -> Result { - let arr: [u8; ECDSA_SIG_LEN + 1] = serde_hex::deserialize(deserializer)? + let arr: [u8; ECDSA_SIG_LEN + 1] = serde_hex::deserialize::<_, Vec>(deserializer)? .as_slice() .try_into() .map_err(|_| DeError::custom("invalid compact recoverable signature length"))?; diff --git a/pkgs/pkc/src/eddsa/public_bytes.rs b/pkgs/pkc/src/eddsa/public_bytes.rs index 01bddc5b..2975c916 100644 --- a/pkgs/pkc/src/eddsa/public_bytes.rs +++ b/pkgs/pkc/src/eddsa/public_bytes.rs @@ -9,7 +9,7 @@ use super::EddsaPkHash; use bitcoin_hashes::sha256::Hash as Sha256; -use dash_types::{make_bytes, type_cvrt, Hashable}; +use dash_types::{make_bytes, Hashable}; /// Raw Ed25519 public key length. pub const EDDSA_PK_LEN: usize = 32; @@ -32,7 +32,3 @@ impl Hashable for EddsaPkBytes { EddsaPkHash::from(id) } } - -type_cvrt!(From<[u8; EDDSA_PK_LEN]> for EddsaPkBytes, |bytes| { - Self::from_bytes(*bytes) -}); diff --git a/pkgs/pkc/src/eddsa/sig_bytes.rs b/pkgs/pkc/src/eddsa/sig_bytes.rs index 9db4911a..8d74ffc7 100644 --- a/pkgs/pkc/src/eddsa/sig_bytes.rs +++ b/pkgs/pkc/src/eddsa/sig_bytes.rs @@ -6,7 +6,7 @@ //! Ed25519 signature byte bag. -use dash_types::{make_bytes, type_cvrt}; +use dash_types::make_bytes; /// Raw Ed25519 signature length. pub const EDDSA_SIG_LEN: usize = 64; @@ -15,7 +15,3 @@ make_bytes! { /// Ed25519 signature bytes (64 bytes, unvalidated). EddsaSigBytes, EDDSA_SIG_LEN, fwd, nocodec } - -type_cvrt!(From<[u8; EDDSA_SIG_LEN]> for EddsaSigBytes, |bytes| { - Self::from_bytes(*bytes) -}); diff --git a/pkgs/primitives/src/types/addrv1.rs b/pkgs/primitives/src/types/addrv1.rs index 247b762b..ac34f8d7 100644 --- a/pkgs/primitives/src/types/addrv1.rs +++ b/pkgs/primitives/src/types/addrv1.rs @@ -23,7 +23,12 @@ const IPV4_MAPPED_PREFIX: [u8; 12] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff]; /// ADDRv1 IPv4-mapped IPv6 address (16 bytes). #[derive(Clone, Copy, Default, Eq, Hash, PartialEq, TypeId)] -pub struct AddrV1(pub [u8; 16]); +#[cfg_attr( + feature = "serde", + derive(::serde::Serialize, ::serde::Deserialize), + serde(transparent) +)] +pub struct AddrV1(#[cfg_attr(feature = "serde", serde(with = "dash_types::serialize::hex"))] pub [u8; 16]); impl_bytes!(AddrV1, 16); @@ -115,23 +120,6 @@ impl fmt::Debug for AddrV1 { } } -#[cfg(feature = "serde")] -impl ::serde::Serialize for AddrV1 { - fn serialize(&self, serializer: S) -> Result { - use dash_types::__private::hex_conservative::DisplayHex; - serializer.serialize_str(&self.0.to_lower_hex_string()) - } -} - -#[cfg(feature = "serde")] -impl<'de> ::serde::Deserialize<'de> for AddrV1 { - fn deserialize>(deserializer: D) -> Result { - use dash_types::__private::hex_conservative::decode_to_array; - let s = ::deserialize(deserializer)?; - decode_to_array::<16>(&s).map(Self).map_err(::serde::de::Error::custom) - } -} - impl NetAddr for AddrV1 { fn bytes(&self) -> &[u8] { if self.is_ipv4() { @@ -203,6 +191,8 @@ impl FromStr for AddrV1 { } } +type_cvrt!(From<[u8; 16]> for AddrV1, |bytes| Self(*bytes)); + type_cvrt!(TryFrom for AddrV1, NetAddrError, |addr| { match addr { AddrV2::Ipv4(b) => { @@ -327,6 +317,13 @@ mod tests { assert_eq!(parsed.to_string(), s); } + #[cfg(feature = "serde")] + #[rstest] + fn serde_carries_bytes_as_data() { + let raw = hex!("20010db8000000000000000000000001"); + dash_dev::assert_cbor_raw(&AddrV1(raw), &raw); + } + #[rstest] #[case::ipv4("1.2.3.4:8333")] #[case::ipv6("[::1]:9999")] diff --git a/pkgs/types/CHANGELOG.md b/pkgs/types/CHANGELOG.md index 14b0160d..98fd9324 100644 --- a/pkgs/types/CHANGELOG.md +++ b/pkgs/types/CHANGELOG.md @@ -8,6 +8,34 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added + +- Implementation of `LowerHex`, `UpperHex`, `FromStr`, `From<&[u8; N]>` and `TryFrom<&[u8]>` for `{derive,make}_bytes!`. +- `ParseHexError` is moved from `dash-num`, superseding `dash_num::ParseHexError`. +- `serialize::hex::serialize_as` and `serialize::hex::deserialize_as`, to permit caller-defined handling of + human-readable encoding while sharing common machine-readable encoding. + +### Changed + +- `{derive,make}_bytes!`-defined types serialize as their raw storage bytes for machine-readable formats. JSON and + other human-readable formats retain hex in storage order for `fwd` and in reverse storage order for `rev`. +- `derive_bytes!` emits `From<[u8; N]>` in place of `impl_bytes!`, and `make_sbytes!` emits the implementation itself. + Callers of `impl_bytes!` without `derive_bytes!` must now write the conversion manually. + - As a result, both `make_bytes!` and `make_sbytes!` carry it with `nocodec` tag and without the `codec` feature and + callers that wrote the conversion manually for those configurations must drop it. +- Types using `derive_bytes!` that implemented `From<&[u8; N]>`, `TryFrom<&[u8]>`, `FromStr`, `LowerHex` or `UpperHex` + manually must drop those implementations, as `derive_bytes!` now emits them. +- `serialize::hex` writes raw bytes for machine-readable formats instead of emitting a hex-encoded string. +- `serialize::hex::deserialize` returns any `T` built from a byte slice instead of `Vec`. Callers that relied on + `Vec` as the fixed return type must now mention it explicitly. +- `serialize::str_u64` writes a native `u64` to machine-readable formats. `str_u64` is a workaround for number-precision + limitations in JSON and is now contained only for human-readable formats. + +### Fixed + +- `serialize::utf8_lossy`'s serializer and deserializer arms were not in sync, writing valid UTF-8 as a string for + machine-readable formats but expecting byte buffers when reading, failing round trips. This has since been resolved. + ## [0.1.0-beta] - 2026-09-14 - Initial release. diff --git a/pkgs/types/Cargo.toml b/pkgs/types/Cargo.toml index e84091ce..02dc0610 100644 --- a/pkgs/types/Cargo.toml +++ b/pkgs/types/Cargo.toml @@ -27,7 +27,7 @@ default = ["codec"] std = [ "bitcoin-consensus-encoding?/std", "bitcoin-primitives?/std", - "hex-conservative?/std", + "hex-conservative/std", ] bitcoin-primitives = [ @@ -48,8 +48,8 @@ full = [ ] serde = [ "codec", - "dep:hex-conservative", "dep:serde", + "hex-conservative/alloc", ] [dependencies] @@ -59,9 +59,7 @@ bitcoin_hashes = { workspace = true, optional = true, features = ["alloc"] } bitcoin-primitives = { workspace = true, optional = true, features = ["alloc"] } cfg-if = "1" dash-types-marker = { version = "0.1.0-beta", path = "marker", optional = true } -hex-conservative = { workspace = true, features = [ - "alloc", -], optional = true } +hex-conservative = { workspace = true } subtle = { workspace = true } serde = { version = "1", default-features = false, features = [ "derive", @@ -70,6 +68,7 @@ serde = { version = "1", default-features = false, features = [ zeroize = { workspace = true } [dev-dependencies] +ciborium = "0.2" rstest = "0.25" serde = { version = "1", features = ["derive"] } diff --git a/pkgs/types/src/entity.rs b/pkgs/types/src/entity.rs index 1d441a8e..725b3a05 100644 --- a/pkgs/types/src/entity.rs +++ b/pkgs/types/src/entity.rs @@ -198,8 +198,8 @@ macro_rules! impl_type { }; } -/// Generates `BaseCodec` + `Encode` + `Decode` + `From<[u8; N]>` for a -/// fixed-size byte newtype, expressed only through `from_bytes` / `as_bytes`. +/// Generates `BaseCodec` + `Encode` + `Decode` for a fixed-size byte newtype, +/// expressed only through `from_bytes` / `as_bytes`. /// /// Staged through the growable [`VecEncoder`]. For a newtype whose contents /// are secret use [`impl_sbytes!`](crate::impl_sbytes). @@ -219,33 +219,26 @@ macro_rules! impl_bytes { buf.extend_from_slice(self.as_bytes()); } } - - impl<$($g)*> ::core::convert::From<[u8; $n]> for $ty { - fn from(bytes: [u8; $n]) -> Self { Self::from_bytes(bytes) } - } }; - (@parse [$($g:tt)*] $ty:ty, $n:expr) => { + (@wire [$($g:tt)*] $ty:ty, $n:expr) => { $crate::impl_bytes!(@codec [$($g)*] $ty, $n); $crate::impl_type!(@parse [$($g)*] $ty, $n); }; (for[$($generic:tt)*] $($args:tt)*) => { - $crate::impl_bytes!(@parse [$($generic)*] $($args)*); + $crate::impl_bytes!(@wire [$($generic)*] $($args)*); }; ($($args:tt)*) => { - $crate::impl_bytes!(@parse [] $($args)*); + $crate::impl_bytes!(@wire [] $($args)*); }; } /// The standard trait set for a fixed-size byte newtype, expressed only /// through `from_bytes` / `as_bytes`. /// -/// Emits `Clone`, `Copy`, `Default`, `Eq`, `PartialEq`, `Ord`, `PartialOrd`, -/// `Hash`, `is_null`, `AsRef<[u8]>`, `AsRef<[u8; N]>`, `From for -/// [u8; N]`, a hex `Debug`/`Display`, and the hex `serde` pair. -/// -/// A trailing `rev` renders the hex in reverse storage order, the default `fwd` -/// renders storage order. +/// The `serde` pair writes the `Display` hex to human-readable formats and the +/// raw storage bytes to machine-readable formats. A trailing `rev` renders and +/// parses the hex in reverse storage order, the default `fwd` in storage order. /// /// For a newtype holding secrets use [`derive_sbytes!`](crate::derive_sbytes), /// which withholds everything that would read or copy out the plaintext. @@ -298,6 +291,16 @@ macro_rules! derive_bytes { fn from(val: $ty) -> Self { *val.as_bytes() } } + $crate::type_cvrt!(for[$($g)*] From<[u8; $n]> for $ty, |bytes| Self::from_bytes(*bytes)); + + impl<$($g)*> ::core::convert::TryFrom<&[u8]> for $ty { + type Error = ::core::array::TryFromSliceError; + + fn try_from(slice: &[u8]) -> ::core::result::Result { + <[u8; $n]>::try_from(slice).map(Self::from_bytes) + } + } + impl<$($g)*> $ty { /// Returns `true` when every byte is zero. pub fn is_null(&self) -> bool { self.as_bytes().iter().all(|&b| b == 0) } @@ -306,9 +309,7 @@ macro_rules! derive_bytes { impl<$($g)*> ::core::fmt::Debug for $ty { fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { $crate::qtypestr(f, ::core::any::type_name::())?; - f.write_str("(")?; - ::core::fmt::Display::fmt(self, f)?; - f.write_str(")") + ::core::write!(f, "({self})") } } @@ -323,12 +324,29 @@ macro_rules! derive_bytes { (@hex [$($g:tt)*] $ty:ty, $n:expr, $rev:expr) => { impl<$($g)*> ::core::fmt::Display for $ty { fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { - let bytes = self.as_bytes(); - for i in 0..$n { - let byte = if $rev { bytes[$n - 1 - i] } else { bytes[i] }; - ::core::write!(f, "{byte:02x}")?; - } - ::core::result::Result::Ok(()) + // A fresh formatter drops `{:#}`, reserving the prefix for `{:#x}`. + ::core::write!(f, "{self:x}") + } + } + + impl<$($g)*> ::core::fmt::LowerHex for $ty { + fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + $crate::__private::__write_hex(self.as_bytes(), $rev, $crate::__private::hex_conservative::Case::Lower, f) + } + } + + impl<$($g)*> ::core::fmt::UpperHex for $ty { + fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result { + $crate::__private::__write_hex(self.as_bytes(), $rev, $crate::__private::hex_conservative::Case::Upper, f) + } + } + + impl<$($g)*> ::core::str::FromStr for $ty { + type Err = $crate::ParseHexError; + + /// Parses `2 * N` hex digits in the order `Display` writes. + fn from_str(s: &str) -> ::core::result::Result { + $crate::__private::__read_hex::<$n>(s, $rev).map(Self::from_bytes) } } @@ -338,7 +356,7 @@ macro_rules! derive_bytes { where Z: $crate::__private::serde::Serializer, { - serializer.serialize_str(&::alloc::format!("{self}")) + $crate::serialize::hex::serialize_as(self.as_bytes(), self, serializer) } } @@ -347,14 +365,10 @@ macro_rules! derive_bytes { where D: $crate::__private::serde::Deserializer<'de>, { - use $crate::__private::serde::de::Error as _; - let s = <::alloc::string::String as $crate::__private::serde::Deserialize>::deserialize(deserializer)?; - let mut bytes = $crate::__private::hex_conservative::decode_to_array::<$n>(&s) - .map_err(D::Error::custom)?; - if $rev { - bytes.reverse(); - } - ::core::result::Result::Ok(Self::from_bytes(bytes)) + $crate::serialize::hex::deserialize_as(deserializer, |s: &str| { + $crate::__private::__read_hex::<$n>(s, $rev) + }) + .map(Self::from_bytes) } } } @@ -407,7 +421,7 @@ macro_rules! make_bytes { @struct [$($g)*] $attrs #[derive($crate::type_id::TypeId)] $name $(<$($param),+>)?, $n ); - $crate::$codec!(@parse [$($g)*] $name $(<$($param),+>)?, $n); + $crate::$codec!(@wire [$($g)*] $name $(<$($param),+>)?, $n); } else { $crate::make_bytes!(@struct [$($g)*] $attrs $name $(<$($param),+>)?, $n); } @@ -515,3 +529,33 @@ macro_rules! dlgt_codec { $crate::dlgt_codec!(@parse [] $($args)*); }; } + +#[cfg(all(test, feature = "codec"))] +mod tests { + use hex_conservative::hex; + use rstest::rstest; + + struct Paired([u8; 4]); + + impl Paired { + const fn from_bytes(bytes: [u8; 4]) -> Self { + Self(bytes) + } + + const fn as_bytes(&self) -> &[u8; 4] { + &self.0 + } + } + + // Pairs the two as `make_bytes!` documents for manual use. + crate::derive_bytes!(Paired, 4); + crate::impl_bytes!(Paired, 4); + + #[rstest] + fn derive_and_impl_bytes_pair() { + let paired = Paired::from(hex!("ab000001")); + + assert!(!paired.is_null()); + assert_eq!(paired.as_bytes(), &hex!("ab000001")); + } +} diff --git a/pkgs/types/src/hex.rs b/pkgs/types/src/hex.rs new file mode 100644 index 00000000..d7d2a212 --- /dev/null +++ b/pkgs/types/src/hex.rs @@ -0,0 +1,166 @@ +// +// Copyright (c) 2026-present, The Dash Core developers +// SPDX-License-Identifier: MIT +// See the accompanying file LICENSE or https://opensource.org/license/MIT +// + +//! Hex parsing and rendering. + +use hex_conservative::{BytesToHexIter, Case, HexSliceToBytesIter}; + +use core::fmt::{self, Write as _}; + +/// Error returned when parsing a hex string fails. +#[derive(Clone, Debug, PartialEq, Eq)] +#[non_exhaustive] +pub enum ParseHexError { + /// The hex string has an odd number of characters. + OddLength, + /// The hex character count does not match the expected length. + InvalidLength { + /// Hex characters the target type accepts, twice its byte width. + expected: usize, + /// Hex characters supplied, after any prefix was stripped. + got: usize, + }, + /// A non-hex character was encountered. + InvalidChar(u8), +} + +impl fmt::Display for ParseHexError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::OddLength => write!(f, "hex string has odd length"), + Self::InvalidLength { expected, got } => { + write!(f, "expected {expected} hex chars, got {got}") + } + Self::InvalidChar(c) => { + write!(f, "invalid hex character: {:#04x}", c) + } + } + } +} + +impl core::error::Error for ParseHexError {} + +/// Decodes `2 * N` hex digits, reversing the byte order with `rev` enabled. +/// +/// # Errors +/// +/// Returns `OddLength` on an odd character count, `InvalidLength` unless the +/// input is `2 * N` characters, or `InvalidChar` on a non-hex digit. +pub fn read_hex(s: &str, rev: bool) -> Result<[u8; N], ParseHexError> { + let digits = HexSliceToBytesIter::new(s).map_err(|_| ParseHexError::OddLength)?; + if digits.len() != N { + return Err(ParseHexError::InvalidLength { + expected: N * 2, + got: s.len(), + }); + } + + let mut bytes = [0u8; N]; + for (slot, byte) in bytes.iter_mut().zip(digits) { + *slot = byte.map_err(|e| ParseHexError::InvalidChar(e.invalid_char()))?; + } + + if rev { + bytes.reverse(); + } + Ok(bytes) +} + +/// Writes `bytes` as hex in storage order, or reversed with `rev` enabled, +/// honouring `{:#x}` with a `0x`/`0X` prefix. +/// +/// # Errors +/// +/// Returns an error when the formatter does. +pub fn write_hex(bytes: &[u8], rev: bool, case: Case, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if f.alternate() { + f.write_str(if case == Case::Lower { "0x" } else { "0X" })?; + } + + let mut fwd = bytes.iter(); + let mut bwd = bytes.iter().rev(); + let ordered: &mut dyn Iterator = if rev { &mut bwd } else { &mut fwd }; + for [hi, lo] in BytesToHexIter::new(ordered.copied(), case) { + f.write_char(char::from(hi))?; + f.write_char(char::from(lo))?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{read_hex, write_hex, ParseHexError}; + use crate::prelude::*; + + use hex_conservative::{hex, Case}; + use rstest::*; + + use core::fmt; + + /// Renders through [`write_hex`] so the formatter flags reach it. + struct Hex<'a>(&'a [u8], bool); + + impl fmt::LowerHex for Hex<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write_hex(self.0, self.1, Case::Lower, f) + } + } + + impl fmt::UpperHex for Hex<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write_hex(self.0, self.1, Case::Upper, f) + } + } + + struct Probe([u8; 4]); + + impl Probe { + const fn from_bytes(bytes: [u8; 4]) -> Self { + Self(bytes) + } + + const fn as_bytes(&self) -> &[u8; 4] { + &self.0 + } + } + + crate::derive_bytes!(Probe, 4); + + #[rstest] + #[case::forward(false, "ab000001")] + #[case::reversed(true, "010000ab")] + fn writes_and_reads_back_in_either_order(#[case] reversed: bool, #[case] text: &str) { + let bytes = hex!("ab000001"); + + assert_eq!(format!("{:x}", Hex(&bytes, reversed)), text); + assert_eq!(format!("{:#x}", Hex(&bytes, reversed)), format!("0x{text}")); + assert_eq!( + format!("{:#X}", Hex(&bytes, reversed)), + format!("0X{}", text.to_uppercase()) + ); + assert_eq!(read_hex::<4>(text, reversed), Ok(bytes)); + assert_eq!(read_hex::<4>(&text.to_uppercase(), reversed), Ok(bytes)); + } + + #[rstest] + #[case::short("ab", ParseHexError::InvalidLength { expected: 8, got: 2 })] + #[case::long("ab00000100", ParseHexError::InvalidLength { expected: 8, got: 10 })] + #[case::odd("ab0000010", ParseHexError::OddLength)] + #[case::prefixed("0x000001", ParseHexError::InvalidChar(b'x'))] + #[case::non_hex("zz000001", ParseHexError::InvalidChar(b'z'))] + fn read_is_strict(#[case] text: &str, #[case] err: ParseHexError) { + assert_eq!(read_hex::<4>(text, false), Err(err)); + } + + #[rstest] + fn display_is_unprefixed_under_alternate() { + let probe = Probe::from_bytes(hex!("ab000001")); + + assert!(!probe.is_null()); + assert_eq!(format!("{probe:#}"), "ab000001"); + assert_eq!(format!("{probe:#x}"), "0xab000001"); + } +} diff --git a/pkgs/types/src/lib.rs b/pkgs/types/src/lib.rs index d09a19a3..2f7db5e0 100644 --- a/pkgs/types/src/lib.rs +++ b/pkgs/types/src/lib.rs @@ -14,6 +14,7 @@ extern crate self as dash_types; extern crate std; mod entity; +mod hex; mod macros; mod numeric; #[allow(unused_imports, reason = "ergonomic shim, exports may be unused")] @@ -24,6 +25,7 @@ mod traits; #[cfg(feature = "serde")] pub mod serialize; +pub use crate::hex::ParseHexError; pub use crate::macros::qtypestr; pub use crate::numeric::Numeric; pub use crate::traits::{Checkable, Hashable}; @@ -50,10 +52,10 @@ cfg_if::cfg_if! { pub mod __private { #[cfg(feature = "bitcoin-primitives")] pub use crate::adapters::bitcoin_primitives::ScriptHash as __ScriptHash; + pub use crate::hex::{read_hex as __read_hex, write_hex as __write_hex}; #[cfg(feature = "codec")] pub use bitcoin_consensus_encoding; - #[cfg(feature = "serde")] pub use hex_conservative; #[cfg(feature = "serde")] pub use serde; diff --git a/pkgs/types/src/secret.rs b/pkgs/types/src/secret.rs index ac844183..bffe45a9 100644 --- a/pkgs/types/src/secret.rs +++ b/pkgs/types/src/secret.rs @@ -302,11 +302,18 @@ macro_rules! impl_stype { #[cfg(feature = "codec")] #[macro_export] macro_rules! impl_sbytes { - (@parse [$($g:tt)*] $ty:ty, $n:expr) => { + (@wire [$($g:tt)*] $ty:ty, $n:expr) => { $crate::impl_bytes!(@codec [$($g)*] $ty, $n); $crate::impl_stype!(@parse [$($g)*] $ty, $n); }; + (@parse [$($g:tt)*] $ty:ty, $n:expr) => { + $crate::impl_sbytes!(@wire [$($g)*] $ty, $n); + + impl<$($g)*> ::core::convert::From<[u8; $n]> for $ty { + fn from(bytes: [u8; $n]) -> Self { Self::from_bytes(bytes) } + } + }; (for[$($generic:tt)*] $($args:tt)*) => { $crate::impl_sbytes!(@parse [$($generic)*] $($args)*); }; @@ -402,6 +409,12 @@ macro_rules! make_sbytes { } }); + impl<$($g)*> ::core::convert::From<[u8; $n]> for $name $(<$($param),+>)? { + fn from(bytes: [u8; $n]) -> Self { + Self::from_bytes(bytes) + } + } + impl<$($g)*> $crate::zeroize::Zeroize for $name $(<$($param),+>)? { fn zeroize(&mut self) { $crate::zeroize::Zeroize::zeroize(&mut self.inner); diff --git a/pkgs/types/src/serialize.rs b/pkgs/types/src/serialize.rs index d2886ad0..620682c2 100644 --- a/pkgs/types/src/serialize.rs +++ b/pkgs/types/src/serialize.rs @@ -6,38 +6,131 @@ //! Reusable serde helpers for `#[serde(with = "...")]`. -/// Wire-order hex for `Vec`. +/// Hex strings for human-readable formats and raw bytes for machine-readable +/// formats. pub mod hex { use crate::prelude::*; + use ::serde::de::Error as DeError; use hex_conservative::{decode_to_vec, DisplayHex}; - /// Serializes bytes as a wire-order hex string. + use core::fmt; + use core::marker::PhantomData; + + /// Serializes bytes as a wire-order hex string, or as raw bytes when the + /// format is machine-readable. /// /// # Errors /// - /// Returns a serialization error when the serializer rejects the string. + /// Returns a serialization error when the serializer rejects the value. pub fn serialize(data: &[u8], serializer: S) -> Result { - serializer.serialize_str(&data.to_lower_hex_string()) + serialize_as(data, &data.as_hex(), serializer) } - /// Deserializes a hex string into bytes. + /// Deserializes a hex string or raw bytes into any `T` built from a byte + /// slice, such as `Vec` or a fixed-width array. /// /// # Errors /// - /// Returns a deserialization error when the input is not a string, or when - /// it is not valid hex. - pub fn deserialize<'de, D: ::serde::Deserializer<'de>>(deserializer: D) -> Result, D::Error> { - let s = ::deserialize(deserializer)?; - decode_to_vec(&s).map_err(::serde::de::Error::custom) + /// Returns a deserialization error when the input is neither form, when a + /// string is not valid hex, or when the bytes do not convert to `T`. + pub fn deserialize<'de, D, T>(deserializer: D) -> Result + where + D: ::serde::Deserializer<'de>, + T: for<'a> TryFrom<&'a [u8]>, + { + deserialize_as(deserializer, |s: &str| { + let bytes = decode_to_vec(s).map_err(|e| e.to_string())?; + // For the byte arrays and bags this serves, the size of `T` is its width. + T::try_from(bytes.as_slice()).map_err(|_| { + format!( + "hex decode length mismatch (expected: {}, got: {})", + size_of::(), + bytes.len() + ) + }) + }) + } + + /// Serializes `text` in human-readable formats and `data` as raw bytes in + /// all others. + /// + /// # Errors + /// + /// Returns a serialization error when the serializer rejects the value. + pub fn serialize_as(data: &[u8], text: &T, serializer: S) -> Result + where + S: ::serde::Serializer, + T: fmt::Display + ?Sized, + { + if serializer.is_human_readable() { + serializer.collect_str(text) + } else { + serializer.serialize_bytes(data) + } + } + + /// Deserializes a string through `parse` or raw bytes through `TryFrom`. + /// + /// Human-readable mode takes raw bytes as well, because serde replays input + /// buffered for untagged enums and `flatten` as human-readable. + /// + /// # Errors + /// + /// Returns a deserialization error when the input is neither form, when + /// `parse` fails, or when the raw bytes do not convert. + pub fn deserialize_as<'de, D, T, F, E>(deserializer: D, parse: F) -> Result + where + D: ::serde::Deserializer<'de>, + T: for<'a> TryFrom<&'a [u8]>, + F: FnOnce(&str) -> Result, + E: fmt::Display, + { + struct Visitor(F, PhantomData T>); + + impl ::serde::de::Visitor<'_> for Visitor + where + T: for<'a> TryFrom<&'a [u8]>, + F: FnOnce(&str) -> Result, + E: fmt::Display, + { + type Value = T; + + fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("a hex string or raw bytes") + } + + fn visit_str(self, v: &str) -> Result { + (self.0)(v).map_err(Er::custom) + } + + fn visit_bytes(self, v: &[u8]) -> Result { + T::try_from(v).map_err(|_| { + let width = format!("{} raw bytes", size_of::()); + Er::invalid_length(v.len(), &width.as_str()) + }) + } + } + + if deserializer.is_human_readable() { + deserializer.deserialize_str(Visitor(parse, PhantomData)) + } else { + deserializer.deserialize_byte_buf(Visitor(parse, PhantomData)) + } } } -/// Serializes `u64` as a decimal string to avoid JSON precision loss. +/// Serializes `u64` as a decimal string in human-readable formats to avoid +/// JSON precision loss, and as a native integer in machine-readable formats. pub mod str_u64 { - /// Serializes a `u64` as a decimal string. + /// Serializes a `u64` as a decimal string, or as an integer when the format + /// is machine-readable. pub fn serialize(val: &u64, s: S) -> Result { - s.serialize_str(&alloc::format!("{val}")) + if s.is_human_readable() { + s.collect_str(val) + } else { + s.serialize_u64(*val) + } } /// Deserializes a `u64` from a decimal string or a number. @@ -60,7 +153,11 @@ pub mod str_u64 { } } - d.deserialize_any(Visitor) + if d.is_human_readable() { + d.deserialize_any(Visitor) + } else { + d.deserialize_u64(Visitor) + } } } @@ -98,20 +195,21 @@ pub mod utf8 { pub mod utf8_lossy { use crate::prelude::*; - use ::serde::de::{Error as DeError, SeqAccess, Visitor}; + use ::serde::de::{Error as DeError, SeqAccess}; use core::fmt; use core::str::from_utf8; - /// Serializes bytes as a string when valid UTF-8, otherwise as raw bytes. + /// Serializes bytes as raw bytes, or as a string when the format is + /// human-readable and the bytes are valid UTF-8. /// /// # Errors /// /// Returns a serialization error when the serializer rejects the value. pub fn serialize(data: &[u8], serializer: S) -> Result { match from_utf8(data) { - Ok(text) => serializer.serialize_str(text), - Err(_) => serializer.serialize_bytes(data), + Ok(text) if serializer.is_human_readable() => serializer.serialize_str(text), + _ => serializer.serialize_bytes(data), } } @@ -124,9 +222,9 @@ pub mod utf8_lossy { /// /// Returns a deserialization error when the input is none of those forms. pub fn deserialize<'de, D: ::serde::Deserializer<'de>>(deserializer: D) -> Result, D::Error> { - struct BytesVisitor; + struct Visitor; - impl<'de> Visitor<'de> for BytesVisitor { + impl<'de> ::serde::de::Visitor<'de> for Visitor { type Value = Vec; fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { @@ -151,9 +249,94 @@ pub mod utf8_lossy { } if deserializer.is_human_readable() { - deserializer.deserialize_any(BytesVisitor) + deserializer.deserialize_any(Visitor) } else { - deserializer.deserialize_byte_buf(BytesVisitor) + deserializer.deserialize_byte_buf(Visitor) } } } + +#[cfg(test)] +#[expect(clippy::unwrap_used, reason = "test code")] +mod tests { + use crate::prelude::*; + + use ::serde::{Deserialize, Serialize}; + use hex_conservative::hex; + use rstest::rstest; + + #[derive(Debug, PartialEq, Serialize, Deserialize)] + struct Blob(#[serde(with = "super::hex")] Vec); + + #[rstest] + #[case::inline(16)] + #[case::beyond_scratch(8192)] + fn hex_vec_roundtrips_through_cbor(#[case] len: usize) { + let blob = Blob(vec![0xa5; len]); + let mut wire = Vec::new(); + ciborium::into_writer(&blob, &mut wire).unwrap(); + assert_eq!(ciborium::from_reader::(wire.as_slice()).unwrap(), blob); + } + + #[derive(Debug, PartialEq, Serialize, Deserialize)] + #[serde(untagged)] + enum Untagged { + Blob(Blob), + } + + #[derive(Debug, PartialEq, Serialize, Deserialize)] + struct Flattened { + #[serde(flatten)] + inner: Inner, + } + + #[derive(Debug, PartialEq, Serialize, Deserialize)] + struct Inner { + #[serde(with = "super::hex")] + blob: Vec, + } + + #[rstest] + fn buffered_input_roundtrips_through_cbor() { + let untagged = Untagged::Blob(Blob(vec![0xa5; 4])); + let mut wire = Vec::new(); + ciborium::into_writer(&untagged, &mut wire).unwrap(); + assert_eq!(ciborium::from_reader::(wire.as_slice()).unwrap(), untagged); + + let flattened = Flattened { + inner: Inner { blob: vec![0xa5; 4] }, + }; + let mut wire = Vec::new(); + ciborium::into_writer(&flattened, &mut wire).unwrap(); + assert_eq!( + ciborium::from_reader::(wire.as_slice()).unwrap(), + flattened + ); + } + + #[derive(Debug, PartialEq, Serialize, Deserialize)] + struct Nonce(#[serde(with = "super::str_u64")] u64); + + #[rstest] + fn str_u64_carries_integer_through_cbor() { + let nonce = Nonce(0x0102_0304_0506_0708); + let mut wire = Vec::new(); + ciborium::into_writer(&nonce, &mut wire).unwrap(); + assert_eq!(wire, hex!("1b0102030405060708")); + assert_eq!(ciborium::from_reader::(wire.as_slice()).unwrap(), nonce); + } + + #[derive(Debug, PartialEq, Serialize, Deserialize)] + struct Lossy(#[serde(with = "super::utf8_lossy")] Vec); + + #[rstest] + #[case::utf8(b"abc", &hex!("43616263"))] + #[case::non_utf8(&[0xff], &hex!("41ff"))] + fn utf8_lossy_carries_bytes_through_cbor(#[case] data: &[u8], #[case] raw: &[u8]) { + let lossy = Lossy(data.to_vec()); + let mut wire = Vec::new(); + ciborium::into_writer(&lossy, &mut wire).unwrap(); + assert_eq!(wire, raw); + assert_eq!(ciborium::from_reader::(wire.as_slice()).unwrap(), lossy); + } +}