Skip to main content

icydb_core/db/codec/
cursor.rs

1//! Module: codec::cursor
2//! Responsibility: cursor token formatting/hex codec helpers.
3//! Does not own: cursor validation or planner/runtime continuation semantics.
4//! Boundary: pure wire formatting and bounded decode for cursor token strings.
5
6use crate::db::cursor::ContinuationSignature;
7
8// Defensive decode bound for untrusted cursor token input.
9const MAX_CURSOR_TOKEN_HEX_LEN: usize = 8 * 1024;
10
11///
12/// CursorDecodeError
13///
14
15#[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/// Encode raw cursor bytes as a lowercase hex token.
31#[must_use]
32pub fn encode_cursor(bytes: &[u8]) -> String {
33    let mut out = String::with_capacity(bytes.len() * 2);
34    for byte in bytes {
35        use std::fmt::Write as _;
36        let _ = write!(out, "{byte:02x}");
37    }
38    out
39}
40
41impl ContinuationSignature {
42    /// Encode this signature as a lowercase hex token.
43    #[must_use]
44    pub fn as_hex(&self) -> String {
45        encode_cursor(&self.into_bytes())
46    }
47}
48
49impl std::fmt::Display for ContinuationSignature {
50    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51        f.write_str(&self.as_hex())
52    }
53}
54
55/// Decode a lowercase/uppercase hex cursor token into raw bytes.
56///
57/// The token may include surrounding whitespace, which is trimmed.
58pub fn decode_cursor(token: &str) -> Result<Vec<u8>, CursorDecodeError> {
59    // Phase 1: normalize input and enforce envelope-level bounds.
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    // Phase 2: decode validated hex pairs into raw cursor bytes.
78    let mut out = Vec::with_capacity(token.len() / 2);
79    let bytes = token.as_bytes();
80
81    for idx in (0..bytes.len()).step_by(2) {
82        let hi = decode_hex_nibble(bytes[idx])
83            .ok_or(CursorDecodeError::InvalidHex { position: idx + 1 })?;
84
85        let lo = decode_hex_nibble(bytes[idx + 1])
86            .ok_or(CursorDecodeError::InvalidHex { position: idx + 2 })?;
87
88        out.push((hi << 4) | lo);
89    }
90
91    Ok(out)
92}
93
94const fn decode_hex_nibble(byte: u8) -> Option<u8> {
95    match byte {
96        b'0'..=b'9' => Some(byte - b'0'),
97        b'a'..=b'f' => Some(byte - b'a' + 10),
98        b'A'..=b'F' => Some(byte - b'A' + 10),
99        _ => None,
100    }
101}
102
103///
104/// TESTS
105///
106
107#[cfg(test)]
108mod tests {
109    use super::{CursorDecodeError, MAX_CURSOR_TOKEN_HEX_LEN, decode_cursor, encode_cursor};
110
111    #[test]
112    fn decode_cursor_rejects_empty_and_whitespace_tokens() {
113        let err = decode_cursor("").expect_err("empty token should be rejected");
114        assert_eq!(err, CursorDecodeError::Empty);
115
116        let err = decode_cursor("   \n\t").expect_err("whitespace token should be rejected");
117        assert_eq!(err, CursorDecodeError::Empty);
118    }
119
120    #[test]
121    fn decode_cursor_rejects_odd_length_tokens() {
122        let err = decode_cursor("abc").expect_err("odd-length token should be rejected");
123        assert_eq!(err, CursorDecodeError::OddLength);
124    }
125
126    #[test]
127    fn decode_cursor_enforces_max_token_length() {
128        let accepted = "aa".repeat(MAX_CURSOR_TOKEN_HEX_LEN / 2);
129        let accepted_bytes = decode_cursor(&accepted).expect("max-sized token should decode");
130        assert_eq!(accepted_bytes.len(), MAX_CURSOR_TOKEN_HEX_LEN / 2);
131
132        let rejected = format!("{accepted}aa");
133        let err = decode_cursor(&rejected).expect_err("oversized token should be rejected");
134        assert_eq!(
135            err,
136            CursorDecodeError::TooLong {
137                len: MAX_CURSOR_TOKEN_HEX_LEN + 2,
138                max: MAX_CURSOR_TOKEN_HEX_LEN
139            }
140        );
141    }
142
143    #[test]
144    fn decode_cursor_rejects_invalid_hex_with_position() {
145        let err = decode_cursor("0x").expect_err("invalid hex nibble should be rejected");
146        assert_eq!(err, CursorDecodeError::InvalidHex { position: 2 });
147    }
148
149    #[test]
150    fn decode_cursor_accepts_mixed_case_and_surrounding_whitespace() {
151        let bytes = decode_cursor("  0aFf10  ").expect("mixed-case hex token should decode");
152        assert_eq!(bytes, vec![0x0a, 0xff, 0x10]);
153    }
154
155    #[test]
156    fn encode_decode_cursor_round_trip_is_stable() {
157        let raw = vec![0x00, 0x01, 0x0a, 0xff];
158        let encoded = encode_cursor(&raw);
159        assert_eq!(encoded, "00010aff");
160
161        let decoded = decode_cursor(&encoded).expect("encoded token should decode");
162        assert_eq!(decoded, raw);
163    }
164}