icydb_core/db/cursor/
string.rs1#[cfg(test)]
7use crate::db::cursor::{GroupedContinuationToken, TokenWireError};
8use crate::db::{codec::hex::encode_hex_lower, cursor::token::MAX_CURSOR_TOKEN_BYTES};
9
10const MAX_CURSOR_TOKEN_HEX_LEN: usize = MAX_CURSOR_TOKEN_BYTES * 2;
13
14#[derive(Debug, Eq, PartialEq)]
21pub enum CursorDecodeError {
22 Empty,
23
24 TooLong { len: usize, max: usize },
25
26 OddLength,
27
28 InvalidHex { position: usize },
29}
30
31#[must_use]
33pub fn encode_cursor(bytes: &[u8]) -> String {
34 encode_hex_lower(bytes)
35}
36
37#[cfg(test)]
39pub(in crate::db) fn encode_grouped_cursor_token(
40 token: &GroupedContinuationToken,
41) -> Result<String, TokenWireError> {
42 token
43 .encode()
44 .map(|encoded| encode_cursor(encoded.as_slice()))
45}
46
47pub fn decode_cursor(token: &str) -> Result<Vec<u8>, CursorDecodeError> {
51 let token = token.trim();
53
54 if token.is_empty() {
55 return Err(CursorDecodeError::Empty);
56 }
57
58 if token.len() > MAX_CURSOR_TOKEN_HEX_LEN {
59 return Err(CursorDecodeError::TooLong {
60 len: token.len(),
61 max: MAX_CURSOR_TOKEN_HEX_LEN,
62 });
63 }
64
65 if !token.len().is_multiple_of(2) {
66 return Err(CursorDecodeError::OddLength);
67 }
68
69 let mut out = Vec::with_capacity(token.len() / 2);
71 let bytes = token.as_bytes();
72
73 for idx in (0..bytes.len()).step_by(2) {
74 let hi = decode_hex_nibble(bytes[idx])
75 .ok_or(CursorDecodeError::InvalidHex { position: idx + 1 })?;
76
77 let lo = decode_hex_nibble(bytes[idx + 1])
78 .ok_or(CursorDecodeError::InvalidHex { position: idx + 2 })?;
79
80 out.push((hi << 4) | lo);
81 }
82
83 Ok(out)
84}
85
86const fn decode_hex_nibble(byte: u8) -> Option<u8> {
87 match byte {
88 b'0'..=b'9' => Some(byte - b'0'),
89 b'a'..=b'f' => Some(byte - b'a' + 10),
90 b'A'..=b'F' => Some(byte - b'A' + 10),
91 _ => None,
92 }
93}
94
95#[cfg(test)]
100mod tests {
101 use crate::db::cursor::string::{
102 CursorDecodeError, MAX_CURSOR_TOKEN_HEX_LEN, decode_cursor, encode_cursor,
103 };
104
105 #[test]
106 fn decode_cursor_rejects_empty_and_whitespace_tokens() {
107 let err = decode_cursor("").expect_err("empty token should be rejected");
108 assert_eq!(err, CursorDecodeError::Empty);
109
110 let err = decode_cursor(" \n\t").expect_err("whitespace token should be rejected");
111 assert_eq!(err, CursorDecodeError::Empty);
112 }
113
114 #[test]
115 fn decode_cursor_rejects_odd_length_tokens() {
116 let err = decode_cursor("abc").expect_err("odd-length token should be rejected");
117 assert_eq!(err, CursorDecodeError::OddLength);
118 }
119
120 #[test]
121 fn decode_cursor_enforces_max_token_length() {
122 let accepted = "aa".repeat(MAX_CURSOR_TOKEN_HEX_LEN / 2);
123 let accepted_bytes = decode_cursor(&accepted).expect("max-sized token should decode");
124 assert_eq!(accepted_bytes.len(), MAX_CURSOR_TOKEN_HEX_LEN / 2);
125
126 let rejected = format!("{accepted}aa");
127 let err = decode_cursor(&rejected).expect_err("oversized token should be rejected");
128 assert_eq!(
129 err,
130 CursorDecodeError::TooLong {
131 len: MAX_CURSOR_TOKEN_HEX_LEN + 2,
132 max: MAX_CURSOR_TOKEN_HEX_LEN
133 }
134 );
135 }
136
137 #[test]
138 fn decode_cursor_rejects_invalid_hex_with_position() {
139 let err = decode_cursor("0x").expect_err("invalid hex nibble should be rejected");
140 assert_eq!(err, CursorDecodeError::InvalidHex { position: 2 });
141 }
142
143 #[test]
144 fn decode_cursor_accepts_mixed_case_and_surrounding_whitespace() {
145 let bytes = decode_cursor(" 0aFf10 ").expect("mixed-case hex token should decode");
146 assert_eq!(bytes, vec![0x0a, 0xff, 0x10]);
147 }
148
149 #[test]
150 fn encode_decode_cursor_round_trip_is_stable() {
151 let raw = vec![0x00, 0x01, 0x0a, 0xff];
152 let encoded = encode_cursor(&raw);
153 assert_eq!(encoded, "00010aff");
154
155 let decoded = decode_cursor(&encoded).expect("encoded token should decode");
156 assert_eq!(decoded, raw);
157 }
158}