p2p: change CommandString to wrap 12 byte array instead of Cow
What changed, and why it matters
This commit refactors how Bitcoin network command names (like 'version' or 'ping') are stored internally. It replaces a flexible string container with a fixed 12-byte array, which removes dynamic memory allocation and tightens checks that command strings are ASCII and no longer than 12 characters. The change is mostly a defensive cleanup, but it also removes a public constructor and slightly alters how non-ASCII or malformed command strings are rejected during decoding.
Treat as a routine hardening/refactoring patch. Review downstream code that used CommandString::try_from_static or V1MessageHeader::new with a string literal, because those APIs changed. Verify that the new decoder behavior (rejecting any non-ASCII byte in the 12-byte field) is compatible with expected peer behavior. No urgent security action is indicated by the diff alone.
Security signals we found
Tightened input validation: all construction paths now enforce ASCII and max length invariants.
Decoder now rejects non-ASCII bytes anywhere in the 12-byte command field, not just in the trimmed suffix.
Use of unsafe std::str::from_utf8_unchecked gated on the construction-time ASCII invariant.
Removal of public try_from_static constructor changes the API surface.
No explicit security bug fix or CVE mentioned in commit message.
Evidence from the diff
CommandString is changed from wrapping Cow<’static, str> to wrapping [u8; 12]. Construction now enforces ASCII and length <= 12 in all paths (TryFrom
Changed components
p2p/src/message.rsCommandString type and its TryFrom/FromStr/AsRef/Display implementationsCommandStringEncoder and CommandStringDecoderV1MessageHeader::newNetworkMessage::commandV2NetworkMessageDecoder command handlingInspect captured patch +74 / −81
diff --git a/p2p/src/message.rs b/p2p/src/message.rs
index ba138a1d..bce0606b 100644
--- a/p2p/src/message.rs
+++ b/p2p/src/message.rs
@@ -5,7 +5,6 @@
//! This module defines the `NetworkMessage` and `V1NetworkMessage` types that
//! are used for (de)serializing Bitcoin objects for transmission on the network.
-use alloc::borrow::{Cow, ToOwned};
use alloc::boxed::Box;
use alloc::string::String;
use alloc::vec::Vec;
@@ -49,29 +48,33 @@ pub const MAX_INV_SIZE: usize = 50_000;
/// This by necessity should be larger than `MAX_VEC_SIZE`
pub const MAX_MSG_SIZE: usize = 5_000_000;
-/// Serializer for command string
+/// Contains the message command.
#[derive(PartialEq, Eq, Clone, Debug)]
-pub struct CommandString(Cow<'static, str>);
+pub struct CommandString([u8; 12]);
impl CommandString {
- /// Converts `&'static str` to `CommandString`
+ /// The maximum length a [`CommandString`] can be once padding characters are trimmed.
+ pub const MAX_LEN: usize = 12;
+
+ /// Create [`CommandString`].
+ ///
+ /// # Parameters
///
- /// This is more efficient for string literals than non-static conversions because it avoids
- /// allocation.
+ /// * `s` - that which implents the trait bounds `AsRef`<str> + Into<String>
///
/// # Errors
///
- /// Returns an error if, and only if, the string is
- /// larger than 12 characters in length.
- pub fn try_from_static(s: &'static str) -> Result<Self, CommandStringError> {
- Self::try_from_static_cow(s.into())
- }
+ /// - If `s` is more than 12 characters in length.
+ /// - If `s` has non-ascii characters.
+ fn try_from_stringly<S: AsRef<str> + Into<String>>(s: &S) -> Result<Self, CommandStringError> {
+ let s = s.as_ref();
- fn try_from_static_cow(cow: Cow<'static, str>) -> Result<Self, CommandStringError> {
- if cow.len() > 12 {
- Err(CommandStringError { cow })
+ if !s.is_ascii() || s.len() > Self::MAX_LEN {
+ Err(CommandStringError(s.into()))
} else {
- Ok(Self(cow))
+ let mut buf = [0; Self::MAX_LEN];
+ buf[..s.len()].copy_from_slice(s.as_bytes());
+ Ok(Self(buf))
}
}
}
@@ -79,53 +82,37 @@ impl CommandString {
impl TryFrom<String> for CommandString {
type Error = CommandStringError;
- fn try_from(value: String) -> Result<Self, Self::Error> {
- Self::try_from_static_cow(value.into())
- }
+ fn try_from(s: String) -> Result<Self, Self::Error> { Self::try_from_stringly(&s) }
}
impl TryFrom<Box<str>> for CommandString {
type Error = CommandStringError;
- fn try_from(value: Box<str>) -> Result<Self, Self::Error> {
- Self::try_from_static_cow(String::from(value).into())
- }
+ fn try_from(s: Box<str>) -> Result<Self, Self::Error> { Self::try_from_stringly(&s) }
}
impl<'a> TryFrom<&'a str> for CommandString {
type Error = CommandStringError;
- fn try_from(value: &'a str) -> Result<Self, Self::Error> {
- Self::try_from_static_cow(value.to_owned().into())
- }
+ fn try_from(s: &'a str) -> Result<Self, Self::Error> { Self::try_from_stringly(&s) }
}
impl core::str::FromStr for CommandString {
type Err = CommandStringError;
- fn from_str(s: &str) -> Result<Self, Self::Err> {
- Self::try_from_static_cow(s.to_owned().into())
- }
-}
-
-impl fmt::Display for CommandString {
- fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { f.write_str(self.0.as_ref()) }
+ fn from_str(s: &str) -> Result<Self, Self::Err> { Self::try_from_stringly(&s) }
}
impl AsRef<str> for CommandString {
- fn as_ref(&self) -> &str { self.0.as_ref() }
+ fn as_ref(&self) -> &str {
+ // CommandStringDecode upholds the invarient that only valid
+ // ASCII characters will be decoded.
+ unsafe { std::str::from_utf8_unchecked(&self.0).trim_end_matches(&['\0'][..]) }
+ }
}
-impl encoding::Encode for CommandString {
- type Encoder<'e> = CommandStringEncoder;
-
- fn encoder(&self) -> Self::Encoder<'_> {
- let mut rawbytes = [0u8; 12];
- let strbytes = self.0.as_bytes();
- debug_assert!(strbytes.len() <= 12);
- rawbytes[..strbytes.len()].copy_from_slice(strbytes);
- CommandStringEncoder::without_length_prefix(rawbytes)
- }
+impl fmt::Display for CommandString {
+ fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { f.write_str(self.as_ref()) }
}
impl encoding::Decode for CommandString {
@@ -139,10 +126,8 @@ impl encoding::Decode for CommandString {
pub struct CommandStringEncoder(encoding::ArrayEncoder<12>);
impl CommandStringEncoder {
- /// Constructs an encoder which encodes the command string with no length prefix.
- pub const fn without_length_prefix(arr: [u8; 12]) -> Self {
- Self(encoding::ArrayEncoder::without_length_prefix(arr))
- }
+ /// Constructs a new instance of the newtype encoder.
+ pub(crate) const fn new(encoder: ArrayEncoder<12>) -> Self { Self(encoder) }
}
impl encoding::Encoder for CommandStringEncoder {
@@ -167,22 +152,31 @@ crate::decoder_newtype! {
CommandStringDecoderError::UnexpectedEof(err)
}
- fn end(
- result: Result<[u8; 12], encoding::UnexpectedEofError>
- ) -> Result<CommandString, CommandStringDecoderError> {
- let rawbytes = result.map_err(CommandStringDecoderError::UnexpectedEof)?;
- // Trim null padding from the end.
- let trimmed =
- rawbytes.iter().rposition(|&b| b != 0).map_or(&rawbytes[..0], |i| &rawbytes[..=i]);
-
- if !trimmed.is_ascii() {
+ fn end(result: Result<[u8; 12], encoding::UnexpectedEofError>) -> Result<CommandString, CommandStringDecoderError> {
+ let bytes = result.map_err(CommandStringDecoderError::UnexpectedEof)?;
+ if !bytes.is_ascii() {
return Err(CommandStringDecoderError::NotAscii);
}
+ Ok(CommandString(bytes))
+ }
+}
+
+impl encoding::Encode for CommandString {
+ type Encoder<'e>
+ = CommandStringEncoder
+ where
+ Self: 'e;
- Ok(CommandString(Cow::Owned(unsafe { String::from_utf8_unchecked(trimmed.to_vec()) })))
+ fn encoder(&self) -> Self::Encoder<'_> {
+ CommandStringEncoder::new(ArrayEncoder::without_length_prefix(self.0))
}
}
+impl CommandStringDecoder {
+ /// Constructs a new [`CommandString`] decoder.
+ pub fn new() -> Self { Self(encoding::ArrayDecoder::new()) }
+}
+
/// A v1 message header used to describe the incoming payload.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct V1MessageHeader {
@@ -208,10 +202,9 @@ impl V1MessageHeader {
/// # Panics
///
/// Panics if the payload length exceeds `u32::MAX`.
- pub fn new<T: encoding::Encode>(magic: Magic, message: &T, command: &'static str) -> Self {
+ pub fn new<T: encoding::Encode>(magic: Magic, message: &T, command: CommandString) -> Self {
let (bytes_hashed, checksum) = sha2_checksum(message);
let payload_len = u32::try_from(bytes_hashed).expect("network message use u32 as length");
- let command = CommandString::try_from_static(command).unwrap();
Self { magic, command, length: payload_len, checksum }
}
@@ -733,7 +726,7 @@ impl NetworkMessage {
pub fn command(&self) -> CommandString {
match *self {
Self::Unknown { command: ref c, .. } => c.clone(),
- _ => CommandString::try_from_static(self.cmd()).expect("cmd returns valid commands"),
+ _ => CommandString::try_from(self.cmd()).expect("cmd returns valid commands"),
}
}
}
@@ -1700,7 +1693,7 @@ impl V2NetworkMessageDecoder {
4u8 => Ok(E::CmpctBlock(bip152::HeaderAndShortIds::decoder())),
5u8 => Ok(E::FeeFilter(FeeFilter::decoder())),
6u8 => Ok(E::FilterAdd(message_bloom::FilterAdd::decoder())),
- 7u8 => Ok(E::Empty(CommandString::try_from_static("filterclear").map_err(|_| err)?)),
+ 7u8 => Ok(E::Empty(CommandString::try_from("filterclear").map_err(|_| err)?)),
8u8 => Ok(E::FilterLoad(message_bloom::FilterLoad::decoder())),
9u8 => Ok(E::GetBlocks(message_blockdata::GetBlocksMessage::decoder())),
10u8 => Ok(E::GetBlockTxn(bip152::BlockTransactionsRequest::decoder())),
@@ -1708,7 +1701,7 @@ impl V2NetworkMessageDecoder {
12u8 => Ok(E::GetHeaders(message_blockdata::GetHeadersMessage::decoder())),
13u8 => Ok(E::Headers(HeadersMessage::decoder())),
14u8 => Ok(E::Inv(InventoryPayload::decoder())),
- 15u8 => Ok(E::Empty(CommandString::try_from_static("mempool").map_err(|_| err)?)),
+ 15u8 => Ok(E::Empty(CommandString::try_from("mempool").map_err(|_| err)?)),
16u8 => Ok(E::MerkleBlock(MerkleBlock::decoder())),
17u8 => Ok(E::NotFound(InventoryPayload::decoder())),
18u8 => Ok(E::Ping(Ping::decoder())),
@@ -1772,7 +1765,7 @@ impl encoding::Decoder for V2NetworkMessageDecoder {
if id == 0 {
// Non-optimized: need to read 12-byte command string next.
self.state = V2NetworkMessageDecoderState::CommandString(
- CommandStringDecoder(encoding::ArrayDecoder::new()),
+ CommandStringDecoder::new(),
);
} else {
// Optimized short ID (1-28): skip command, go straight to payload.
@@ -1865,7 +1858,6 @@ fn sha2_checksum(data: &impl encoding::Encode) -> (u64, [u8; 4]) {
/// Error types for network messages.
pub mod error {
- use alloc::borrow::Cow;
use core::convert::Infallible;
use core::fmt;
@@ -1907,17 +1899,15 @@ pub mod error {
/// This is currently returned for command strings longer than 12.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
- pub struct CommandStringError {
- pub(super) cow: Cow<'static, str>,
- }
+ pub struct CommandStringError(pub alloc::string::String);
impl fmt::Display for CommandStringError {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
write!(
f,
"the command string '{}' has length {} which is larger than 12",
- self.cow,
- self.cow.len()
+ self.0,
+ self.0.len()
)
}
}
@@ -2266,7 +2256,8 @@ impl<'a> Arbitrary<'a> for InventoryPayload {
#[cfg(feature = "arbitrary")]
impl<'a> Arbitrary<'a> for CommandString {
fn arbitrary(u: &mut Unstructured<'a>) -> arbitrary::Result<Self> {
- Ok(Self(u.arbitrary::<String>()?.into()))
+ let s = u.arbitrary::<String>()?;
+ Self::try_from(s).map_err(|_| arbitrary::Error::IncorrectFormat)
}
}
@@ -2336,6 +2327,7 @@ impl<'a> Arbitrary<'a> for V1NetworkMessage {
#[cfg(test)]
mod test {
+ use alloc::borrow::ToOwned;
use alloc::string::ToString;
use alloc::vec;
use std::net::Ipv4Addr;
@@ -2365,11 +2357,12 @@ mod test {
let magic = Magic::BITCOIN;
let payload = Pong(314);
- let header = V1MessageHeader::new(magic, &payload, "pong");
+ let cmd = CommandString::try_from("pong").unwrap();
+ let header = V1MessageHeader::new(magic, &payload, cmd);
let target_header = V1MessageHeader {
magic: Magic::BITCOIN,
- command: CommandString::try_from_static("pong").unwrap(),
+ command: CommandString::try_from("pong").unwrap(),
length: 8,
checksum: [198, 34, 189, 120],
};
@@ -2521,14 +2514,11 @@ mod test {
#[test]
fn commandstring() {
// Test converting.
- assert_eq!(
- CommandString::try_from_static("AndrewAndrew").unwrap().as_ref(),
- "AndrewAndrew"
- );
- assert!(CommandString::try_from_static("AndrewAndrewA").is_err());
+ assert_eq!(CommandString::try_from("AndrewAndrew").unwrap().as_ref(), "AndrewAndrew");
+ assert!(CommandString::try_from("AndrewAndrewA").is_err());
// Test serializing.
- let cs = CommandString("Andrew".into());
+ let cs = CommandString::try_from("Andrew").unwrap();
assert_eq!(
encoding::encode_to_vec(&cs),
[0x41u8, 0x6e, 0x64, 0x72, 0x65, 0x77, 0, 0, 0, 0, 0, 0]
@@ -2539,14 +2529,14 @@ mod test {
encoding::decode_from_slice(&[0x41u8, 0x6e, 0x64, 0x72, 0x65, 0x77, 0, 0, 0, 0, 0, 0]);
assert!(cs.is_ok());
assert_eq!(cs.as_ref().unwrap().to_string(), "Andrew".to_owned());
- assert_eq!(cs.unwrap(), CommandString::try_from_static("Andrew").unwrap());
+ assert_eq!(cs.unwrap(), CommandString::try_from("Andrew").unwrap());
// Test that embedded null bytes are preserved while trailing nulls are trimmed
let cs: Result<CommandString, _> =
encoding::decode_from_slice(&[0, 0x41u8, 0x6e, 0x64, 0, 0x72, 0x65, 0x77, 0, 0, 0, 0]);
assert!(cs.is_ok());
assert_eq!(cs.as_ref().unwrap().to_string(), "\0And\0rew".to_owned());
- assert_eq!(cs.unwrap(), CommandString::try_from_static("\0And\0rew").unwrap());
+ assert_eq!(cs.unwrap(), CommandString::try_from("\0And\0rew").unwrap());
// Invalid CommandString, must be ASCII
assert!(encoding::decode_from_slice::<CommandString>(&[
@@ -2559,6 +2549,9 @@ mod test {
0x41u8, 0x6e, 0x64, 0x72, 0x65, 0x77, 0, 0, 0, 0, 0
])
.is_err());
+
+ let s = "\u{1F980}";
+ assert!(CommandString::try_from(s).is_err());
}
#[test]
@@ -2824,7 +2817,7 @@ mod test {
let data = hex!("010101010101");
let mut decoder =
- NetworkMessageDecoder::new(CommandString::try_from_static("unknown").unwrap(), 6);
+ NetworkMessageDecoder::new(CommandString::try_from("unknown").unwrap(), 6);
let _ = decoder.push_bytes(&mut data.as_slice());
let decoded = decoder.end().unwrap();
@@ -2865,7 +2858,7 @@ mod test {
fn command_string_encoder() {
use encoding::{Encode as _, ExactSizeEncoder as _};
- let cmd = CommandString::try_from_static("version").unwrap();
+ let cmd = CommandString::try_from("version").unwrap();
let expected_bytes: [u8; 12] = [b'v', b'e', b'r', b's', b'i', b'o', b'n', 0, 0, 0, 0, 0];
let mut encoder = cmd.encoder();
Why this scored 20/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.