diff --git a/cmd/crates/soroban-spec-tools/src/lib.rs b/cmd/crates/soroban-spec-tools/src/lib.rs index a12d5821b..348366f98 100644 --- a/cmd/crates/soroban-spec-tools/src/lib.rs +++ b/cmd/crates/soroban-spec-tools/src/lib.rs @@ -33,6 +33,10 @@ pub enum Error { InvalidPair(ScVal, ScType), #[error("value is not parseable to {0:#?}")] InvalidValue(Option), + #[error("invalid hex: expected an even number of hex digits, got {0}")] + OddHexLength(usize), + #[error("invalid length for BytesN<{expected}>: expected {expected} bytes but got {got} bytes")] + BytesNLengthMismatch { expected: usize, got: usize }, #[error("Unknown case {0} for {1}")] EnumCase(String, String), #[error("Enum {0} missing value for type {1}")] @@ -805,6 +809,15 @@ impl Spec { /// # Errors /// /// Might return an error +/// Decode a hex string that must be complete: an even number of digits, no +/// zero-padding (see #2244). Returns the raw bytes; callers enforce any length. +fn decode_complete_hex(s: &str, t: &ScType) -> Result, Error> { + if !s.len().is_multiple_of(2) { + return Err(Error::OddHexLength(s.len())); + } + hex::decode(s).map_err(|_| Error::InvalidValue(Some(t.clone()))) +} + pub fn from_string_primitive(s: &str, t: &ScType) -> Result { Spec::from_string_primitive(s, t) } @@ -921,9 +934,16 @@ pub fn from_json_primitives(v: &Value, t: &ScType) -> Result { return Ok(key); } } - // Bytes are not an address, just parse as a hex string - utils::padded_hex_from_str(s, bytes.n as usize) - .map_err(|_| Error::InvalidValue(Some(t.clone())))? + // Bytes are not an address, just parse as a hex string. The input must be + // complete and exactly N bytes: no right-align/zero-pad (see #2244). + let decoded = decode_complete_hex(s, t)?; + if decoded.len() != bytes.n as usize { + return Err(Error::BytesNLengthMismatch { + expected: bytes.n as usize, + got: decoded.len(), + }); + } + decoded .try_into() .map_err(|_| Error::InvalidValue(Some(t.clone())))? })), @@ -931,8 +951,7 @@ pub fn from_json_primitives(v: &Value, t: &ScType) -> Result { return Err(Error::InvalidValue(Some(t.clone()))); } (ScType::Bytes, Value::String(s)) => ScVal::Bytes( - hex::decode(s) - .map_err(|_| Error::InvalidValue(Some(t.clone())))? + decode_complete_hex(s, t)? .try_into() .map_err(|_| Error::InvalidValue(Some(t.clone())))?, ), @@ -1732,6 +1751,61 @@ mod tests { assert_eq!(to_string(&parsed).unwrap(), format!("\"{as_str}\"")); } + #[test] + fn test_bytesn_rejects_under_length() { + // #2244: a short value was silently zero-padded; it must now error as incomplete. + let r = from_string_primitive("beefface", &ScType::BytesN(ScSpecTypeBytesN { n: 9 })); + assert!(matches!( + r, + Err(Error::BytesNLengthMismatch { + expected: 9, + got: 4 + }) + )); + } + + #[test] + fn test_bytesn_rejects_over_length() { + let r = from_string_primitive( + "beeffacebeefface00ff", + &ScType::BytesN(ScSpecTypeBytesN { n: 9 }), + ); + assert!(matches!( + r, + Err(Error::BytesNLengthMismatch { + expected: 9, + got: 10 + }) + )); + } + + #[test] + fn test_bytesn_rejects_odd_hex() { + let r = from_string_primitive("beeffac", &ScType::BytesN(ScSpecTypeBytesN { n: 4 })); + assert!(matches!(r, Err(Error::OddHexLength(7)))); + } + + #[test] + fn test_bytes_rejects_odd_hex() { + let r = from_string_primitive("abc", &ScType::Bytes); + assert!(matches!(r, Err(Error::OddHexLength(3)))); + } + + #[test] + fn test_bytesn_exact_length_still_works() { + let parsed = from_string_primitive( + "beeffacebeefface00", + &ScType::BytesN(ScSpecTypeBytesN { n: 9 }), + ) + .unwrap(); + let expected = ScVal::Bytes(ScBytes( + vec![0xbe, 0xef, 0xfa, 0xce, 0xbe, 0xef, 0xfa, 0xce, 0x00] + .try_into() + .unwrap(), + )); + assert_eq!(parsed, expected); + } + #[test] fn test_bytes_conversion_array_input() { // Array of byte values should also work