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 #[must_use]
94 pub(crate) fn uses_system_namespace(&self) -> bool {
95 self.index_id
96 .0
97 .as_str()
98 .split('|')
99 .skip(1)
100 .any(|segment| segment.starts_with('~'))
101 }
102}
103
104#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
112pub struct RawIndexKey([u8; IndexKey::STORED_SIZE_USIZE]);
113
114impl RawIndexKey {
115 #[must_use]
117 pub const fn as_bytes(&self) -> &[u8; IndexKey::STORED_SIZE_USIZE] {
118 &self.0
119 }
120}
121
122impl Storable for RawIndexKey {
123 fn to_bytes(&self) -> Cow<'_, [u8]> {
124 Cow::Borrowed(&self.0)
125 }
126
127 fn from_bytes(bytes: Cow<'_, [u8]>) -> Self {
128 let mut out = [0u8; IndexKey::STORED_SIZE_USIZE];
129 if bytes.len() == out.len() {
130 out.copy_from_slice(bytes.as_ref());
131 }
132 Self(out)
133 }
134
135 fn into_bytes(self) -> Vec<u8> {
136 self.0.to_vec()
137 }
138
139 #[expect(clippy::cast_possible_truncation)]
140 const BOUND: Bound = Bound::Bounded {
141 max_size: IndexKey::STORED_SIZE_BYTES as u32,
142 is_fixed_size: true,
143 };
144}
145
146#[cfg(test)]
151mod tests {
152 use crate::{
153 MAX_INDEX_FIELDS,
154 db::{
155 identity::{EntityName, IndexName},
156 index::{IndexId, IndexKey, RawIndexKey},
157 },
158 traits::Storable,
159 };
160 use std::borrow::Cow;
161
162 #[test]
163 fn index_key_rejects_undersized_bytes() {
164 let bytes = vec![0u8; IndexKey::STORED_SIZE_USIZE - 1];
165 let raw = RawIndexKey::from_bytes(Cow::Borrowed(&bytes));
166 let err = IndexKey::try_from_raw(&raw).expect_err("undersized key should fail");
167 assert!(err.contains("corrupted"));
168 }
169
170 #[test]
171 fn index_key_rejects_oversized_bytes() {
172 let bytes = vec![0u8; IndexKey::STORED_SIZE_USIZE + 1];
173 let raw = RawIndexKey::from_bytes(Cow::Borrowed(&bytes));
174 let err = IndexKey::try_from_raw(&raw).expect_err("oversized key should fail");
175 assert!(err.contains("corrupted"));
176 }
177
178 #[test]
179 #[allow(clippy::cast_possible_truncation)]
180 fn index_key_rejects_len_over_max() {
181 let key = IndexKey::empty(IndexId::max_storable());
182 let raw = key.to_raw();
183 let len_offset = IndexName::STORED_SIZE_BYTES as usize;
184 let mut bytes = raw.as_bytes().to_vec();
185 bytes[len_offset] = (MAX_INDEX_FIELDS as u8) + 1;
186 let raw = RawIndexKey::from_bytes(Cow::Owned(bytes));
187 let err = IndexKey::try_from_raw(&raw).expect_err("oversized length should fail");
188 assert!(err.contains("corrupted"));
189 }
190
191 #[test]
192 fn index_key_rejects_invalid_index_name() {
193 let key = IndexKey::empty(IndexId::max_storable());
194 let raw = key.to_raw();
195 let mut bytes = raw.as_bytes().to_vec();
196 bytes[0] = 0;
197 bytes[1] = 0;
198 let raw = RawIndexKey::from_bytes(Cow::Owned(bytes));
199 let err = IndexKey::try_from_raw(&raw).expect_err("invalid index name should fail");
200 assert!(err.contains("corrupted"));
201 }
202
203 #[test]
204 fn index_key_rejects_fingerprint_padding() {
205 let key = IndexKey::empty(IndexId::max_storable());
206 let raw = key.to_raw();
207 let values_offset = IndexName::STORED_SIZE_USIZE + 1;
208 let mut bytes = raw.as_bytes().to_vec();
209 bytes[values_offset] = 1;
210 let raw = RawIndexKey::from_bytes(Cow::Owned(bytes));
211 let err = IndexKey::try_from_raw(&raw).expect_err("padding should fail");
212 assert!(err.contains("corrupted"));
213 }
214
215 #[test]
216 #[expect(clippy::large_types_passed_by_value)]
217 fn index_key_ordering_matches_bytes() {
218 fn make_key(index_id: IndexId, value_count: u8, first: u8, second: u8) -> IndexKey {
219 let mut bytes = [0u8; IndexKey::STORED_SIZE_USIZE];
220
221 let name_bytes = index_id.0.to_bytes();
222 bytes[..name_bytes.len()].copy_from_slice(&name_bytes);
223
224 let mut offset = IndexName::STORED_SIZE_USIZE;
225 bytes[offset] = value_count;
226 offset += 1;
227
228 let mut values = [[0u8; 16]; MAX_INDEX_FIELDS];
229 values[0] = [first; 16];
230 values[1] = [second; 16];
231
232 for value in values {
233 bytes[offset..offset + 16].copy_from_slice(&value);
234 offset += 16;
235 }
236
237 let raw = RawIndexKey::from_bytes(Cow::Borrowed(&bytes));
238 IndexKey::try_from_raw(&raw).expect("valid key bytes should decode")
239 }
240
241 let entity = EntityName::try_from_str("entity").expect("entity name should parse");
242 let idx_a = IndexId(IndexName::try_from_parts(&entity, &["a"]).expect("index name"));
243 let idx_b = IndexId(IndexName::try_from_parts(&entity, &["b"]).expect("index name"));
244
245 let keys = vec![
246 make_key(idx_a, 1, 1, 0),
247 make_key(idx_a, 2, 1, 2),
248 make_key(idx_a, 1, 2, 0),
249 make_key(idx_b, 1, 0, 0),
250 ];
251
252 let mut sorted_by_ord = keys.clone();
253 sorted_by_ord.sort();
254
255 let mut sorted_by_bytes = keys;
256 sorted_by_bytes.sort_by(|a, b| a.to_raw().as_bytes().cmp(b.to_raw().as_bytes()));
257
258 assert_eq!(sorted_by_ord, sorted_by_bytes);
259 }
260
261 #[test]
262 #[expect(clippy::cast_possible_truncation)]
263 fn index_key_decode_fuzz_roundtrip_is_canonical() {
264 const RUNS: u64 = 1_000;
265
266 let mut seed = 0xBADC_0FFE_u64;
267 for _ in 0..RUNS {
268 let mut bytes = [0u8; IndexKey::STORED_SIZE_BYTES as usize];
269 for byte in &mut bytes {
270 seed = seed.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
271 *byte = (seed >> 24) as u8;
272 }
273
274 let raw = RawIndexKey::from_bytes(Cow::Borrowed(&bytes));
275 if let Ok(decoded) = IndexKey::try_from_raw(&raw) {
276 let reencoded = decoded.to_raw();
277 assert_eq!(raw.as_bytes(), reencoded.as_bytes());
278 }
279 }
280 }
281
282 #[test]
283 fn index_key_detects_system_namespace() {
284 let entity = EntityName::try_from_str("entity").expect("entity name should parse");
285
286 let user_id = IndexId(IndexName::try_from_parts(&entity, &["email"]).expect("index name"));
287 let user_key = IndexKey::empty(user_id);
288 assert!(!user_key.uses_system_namespace());
289
290 let system_id = IndexId(IndexName::try_from_parts(&entity, &["~ri"]).expect("index name"));
291 let system_key = IndexKey::empty(system_id);
292 assert!(system_key.uses_system_namespace());
293
294 let nested_system_id = IndexId(
295 IndexName::try_from_parts(&entity, &["email", "~ri_shadow"]).expect("index name"),
296 );
297 let nested_system_key = IndexKey::empty(nested_system_id);
298 assert!(nested_system_key.uses_system_namespace());
299 }
300}