Skip to main content

icydb_core/db/codec/
cursor.rs

1///
2/// Cursor codec helpers.
3///
4/// This module owns the opaque wire-token format used for continuation cursors.
5/// It intentionally contains only token encoding/decoding logic and no query semantics.
6///
7use crate::db::cursor::ContinuationSignature;
8
9// Defensive decode bound for untrusted cursor token input.
10const MAX_CURSOR_TOKEN_HEX_LEN: usize = 8 * 1024;
11
12///
13/// CursorDecodeError
14///
15
16#[derive(Debug, Eq, thiserror::Error, PartialEq)]
17pub enum CursorDecodeError {
18    #[error("cursor token is empty")]
19    Empty,
20
21    #[error("cursor token exceeds max length: {len} hex chars (max {max})")]
22    TooLong { len: usize, max: usize },
23
24    #[error("cursor token must have an even number of hex characters")]
25    OddLength,
26
27    #[error("invalid hex character at position {position}")]
28    InvalidHex { position: usize },
29}
30
31/// Encode raw cursor bytes as a lowercase hex token.
32#[must_use]
33pub fn encode_cursor(bytes: &[u8]) -> String {
34    let mut out = String::with_capacity(bytes.len() * 2);
35    for byte in bytes {
36        use std::fmt::Write as _;
37        let _ = write!(out, "{byte:02x}");
38    }
39    out
40}
41
42impl ContinuationSignature {
43    /// Encode this signature as a lowercase hex token.
44    #[must_use]
45    pub fn as_hex(&self) -> String {
46        encode_cursor(&self.into_bytes())
47    }
48}
49
50impl std::fmt::Display for ContinuationSignature {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        f.write_str(&self.as_hex())
53    }
54}
55
56/// Decode a lowercase/uppercase hex cursor token into raw bytes.
57///
58/// The token may include surrounding whitespace, which is trimmed.
59pub fn decode_cursor(token: &str) -> Result<Vec<u8>, CursorDecodeError> {
60    let token = token.trim();
61
62    if token.is_empty() {
63        return Err(CursorDecodeError::Empty);
64    }
65
66    if token.len() > MAX_CURSOR_TOKEN_HEX_LEN {
67        return Err(CursorDecodeError::TooLong {
68            len: token.len(),
69            max: MAX_CURSOR_TOKEN_HEX_LEN,
70        });
71    }
72
73    if !token.len().is_multiple_of(2) {
74        return Err(CursorDecodeError::OddLength);
75    }
76
77    let mut out = Vec::with_capacity(token.len() / 2);
78    let bytes = token.as_bytes();
79
80    for idx in (0..bytes.len()).step_by(2) {
81        let hi = decode_hex_nibble(bytes[idx])
82            .ok_or(CursorDecodeError::InvalidHex { position: idx + 1 })?;
83
84        let lo = decode_hex_nibble(bytes[idx + 1])
85            .ok_or(CursorDecodeError::InvalidHex { position: idx + 2 })?;
86
87        out.push((hi << 4) | lo);
88    }
89
90    Ok(out)
91}
92
93const fn decode_hex_nibble(byte: u8) -> Option<u8> {
94    match byte {
95        b'0'..=b'9' => Some(byte - b'0'),
96        b'a'..=b'f' => Some(byte - b'a' + 10),
97        b'A'..=b'F' => Some(byte - b'A' + 10),
98        _ => None,
99    }
100}
101
102///
103/// TESTS
104///
105
106#[cfg(test)]
107mod tests {
108    use super::{CursorDecodeError, MAX_CURSOR_TOKEN_HEX_LEN, decode_cursor, encode_cursor};
109
110    #[test]
111    fn decode_cursor_rejects_empty_and_whitespace_tokens() {
112        let err = decode_cursor("").expect_err("empty token should be rejected");
113        assert_eq!(err, CursorDecodeError::Empty);
114
115        let err = decode_cursor("   \n\t").expect_err("whitespace token should be rejected");
116        assert_eq!(err, CursorDecodeError::Empty);
117    }
118
119    #[test]
120    fn decode_cursor_rejects_odd_length_tokens() {
121        let err = decode_cursor("abc").expect_err("odd-length token should be rejected");
122        assert_eq!(err, CursorDecodeError::OddLength);
123    }
124
125    #[test]
126    fn decode_cursor_enforces_max_token_length() {
127        let accepted = "aa".repeat(MAX_CURSOR_TOKEN_HEX_LEN / 2);
128        let accepted_bytes = decode_cursor(&accepted).expect("max-sized token should decode");
129        assert_eq!(accepted_bytes.len(), MAX_CURSOR_TOKEN_HEX_LEN / 2);
130
131        let rejected = format!("{accepted}aa");
132        let err = decode_cursor(&rejected).expect_err("oversized token should be rejected");
133        assert_eq!(
134            err,
135            CursorDecodeError::TooLong {
136                len: MAX_CURSOR_TOKEN_HEX_LEN + 2,
137                max: MAX_CURSOR_TOKEN_HEX_LEN
138            }
139        );
140    }
141
142    #[test]
143    fn decode_cursor_rejects_invalid_hex_with_position() {
144        let err = decode_cursor("0x").expect_err("invalid hex nibble should be rejected");
145        assert_eq!(err, CursorDecodeError::InvalidHex { position: 2 });
146    }
147
148    #[test]
149    fn decode_cursor_accepts_mixed_case_and_surrounding_whitespace() {
150        let bytes = decode_cursor("  0aFf10  ").expect("mixed-case hex token should decode");
151        assert_eq!(bytes, vec![0x0a, 0xff, 0x10]);
152    }
153
154    #[test]
155    fn encode_decode_cursor_round_trip_is_stable() {
156        let raw = vec![0x00, 0x01, 0x0a, 0xff];
157        let encoded = encode_cursor(&raw);
158        assert_eq!(encoded, "00010aff");
159
160        let decoded = decode_cursor(&encoded).expect("encoded token should decode");
161        assert_eq!(decoded, raw);
162    }
163}