icydb_core/db/codec/
cursor.rs1use crate::db::codec::hex::encode_hex_lower;
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 encode_hex_lower(bytes)
34}
35
36pub fn decode_cursor(token: &str) -> Result<Vec<u8>, CursorDecodeError> {
40 let token = token.trim();
42
43 if token.is_empty() {
44 return Err(CursorDecodeError::Empty);
45 }
46
47 if token.len() > MAX_CURSOR_TOKEN_HEX_LEN {
48 return Err(CursorDecodeError::TooLong {
49 len: token.len(),
50 max: MAX_CURSOR_TOKEN_HEX_LEN,
51 });
52 }
53
54 if !token.len().is_multiple_of(2) {
55 return Err(CursorDecodeError::OddLength);
56 }
57
58 let mut out = Vec::with_capacity(token.len() / 2);
60 let bytes = token.as_bytes();
61
62 for idx in (0..bytes.len()).step_by(2) {
63 let hi = decode_hex_nibble(bytes[idx])
64 .ok_or(CursorDecodeError::InvalidHex { position: idx + 1 })?;
65
66 let lo = decode_hex_nibble(bytes[idx + 1])
67 .ok_or(CursorDecodeError::InvalidHex { position: idx + 2 })?;
68
69 out.push((hi << 4) | lo);
70 }
71
72 Ok(out)
73}
74
75const fn decode_hex_nibble(byte: u8) -> Option<u8> {
76 match byte {
77 b'0'..=b'9' => Some(byte - b'0'),
78 b'a'..=b'f' => Some(byte - b'a' + 10),
79 b'A'..=b'F' => Some(byte - b'A' + 10),
80 _ => None,
81 }
82}
83
84#[cfg(test)]
89mod tests {
90 use super::{CursorDecodeError, MAX_CURSOR_TOKEN_HEX_LEN, decode_cursor, encode_cursor};
91
92 #[test]
93 fn decode_cursor_rejects_empty_and_whitespace_tokens() {
94 let err = decode_cursor("").expect_err("empty token should be rejected");
95 assert_eq!(err, CursorDecodeError::Empty);
96
97 let err = decode_cursor(" \n\t").expect_err("whitespace token should be rejected");
98 assert_eq!(err, CursorDecodeError::Empty);
99 }
100
101 #[test]
102 fn decode_cursor_rejects_odd_length_tokens() {
103 let err = decode_cursor("abc").expect_err("odd-length token should be rejected");
104 assert_eq!(err, CursorDecodeError::OddLength);
105 }
106
107 #[test]
108 fn decode_cursor_enforces_max_token_length() {
109 let accepted = "aa".repeat(MAX_CURSOR_TOKEN_HEX_LEN / 2);
110 let accepted_bytes = decode_cursor(&accepted).expect("max-sized token should decode");
111 assert_eq!(accepted_bytes.len(), MAX_CURSOR_TOKEN_HEX_LEN / 2);
112
113 let rejected = format!("{accepted}aa");
114 let err = decode_cursor(&rejected).expect_err("oversized token should be rejected");
115 assert_eq!(
116 err,
117 CursorDecodeError::TooLong {
118 len: MAX_CURSOR_TOKEN_HEX_LEN + 2,
119 max: MAX_CURSOR_TOKEN_HEX_LEN
120 }
121 );
122 }
123
124 #[test]
125 fn decode_cursor_rejects_invalid_hex_with_position() {
126 let err = decode_cursor("0x").expect_err("invalid hex nibble should be rejected");
127 assert_eq!(err, CursorDecodeError::InvalidHex { position: 2 });
128 }
129
130 #[test]
131 fn decode_cursor_accepts_mixed_case_and_surrounding_whitespace() {
132 let bytes = decode_cursor(" 0aFf10 ").expect("mixed-case hex token should decode");
133 assert_eq!(bytes, vec![0x0a, 0xff, 0x10]);
134 }
135
136 #[test]
137 fn encode_decode_cursor_round_trip_is_stable() {
138 let raw = vec![0x00, 0x01, 0x0a, 0xff];
139 let encoded = encode_cursor(&raw);
140 assert_eq!(encoded, "00010aff");
141
142 let decoded = decode_cursor(&encoded).expect("encoded token should decode");
143 assert_eq!(decoded, raw);
144 }
145}