icydb_core/db/index/key/
codec.rs1use crate::{
2 MAX_INDEX_FIELDS,
3 db::{identity::IndexName, index::key::IndexId},
4 traits::Storable,
5};
6use canic_cdk::structures::storable::Bound;
7use std::borrow::Cow;
8
9#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
18pub struct IndexKey {
19 pub(super) index_id: IndexId,
20 pub(super) len: u8,
21 pub(super) values: [[u8; 16]; MAX_INDEX_FIELDS],
22}
23
24#[expect(clippy::cast_possible_truncation)]
25impl IndexKey {
26 pub const STORED_SIZE_BYTES: u64 =
28 IndexName::STORED_SIZE_BYTES + 1 + (MAX_INDEX_FIELDS as u64 * 16);
29
30 pub const STORED_SIZE_USIZE: usize = Self::STORED_SIZE_BYTES as usize;
32
33 #[must_use]
34 pub fn to_raw(&self) -> RawIndexKey {
35 let mut bytes = [0u8; Self::STORED_SIZE_USIZE];
36
37 let name_bytes = self.index_id.0.to_bytes();
38 bytes[..name_bytes.len()].copy_from_slice(&name_bytes);
39
40 let mut offset = IndexName::STORED_SIZE_USIZE;
41 bytes[offset] = self.len;
42 offset += 1;
43
44 for value in &self.values {
45 bytes[offset..offset + 16].copy_from_slice(value);
46 offset += 16;
47 }
48
49 RawIndexKey(bytes)
50 }
51
52 pub fn try_from_raw(raw: &RawIndexKey) -> Result<Self, &'static str> {
53 let bytes = &raw.0;
54 if bytes.len() != Self::STORED_SIZE_USIZE {
55 return Err("corrupted IndexKey: invalid size");
56 }
57
58 let mut offset = 0;
59
60 let index_name =
61 IndexName::from_bytes(&bytes[offset..offset + IndexName::STORED_SIZE_USIZE])
62 .map_err(|_| "corrupted IndexKey: invalid IndexName bytes")?;
63 offset += IndexName::STORED_SIZE_USIZE;
64
65 let len = bytes[offset];
66 offset += 1;
67
68 if len as usize > MAX_INDEX_FIELDS {
69 return Err("corrupted IndexKey: invalid index length");
70 }
71
72 let mut values = [[0u8; 16]; MAX_INDEX_FIELDS];
73 for value in &mut values {
74 value.copy_from_slice(&bytes[offset..offset + 16]);
75 offset += 16;
76 }
77
78 let len_usize = len as usize;
79 for value in values.iter().skip(len_usize) {
80 if value.iter().any(|&byte| byte != 0) {
81 return Err("corrupted IndexKey: non-zero fingerprint padding");
82 }
83 }
84
85 Ok(Self {
86 index_id: IndexId(index_name),
87 len,
88 values,
89 })
90 }
91}
92
93#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
101pub struct RawIndexKey([u8; IndexKey::STORED_SIZE_USIZE]);
102
103impl RawIndexKey {
104 #[must_use]
106 pub const fn as_bytes(&self) -> &[u8; IndexKey::STORED_SIZE_USIZE] {
107 &self.0
108 }
109}
110
111impl Storable for RawIndexKey {
112 fn to_bytes(&self) -> Cow<'_, [u8]> {
113 Cow::Borrowed(&self.0)
114 }
115
116 fn from_bytes(bytes: Cow<'_, [u8]>) -> Self {
117 let mut out = [0u8; IndexKey::STORED_SIZE_USIZE];
118 if bytes.len() == out.len() {
119 out.copy_from_slice(bytes.as_ref());
120 }
121 Self(out)
122 }
123
124 fn into_bytes(self) -> Vec<u8> {
125 self.0.to_vec()
126 }
127
128 #[expect(clippy::cast_possible_truncation)]
129 const BOUND: Bound = Bound::Bounded {
130 max_size: IndexKey::STORED_SIZE_BYTES as u32,
131 is_fixed_size: true,
132 };
133}
134
135#[cfg(test)]
140mod tests {
141 use crate::{
142 MAX_INDEX_FIELDS,
143 db::{
144 identity::{EntityName, IndexName},
145 index::{IndexId, IndexKey, RawIndexKey},
146 },
147 traits::Storable,
148 };
149 use std::borrow::Cow;
150
151 #[test]
152 fn index_key_rejects_undersized_bytes() {
153 let bytes = vec![0u8; IndexKey::STORED_SIZE_USIZE - 1];
154 let raw = RawIndexKey::from_bytes(Cow::Borrowed(&bytes));
155 let err = IndexKey::try_from_raw(&raw).expect_err("undersized key should fail");
156 assert!(err.contains("corrupted"));
157 }
158
159 #[test]
160 fn index_key_rejects_oversized_bytes() {
161 let bytes = vec![0u8; IndexKey::STORED_SIZE_USIZE + 1];
162 let raw = RawIndexKey::from_bytes(Cow::Borrowed(&bytes));
163 let err = IndexKey::try_from_raw(&raw).expect_err("oversized key should fail");
164 assert!(err.contains("corrupted"));
165 }
166
167 #[test]
168 #[allow(clippy::cast_possible_truncation)]
169 fn index_key_rejects_len_over_max() {
170 let key = IndexKey::empty(IndexId::max_storable());
171 let raw = key.to_raw();
172 let len_offset = IndexName::STORED_SIZE_BYTES as usize;
173 let mut bytes = raw.as_bytes().to_vec();
174 bytes[len_offset] = (MAX_INDEX_FIELDS as u8) + 1;
175 let raw = RawIndexKey::from_bytes(Cow::Owned(bytes));
176 let err = IndexKey::try_from_raw(&raw).expect_err("oversized length should fail");
177 assert!(err.contains("corrupted"));
178 }
179
180 #[test]
181 fn index_key_rejects_invalid_index_name() {
182 let key = IndexKey::empty(IndexId::max_storable());
183 let raw = key.to_raw();
184 let mut bytes = raw.as_bytes().to_vec();
185 bytes[0] = 0;
186 bytes[1] = 0;
187 let raw = RawIndexKey::from_bytes(Cow::Owned(bytes));
188 let err = IndexKey::try_from_raw(&raw).expect_err("invalid index name should fail");
189 assert!(err.contains("corrupted"));
190 }
191
192 #[test]
193 fn index_key_rejects_fingerprint_padding() {
194 let key = IndexKey::empty(IndexId::max_storable());
195 let raw = key.to_raw();
196 let values_offset = IndexName::STORED_SIZE_USIZE + 1;
197 let mut bytes = raw.as_bytes().to_vec();
198 bytes[values_offset] = 1;
199 let raw = RawIndexKey::from_bytes(Cow::Owned(bytes));
200 let err = IndexKey::try_from_raw(&raw).expect_err("padding should fail");
201 assert!(err.contains("corrupted"));
202 }
203
204 #[test]
205 #[expect(clippy::large_types_passed_by_value)]
206 fn index_key_ordering_matches_bytes() {
207 fn make_key(index_id: IndexId, value_count: u8, first: u8, second: u8) -> IndexKey {
208 let mut bytes = [0u8; IndexKey::STORED_SIZE_USIZE];
209
210 let name_bytes = index_id.0.to_bytes();
211 bytes[..name_bytes.len()].copy_from_slice(&name_bytes);
212
213 let mut offset = IndexName::STORED_SIZE_USIZE;
214 bytes[offset] = value_count;
215 offset += 1;
216
217 let mut values = [[0u8; 16]; MAX_INDEX_FIELDS];
218 values[0] = [first; 16];
219 values[1] = [second; 16];
220
221 for value in values {
222 bytes[offset..offset + 16].copy_from_slice(&value);
223 offset += 16;
224 }
225
226 let raw = RawIndexKey::from_bytes(Cow::Borrowed(&bytes));
227 IndexKey::try_from_raw(&raw).expect("valid key bytes should decode")
228 }
229
230 let entity = EntityName::try_from_str("entity").expect("entity name should parse");
231 let idx_a = IndexId(IndexName::try_from_parts(&entity, &["a"]).expect("index name"));
232 let idx_b = IndexId(IndexName::try_from_parts(&entity, &["b"]).expect("index name"));
233
234 let keys = vec![
235 make_key(idx_a, 1, 1, 0),
236 make_key(idx_a, 2, 1, 2),
237 make_key(idx_a, 1, 2, 0),
238 make_key(idx_b, 1, 0, 0),
239 ];
240
241 let mut sorted_by_ord = keys.clone();
242 sorted_by_ord.sort();
243
244 let mut sorted_by_bytes = keys;
245 sorted_by_bytes.sort_by(|a, b| a.to_raw().as_bytes().cmp(b.to_raw().as_bytes()));
246
247 assert_eq!(sorted_by_ord, sorted_by_bytes);
248 }
249
250 #[test]
251 #[expect(clippy::cast_possible_truncation)]
252 fn index_key_decode_fuzz_roundtrip_is_canonical() {
253 const RUNS: u64 = 1_000;
254
255 let mut seed = 0xBADC_0FFE_u64;
256 for _ in 0..RUNS {
257 let mut bytes = [0u8; IndexKey::STORED_SIZE_BYTES as usize];
258 for byte in &mut bytes {
259 seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
260 *byte = (seed >> 24) as u8;
261 }
262
263 let raw = RawIndexKey::from_bytes(Cow::Borrowed(&bytes));
264 if let Ok(decoded) = IndexKey::try_from_raw(&raw) {
265 let reencoded = decoded.to_raw();
266 assert_eq!(raw.as_bytes(), reencoded.as_bytes());
267 }
268 }
269 }
270}