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    const HEX: &[u8; 16] = b"0123456789abcdef";
34
35    // Keep cursor token emission allocation-bounded and formatting-free.
36    // `write!(..., "{byte:02x}")` re-enters the formatting machinery for every
37    // byte; manual nibble encoding is equivalent on the wire and cheaper on the
38    // hot paged-response path.
39    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    /// Encode this signature as a lowercase hex token.
51    #[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
63/// Decode a lowercase/uppercase hex cursor token into raw bytes.
64///
65/// The token may include surrounding whitespace, which is trimmed.
66pub fn decode_cursor(token: &str) -> Result<Vec<u8>, CursorDecodeError> {
67    // Phase 1: normalize input and enforce envelope-level bounds.
68    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    // Phase 2: decode validated hex pairs into raw cursor bytes.
86    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///
112/// TESTS
113///
114
115#[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}