Skip to main content

ic_storage/
storable.rs

1//! Storage-owned record codec contract. Application records normally use IC Wire.
2use std::borrow::Cow;
3
4#[cfg(test)]
5mod tests;
6
7///
8/// Bound
9///
10/// Maximum encoded record size, with an optional exact-width requirement.
11///
12#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13pub enum Bound {
14    /// No type-level bound; collection resource limits still apply.
15    Unbounded,
16    /// A finite encoded-size limit.
17    Bounded {
18        /// Maximum encoded byte count.
19        max_size: u32,
20        /// Whether every encoding must have exactly the maximum length.
21        is_fixed_size: bool,
22    },
23}
24
25impl Bound {
26    pub(crate) const fn check(self, bytes: &[u8]) -> Result<(), CodecError> {
27        if let Self::Bounded {
28            max_size,
29            is_fixed_size,
30        } = self
31            && !(bytes.len() <= max_size as usize
32                && (!is_fixed_size || bytes.len() == max_size as usize))
33        {
34            return Err(CodecError::Bounds);
35        }
36        Ok(())
37    }
38}
39
40///
41/// `CodecError`
42///
43/// Invalid application encoding or a violation of its declared byte bound.
44///
45
46#[derive(Clone, Copy, Debug, Eq, PartialEq, thiserror::Error)]
47pub enum CodecError {
48    /// The payload does not satisfy the codec.
49    #[error("invalid encoding: {0}")]
50    Invalid(&'static str),
51    /// Encoded bytes violate the declared maximum or fixed width.
52    #[error("record violates codec bound")]
53    Bounds,
54}
55
56///
57/// Storable
58///
59/// Encode and decode a collection record. The caller owns its schema; Storage
60/// enforces its declared byte bound before writes and after reading bytes.
61/// Collection checks are independent of overrides of the convenience helpers.
62///
63pub trait Storable: Sized {
64    /// Record size contract.
65    const BOUND: Bound;
66    /// Borrow or allocate an encoding.
67    ///
68    /// # Errors
69    /// Returns a codec error if the value cannot be represented by the implementation.
70    fn to_bytes(&self) -> Result<Cow<'_, [u8]>, CodecError>;
71    /// Consume a value into its encoding.
72    ///
73    /// # Errors
74    /// Returns the codec error from encoding this value.
75    fn into_bytes(self) -> Result<Vec<u8>, CodecError> {
76        Ok(self.to_bytes()?.into_owned())
77    }
78    /// Decode application bytes, returning an error for invalid encodings.
79    ///
80    /// # Errors
81    /// Returns a codec error if the bytes are not a valid value of this type.
82    fn from_bytes(bytes: Cow<'_, [u8]>) -> Result<Self, CodecError>;
83    /// Enforce the declared maximum and exact width where applicable.
84    ///
85    /// # Errors
86    /// Returns `CodecError::Bounds` when the declared maximum or exact width is violated.
87    fn check_bounds(bytes: &[u8]) -> Result<(), CodecError> {
88        Self::BOUND.check(bytes)
89    }
90    /// Encode and enforce the declared bound.
91    ///
92    /// # Errors
93    /// Returns an encoding error or `CodecError::Bounds` if the encoded bytes violate the declared bound.
94    fn to_bytes_checked(&self) -> Result<Cow<'_, [u8]>, CodecError> {
95        let bytes = self.to_bytes()?;
96        Self::BOUND.check(&bytes)?;
97        Ok(bytes)
98    }
99    /// Consume, encode and enforce the declared bound.
100    ///
101    /// # Errors
102    /// Returns an encoding error or `CodecError::Bounds` if the encoded bytes violate the declared bound.
103    fn into_bytes_checked(self) -> Result<Vec<u8>, CodecError> {
104        let bytes = self.into_bytes()?;
105        Self::BOUND.check(&bytes)?;
106        Ok(bytes)
107    }
108}
109
110macro_rules! integer {
111    ($($ty:ty),* $(,)?) => {$ (
112        impl Storable for $ty {
113            #[expect(clippy::cast_possible_truncation, reason = "primitive widths are at most 16 bytes")]
114            const BOUND: Bound = Bound::Bounded { max_size: size_of::<Self>() as u32, is_fixed_size: true };
115            fn to_bytes(&self) -> Result<Cow<'_, [u8]>, CodecError> { Ok(Cow::Owned(self.to_le_bytes().to_vec())) }
116            fn from_bytes(bytes: Cow<'_, [u8]>) -> Result<Self, CodecError> { Ok(Self::from_le_bytes(bytes.as_ref().try_into().map_err(|_| CodecError::Invalid("integer width"))?)) }
117        }
118    )*};
119}
120integer!(u16, u32, u64, u128, i8, i16, i32, i64, i128, f32, f64);
121impl Storable for u8 {
122    const BOUND: Bound = Bound::Bounded {
123        max_size: 1,
124        is_fixed_size: true,
125    };
126    fn to_bytes(&self) -> Result<Cow<'_, [u8]>, CodecError> {
127        Ok(Cow::Borrowed(std::slice::from_ref(self)))
128    }
129    fn from_bytes(bytes: Cow<'_, [u8]>) -> Result<Self, CodecError> {
130        Ok(Self::from_le_bytes(
131            bytes
132                .as_ref()
133                .try_into()
134                .map_err(|_| CodecError::Invalid("integer width"))?,
135        ))
136    }
137}
138impl Storable for () {
139    const BOUND: Bound = Bound::Bounded {
140        max_size: 0,
141        is_fixed_size: true,
142    };
143    fn to_bytes(&self) -> Result<Cow<'_, [u8]>, CodecError> {
144        Ok(Cow::Borrowed(&[]))
145    }
146    fn from_bytes(bytes: Cow<'_, [u8]>) -> Result<Self, CodecError> {
147        Self::check_bounds(&bytes)
148    }
149}
150impl Storable for bool {
151    const BOUND: Bound = Bound::Bounded {
152        max_size: 1,
153        is_fixed_size: true,
154    };
155    fn to_bytes(&self) -> Result<Cow<'_, [u8]>, CodecError> {
156        Ok(Cow::Borrowed(if *self { &[1] } else { &[0] }))
157    }
158    fn from_bytes(bytes: Cow<'_, [u8]>) -> Result<Self, CodecError> {
159        match bytes.as_ref() {
160            [0] => Ok(false),
161            [1] => Ok(true),
162            _ => Err(CodecError::Invalid("boolean encoding")),
163        }
164    }
165}
166impl Storable for Vec<u8> {
167    const BOUND: Bound = Bound::Unbounded;
168    fn to_bytes(&self) -> Result<Cow<'_, [u8]>, CodecError> {
169        Ok(Cow::Borrowed(self))
170    }
171    fn into_bytes(self) -> Result<Vec<u8>, CodecError> {
172        Ok(self)
173    }
174    fn from_bytes(bytes: Cow<'_, [u8]>) -> Result<Self, CodecError> {
175        Ok(bytes.into_owned())
176    }
177}
178impl Storable for String {
179    const BOUND: Bound = Bound::Unbounded;
180    fn to_bytes(&self) -> Result<Cow<'_, [u8]>, CodecError> {
181        Ok(Cow::Borrowed(self.as_bytes()))
182    }
183    fn into_bytes(self) -> Result<Vec<u8>, CodecError> {
184        Ok(self.into_bytes())
185    }
186    fn from_bytes(bytes: Cow<'_, [u8]>) -> Result<Self, CodecError> {
187        Self::from_utf8(bytes.into_owned()).map_err(|_| CodecError::Invalid("UTF-8 record"))
188    }
189}
190impl<const N: usize> Storable for [u8; N] {
191    #[expect(
192        clippy::cast_possible_truncation,
193        reason = "the const assertion admits only u32 widths"
194    )]
195    const BOUND: Bound = Bound::Bounded {
196        max_size: {
197            assert!(N <= u32::MAX as usize);
198            N as u32
199        },
200        is_fixed_size: true,
201    };
202    fn to_bytes(&self) -> Result<Cow<'_, [u8]>, CodecError> {
203        Ok(Cow::Borrowed(self))
204    }
205    fn from_bytes(bytes: Cow<'_, [u8]>) -> Result<Self, CodecError> {
206        bytes
207            .as_ref()
208            .try_into()
209            .map_err(|_| CodecError::Invalid("byte array width"))
210    }
211}
212impl<T: Storable> Storable for Option<T> {
213    const BOUND: Bound = match T::BOUND {
214        Bound::Unbounded => Bound::Unbounded,
215        Bound::Bounded { max_size, .. } => Bound::Bounded {
216            max_size: max_size.checked_add(1).expect("optional bound overflow"),
217            is_fixed_size: false,
218        },
219    };
220    fn to_bytes(&self) -> Result<Cow<'_, [u8]>, CodecError> {
221        let mut bytes = vec![u8::from(self.is_some())];
222        if let Some(value) = self {
223            bytes.extend_from_slice(&value.to_bytes_checked()?);
224        }
225        Ok(Cow::Owned(bytes))
226    }
227    fn from_bytes(bytes: Cow<'_, [u8]>) -> Result<Self, CodecError> {
228        match bytes.as_ref().split_first() {
229            Some((0, [])) => Ok(None),
230            Some((1, payload)) => {
231                T::check_bounds(payload)?;
232                T::from_bytes(Cow::Borrowed(payload)).map(Some)
233            }
234            _ => Err(CodecError::Invalid("optional record tag")),
235        }
236    }
237}