use alloc::boxed::Box;
use alloc::string::String;
use alloc::vec::Vec;
use miden_protocol::{Felt, WORD_SIZE, Word};
const BYTES_PER_FELT: usize = 7;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FixedWidthString<const N: usize>(Box<str>);
impl<const N: usize> Default for FixedWidthString<N> {
fn default() -> Self {
Self("".into())
}
}
const MAX_PAYLOAD_BYTES: usize = 251;
impl<const N: usize> FixedWidthString<N> {
const _CAPACITY_FITS_LENGTH_PREFIX: () = assert!(N <= 9);
pub const CAPACITY: usize =
N * 4 * BYTES_PER_FELT - 1 + (Self::_CAPACITY_FITS_LENGTH_PREFIX, 0).1;
pub fn new(value: &str) -> Result<Self, FixedWidthStringError> {
if value.len() > Self::CAPACITY {
return Err(FixedWidthStringError::TooLong {
actual: value.len(),
max: Self::CAPACITY,
});
}
Ok(Self(value.into()))
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn to_words(&self) -> Vec<Word> {
let n_felts = N * WORD_SIZE;
let buf_len = n_felts * BYTES_PER_FELT;
let bytes = self.0.as_bytes();
debug_assert!(bytes.len() < buf_len);
let mut buf = alloc::vec![0u8; buf_len];
buf[0] = bytes.len() as u8;
buf[1..1 + bytes.len()].copy_from_slice(bytes);
(0..N)
.map(|word_idx| {
let felts: [Felt; 4] = core::array::from_fn(|felt_idx| {
let start = (word_idx * 4 + felt_idx) * BYTES_PER_FELT;
let mut le_bytes = [0u8; 8];
le_bytes[..BYTES_PER_FELT].copy_from_slice(&buf[start..start + BYTES_PER_FELT]);
Felt::try_from(u64::from_le_bytes(le_bytes))
.expect("7-byte LE value always fits in a Goldilocks felt")
});
Word::from(felts)
})
.collect()
}
pub fn try_from_words(words: &[Word]) -> Result<Self, FixedWidthStringError> {
if words.len() != N {
return Err(FixedWidthStringError::InvalidLength { expected: N, got: words.len() });
}
let n_felts = N * WORD_SIZE;
let buf_len = n_felts * BYTES_PER_FELT;
let mut buf = alloc::vec![0u8; buf_len];
for (word_idx, word) in words.iter().enumerate() {
for (felt_idx, felt) in word.as_slice().iter().enumerate() {
let felt_value = felt.as_canonical_u64();
let le_bytes = felt_value.to_le_bytes();
if le_bytes[BYTES_PER_FELT] != 0 {
return Err(FixedWidthStringError::InvalidPadding);
}
let start = (word_idx * 4 + felt_idx) * BYTES_PER_FELT;
buf[start..start + BYTES_PER_FELT].copy_from_slice(&le_bytes[..BYTES_PER_FELT]);
}
}
let len = buf[0] as usize;
if len > MAX_PAYLOAD_BYTES {
return Err(FixedWidthStringError::InvalidLengthPrefix);
}
if len + 1 > buf_len {
return Err(FixedWidthStringError::InvalidLengthPrefix);
}
String::from_utf8(buf[1..1 + len].to_vec())
.map_err(FixedWidthStringError::InvalidUtf8)
.map(|s| Self(s.into()))
}
}
#[derive(Debug, Clone, thiserror::Error)]
pub enum FixedWidthStringError {
#[error("string must be at most {max} bytes, got {actual}")]
TooLong { actual: usize, max: usize },
#[error("string is not valid UTF-8")]
InvalidUtf8(#[source] alloc::string::FromUtf8Error),
#[error("felt high byte is non-zero (invalid padding)")]
InvalidPadding,
#[error("length prefix is invalid or exceeds buffer capacity")]
InvalidLengthPrefix,
#[error("expected {expected} words, got {got}")]
InvalidLength { expected: usize, got: usize },
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_string_roundtrip() {
let s: FixedWidthString<2> = FixedWidthString::new("").unwrap();
let words = s.to_words();
assert_eq!(words.len(), 2);
let decoded = FixedWidthString::<2>::try_from_words(&words).unwrap();
assert_eq!(decoded.as_str(), "");
}
#[test]
fn ascii_roundtrip_2_words() {
let s = FixedWidthString::<2>::new("hello").unwrap();
let decoded = FixedWidthString::<2>::try_from_words(&s.to_words()).unwrap();
assert_eq!(decoded.as_str(), "hello");
}
#[test]
fn ascii_roundtrip_7_words() {
let text = "A longer description that spans many felts";
let s = FixedWidthString::<7>::new(text).unwrap();
let decoded = FixedWidthString::<7>::try_from_words(&s.to_words()).unwrap();
assert_eq!(decoded.as_str(), text);
}
#[test]
fn utf8_multibyte_roundtrip() {
let s = FixedWidthString::<2>::new("café").unwrap();
let decoded = FixedWidthString::<2>::try_from_words(&s.to_words()).unwrap();
assert_eq!(decoded.as_str(), "café");
}
#[test]
fn exactly_at_capacity_accepted() {
let cap = FixedWidthString::<2>::CAPACITY; let s = "a".repeat(cap);
assert!(FixedWidthString::<2>::new(&s).is_ok());
}
#[test]
fn one_over_capacity_rejected() {
let cap = FixedWidthString::<2>::CAPACITY;
let s = "a".repeat(cap + 1);
assert!(matches!(
FixedWidthString::<2>::new(&s),
Err(FixedWidthStringError::TooLong { .. })
));
}
#[test]
fn capacity_7_words() {
assert_eq!(FixedWidthString::<7>::CAPACITY, 195);
let s = "b".repeat(195);
let fw = FixedWidthString::<7>::new(&s).unwrap();
let decoded = FixedWidthString::<7>::try_from_words(&fw.to_words()).unwrap();
assert_eq!(decoded.as_str(), s);
}
#[test]
fn capacity_9_words_is_max() {
assert_eq!(FixedWidthString::<9>::CAPACITY, 251);
let s = "x".repeat(251);
let fw = FixedWidthString::<9>::new(&s).unwrap();
let decoded = FixedWidthString::<9>::try_from_words(&fw.to_words()).unwrap();
assert_eq!(decoded.as_str(), s);
}
#[test]
#[allow(clippy::assertions_on_constants)]
fn n10_would_exceed_length_prefix() {
assert!(10 * 4 * BYTES_PER_FELT - 1 > MAX_PAYLOAD_BYTES);
}
#[test]
fn to_words_returns_correct_count() {
let s = FixedWidthString::<7>::new("test").unwrap();
assert_eq!(s.to_words().len(), 7);
}
#[test]
fn wrong_word_count_returns_error() {
let s = FixedWidthString::<2>::new("hi").unwrap();
let words = s.to_words();
assert!(matches!(
FixedWidthString::<2>::try_from_words(&words[..1]),
Err(FixedWidthStringError::InvalidLength { expected: 2, got: 1 })
));
}
#[test]
fn length_prefix_overflow_returns_invalid_length_prefix() {
let overflow_len = Felt::try_from(0xff_u64).unwrap();
let words = [
Word::from([overflow_len, Felt::ZERO, Felt::ZERO, Felt::ZERO]),
Word::from([Felt::ZERO, Felt::ZERO, Felt::ZERO, Felt::ZERO]),
];
assert!(matches!(
FixedWidthString::<2>::try_from_words(&words),
Err(FixedWidthStringError::InvalidLengthPrefix)
));
}
#[test]
fn felt_with_high_byte_set_returns_invalid_padding() {
let high_byte_non_zero = Felt::try_from(2u64.pow(63)).unwrap();
let words = [
Word::from([Felt::ZERO, high_byte_non_zero, Felt::ZERO, Felt::ZERO]),
Word::from([Felt::ZERO, Felt::ZERO, Felt::ZERO, Felt::ZERO]),
];
assert!(matches!(
FixedWidthString::<2>::try_from_words(&words),
Err(FixedWidthStringError::InvalidPadding)
));
}
#[test]
fn non_utf8_bytes_return_invalid_utf8() {
let raw: u64 = 0x0000_0000_0000_ff01;
let bad_felt = Felt::try_from(raw).unwrap();
let words = [
Word::from([bad_felt, Felt::ZERO, Felt::ZERO, Felt::ZERO]),
Word::from([Felt::ZERO, Felt::ZERO, Felt::ZERO, Felt::ZERO]),
];
assert!(matches!(
FixedWidthString::<2>::try_from_words(&words),
Err(FixedWidthStringError::InvalidUtf8(_))
));
}
#[test]
fn default_is_empty_string() {
let s: FixedWidthString<2> = FixedWidthString::default();
assert_eq!(s.as_str(), "");
}
}