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