icydb_core/db/codec/
cursor.rs1use crate::db::cursor::ContinuationSignature;
7
8const MAX_CURSOR_TOKEN_HEX_LEN: usize = 8 * 1024;
10
11#[derive(Debug, Eq, thiserror::Error, PartialEq)]
16pub enum CursorDecodeError {
17 #[error("cursor token is empty")]
18 Empty,
19
20 #[error("cursor token exceeds max length: {len} hex chars (max {max})")]
21 TooLong { len: usize, max: usize },
22
23 #[error("cursor token must have an even number of hex characters")]
24 OddLength,
25
26 #[error("invalid hex character at position {position}")]
27 InvalidHex { position: usize },
28}
29
30#[must_use]
32pub fn encode_cursor(bytes: &[u8]) -> String {
33 const HEX: &[u8; 16] = b"0123456789abcdef";
34
35 let mut out = String::with_capacity(bytes.len() * 2);
40
41 for byte in bytes {
42 out.push(HEX[(byte >> 4) as usize] as char);
43 out.push(HEX[(byte & 0x0f) as usize] as char);
44 }
45
46 out
47}
48
49impl ContinuationSignature {
50 #[must_use]
52 pub fn as_hex(&self) -> String {
53 encode_cursor(&self.into_bytes())
54 }
55}
56
57impl std::fmt::Display for ContinuationSignature {
58 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59 f.write_str(&self.as_hex())
60 }
61}
62
63pub fn decode_cursor(token: &str) -> Result<Vec<u8>, CursorDecodeError> {
67 let token = token.trim();
69
70 if token.is_empty() {
71 return Err(CursorDecodeError::Empty);
72 }
73
74 if token.len() > MAX_CURSOR_TOKEN_HEX_LEN {
75 return Err(CursorDecodeError::TooLong {
76 len: token.len(),
77 max: MAX_CURSOR_TOKEN_HEX_LEN,
78 });
79 }
80
81 if !token.len().is_multiple_of(2) {
82 return Err(CursorDecodeError::OddLength);
83 }
84
85 let mut out = Vec::with_capacity(token.len() / 2);
87 let bytes = token.as_bytes();
88
89 for idx in (0..bytes.len()).step_by(2) {
90 let hi = decode_hex_nibble(bytes[idx])
91 .ok_or(CursorDecodeError::InvalidHex { position: idx + 1 })?;
92
93 let lo = decode_hex_nibble(bytes[idx + 1])
94 .ok_or(CursorDecodeError::InvalidHex { position: idx + 2 })?;
95
96 out.push((hi << 4) | lo);
97 }
98
99 Ok(out)
100}
101
102const fn decode_hex_nibble(byte: u8) -> Option<u8> {
103 match byte {
104 b'0'..=b'9' => Some(byte - b'0'),
105 b'a'..=b'f' => Some(byte - b'a' + 10),
106 b'A'..=b'F' => Some(byte - b'A' + 10),
107 _ => None,
108 }
109}
110
111#[cfg(test)]
116mod tests {
117 use super::{CursorDecodeError, MAX_CURSOR_TOKEN_HEX_LEN, decode_cursor, encode_cursor};
118
119 #[test]
120 fn decode_cursor_rejects_empty_and_whitespace_tokens() {
121 let err = decode_cursor("").expect_err("empty token should be rejected");
122 assert_eq!(err, CursorDecodeError::Empty);
123
124 let err = decode_cursor(" \n\t").expect_err("whitespace token should be rejected");
125 assert_eq!(err, CursorDecodeError::Empty);
126 }
127
128 #[test]
129 fn decode_cursor_rejects_odd_length_tokens() {
130 let err = decode_cursor("abc").expect_err("odd-length token should be rejected");
131 assert_eq!(err, CursorDecodeError::OddLength);
132 }
133
134 #[test]
135 fn decode_cursor_enforces_max_token_length() {
136 let accepted = "aa".repeat(MAX_CURSOR_TOKEN_HEX_LEN / 2);
137 let accepted_bytes = decode_cursor(&accepted).expect("max-sized token should decode");
138 assert_eq!(accepted_bytes.len(), MAX_CURSOR_TOKEN_HEX_LEN / 2);
139
140 let rejected = format!("{accepted}aa");
141 let err = decode_cursor(&rejected).expect_err("oversized token should be rejected");
142 assert_eq!(
143 err,
144 CursorDecodeError::TooLong {
145 len: MAX_CURSOR_TOKEN_HEX_LEN + 2,
146 max: MAX_CURSOR_TOKEN_HEX_LEN
147 }
148 );
149 }
150
151 #[test]
152 fn decode_cursor_rejects_invalid_hex_with_position() {
153 let err = decode_cursor("0x").expect_err("invalid hex nibble should be rejected");
154 assert_eq!(err, CursorDecodeError::InvalidHex { position: 2 });
155 }
156
157 #[test]
158 fn decode_cursor_accepts_mixed_case_and_surrounding_whitespace() {
159 let bytes = decode_cursor(" 0aFf10 ").expect("mixed-case hex token should decode");
160 assert_eq!(bytes, vec![0x0a, 0xff, 0x10]);
161 }
162
163 #[test]
164 fn encode_decode_cursor_round_trip_is_stable() {
165 let raw = vec![0x00, 0x01, 0x0a, 0xff];
166 let encoded = encode_cursor(&raw);
167 assert_eq!(encoded, "00010aff");
168
169 let decoded = decode_cursor(&encoded).expect("encoded token should decode");
170 assert_eq!(decoded, raw);
171 }
172}