mik_sql/pagination/
cursor.rs

1//! Cursor encoding/decoding for pagination.
2
3use crate::builder::Value;
4
5use super::encoding::{base64_decode, base64_encode, escape_json, split_json_pairs, unescape_json};
6
7/// Maximum allowed cursor size in bytes (4KB).
8/// This prevents DoS attacks via oversized cursor payloads.
9const MAX_CURSOR_SIZE: usize = 4 * 1024;
10
11/// Maximum number of fields allowed in a cursor.
12/// This prevents DoS attacks via cursors with many tiny fields
13/// (e.g., `{"a":1,"b":2,...}` with hundreds of fields).
14const MAX_CURSOR_FIELDS: usize = 16;
15
16/// A cursor for cursor-based pagination.
17///
18/// Cursors encode the position in a result set as a base64 JSON object.
19/// The cursor contains the values of the sort fields for the last item.
20///
21/// # Security Note
22///
23/// Cursors use simple base64 encoding, **not encryption**. The cursor content
24/// is easily decoded by clients. This is intentional - cursors are opaque
25/// pagination tokens, not security mechanisms.
26///
27/// **Do not include sensitive data in cursor fields.** Only include the
28/// values needed for pagination (e.g., `id`, `created_at`).
29///
30/// If you need to prevent cursor tampering, validate cursor values against
31/// expected ranges or sign cursors server-side.
32#[derive(Debug, Clone, PartialEq)]
33#[non_exhaustive]
34#[must_use = "cursor must be encoded with .encode() or used with a query builder"]
35pub struct Cursor {
36    /// Field values that define the cursor position.
37    pub fields: Vec<(String, Value)>,
38}
39
40impl Cursor {
41    /// Create a new empty cursor.
42    #[must_use]
43    pub const fn new() -> Self {
44        Self { fields: Vec::new() }
45    }
46
47    /// Add a field value to the cursor.
48    pub fn field(mut self, name: impl Into<String>, value: impl Into<Value>) -> Self {
49        self.fields.push((name.into(), value.into()));
50        self
51    }
52
53    /// Add an integer field.
54    pub fn int(self, name: impl Into<String>, value: i64) -> Self {
55        self.field(name, Value::Int(value))
56    }
57
58    /// Add a string field.
59    pub fn string(self, name: impl Into<String>, value: impl Into<String>) -> Self {
60        self.field(name, Value::String(value.into()))
61    }
62
63    /// Encode the cursor to a base64 string.
64    ///
65    /// Note: This uses simple base64, not encryption. See [`Cursor`] security note.
66    #[must_use]
67    pub fn encode(&self) -> String {
68        let json = self.to_json();
69        base64_encode(&json)
70    }
71
72    /// Decode a cursor from a base64 string.
73    ///
74    /// Returns an error if the cursor exceeds `MAX_CURSOR_SIZE` (4KB).
75    pub fn decode(encoded: &str) -> Result<Self, CursorError> {
76        // Check size before decoding to prevent DoS attacks
77        if encoded.len() > MAX_CURSOR_SIZE {
78            return Err(CursorError::TooLarge);
79        }
80        let json = base64_decode(encoded).map_err(|()| CursorError::InvalidBase64)?;
81        Self::from_json(&json)
82    }
83
84    /// Convert cursor to JSON string.
85    fn to_json(&self) -> String {
86        let mut parts = Vec::new();
87        for (name, value) in &self.fields {
88            let val_str = match value {
89                Value::Null => "null".to_string(),
90                Value::Bool(b) => b.to_string(),
91                Value::Int(i) => i.to_string(),
92                Value::Float(f) => f.to_string(),
93                Value::String(s) => format!("\"{}\"", escape_json(s)),
94                Value::Array(_) => continue, // Skip arrays in cursors
95            };
96            parts.push(format!("\"{name}\":{val_str}"));
97        }
98        format!("{{{}}}", parts.join(","))
99    }
100
101    /// Parse cursor from JSON string.
102    fn from_json(json: &str) -> Result<Self, CursorError> {
103        let mut cursor = Self::new();
104        let json = json.trim();
105
106        if !json.starts_with('{') || !json.ends_with('}') {
107            return Err(CursorError::InvalidFormat);
108        }
109
110        let inner = &json[1..json.len() - 1];
111        if inner.is_empty() {
112            return Ok(cursor);
113        }
114
115        // Simple JSON parser for cursor format
116        for pair in split_json_pairs(inner) {
117            let pair = pair.trim();
118            if pair.is_empty() {
119                continue;
120            }
121
122            let colon_idx = pair.find(':').ok_or(CursorError::InvalidFormat)?;
123            let key = pair[..colon_idx].trim();
124            let value = pair[colon_idx + 1..].trim();
125
126            // Parse key (remove quotes)
127            if !key.starts_with('"') || !key.ends_with('"') {
128                return Err(CursorError::InvalidFormat);
129            }
130            let key = &key[1..key.len() - 1];
131
132            // Parse value
133            let parsed_value = if value == "null" {
134                Value::Null
135            } else if value == "true" {
136                Value::Bool(true)
137            } else if value == "false" {
138                Value::Bool(false)
139            } else if value.starts_with('"') && value.ends_with('"') {
140                Value::String(unescape_json(&value[1..value.len() - 1]))
141            } else if value.contains('.') {
142                value
143                    .parse::<f64>()
144                    .map(Value::Float)
145                    .map_err(|_| CursorError::InvalidFormat)?
146            } else {
147                value
148                    .parse::<i64>()
149                    .map(Value::Int)
150                    .map_err(|_| CursorError::InvalidFormat)?
151            };
152
153            cursor.fields.push((key.to_string(), parsed_value));
154
155            // Limit field count to prevent DoS via many tiny fields
156            if cursor.fields.len() > MAX_CURSOR_FIELDS {
157                return Err(CursorError::TooManyFields);
158            }
159        }
160
161        Ok(cursor)
162    }
163}
164
165impl Default for Cursor {
166    fn default() -> Self {
167        Self::new()
168    }
169}
170
171/// Errors that can occur when parsing a cursor.
172#[derive(Debug, Clone, PartialEq, Eq)]
173#[non_exhaustive]
174pub enum CursorError {
175    /// The base64 encoding is invalid.
176    InvalidBase64,
177    /// The cursor format is invalid.
178    InvalidFormat,
179    /// The cursor exceeds the maximum allowed size.
180    TooLarge,
181    /// The cursor has too many fields.
182    TooManyFields,
183}
184
185impl std::fmt::Display for CursorError {
186    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
187        match self {
188            Self::InvalidBase64 => write!(f, "invalid base64 encoding in cursor"),
189            Self::InvalidFormat => write!(f, "invalid cursor format (expected JSON object)"),
190            Self::TooLarge => write!(
191                f,
192                "cursor exceeds maximum size ({}KB limit)",
193                MAX_CURSOR_SIZE / 1024
194            ),
195            Self::TooManyFields => {
196                write!(f, "cursor has too many fields (max {MAX_CURSOR_FIELDS})")
197            },
198        }
199    }
200}
201
202impl std::error::Error for CursorError {}
203
204impl CursorError {
205    /// Returns `true` if this is an encoding/format error.
206    ///
207    /// Includes `InvalidBase64` and `InvalidFormat`.
208    #[inline]
209    #[must_use]
210    pub const fn is_format_error(&self) -> bool {
211        matches!(self, Self::InvalidBase64 | Self::InvalidFormat)
212    }
213
214    /// Returns `true` if this is a size/limit error.
215    ///
216    /// Includes `TooLarge` and `TooManyFields`.
217    #[inline]
218    #[must_use]
219    pub const fn is_limit_error(&self) -> bool {
220        matches!(self, Self::TooLarge | Self::TooManyFields)
221    }
222}
223
224/// Trait for types that can be converted into a cursor.
225///
226/// Provides flexible DX for cursor pagination methods.
227///
228/// # Example
229///
230/// ```
231/// # use mik_sql::{Cursor, IntoCursor};
232/// // Cursor directly
233/// let cursor = Cursor::new().int("id", 100);
234/// assert!(cursor.into_cursor().is_some());
235///
236/// // Base64 encoded string
237/// let encoded = Cursor::new().int("id", 42).encode();
238/// let decoded: Option<Cursor> = encoded.as_str().into_cursor();
239/// assert!(decoded.is_some());
240///
241/// // Option<&str> - None returns None
242/// let none: Option<&str> = None;
243/// assert!(none.into_cursor().is_none());
244/// ```
245pub trait IntoCursor {
246    /// Convert into an optional cursor.
247    /// Returns None if the input is invalid or missing.
248    fn into_cursor(self) -> Option<Cursor>;
249}
250
251impl IntoCursor for Cursor {
252    fn into_cursor(self) -> Option<Cursor> {
253        // Empty cursor should not add any conditions
254        if self.fields.is_empty() {
255            None
256        } else {
257            Some(self)
258        }
259    }
260}
261
262impl IntoCursor for &str {
263    fn into_cursor(self) -> Option<Cursor> {
264        if self.is_empty() || self.len() > MAX_CURSOR_SIZE {
265            return None;
266        }
267        Cursor::decode(self).ok()
268    }
269}
270
271impl IntoCursor for String {
272    fn into_cursor(self) -> Option<Cursor> {
273        self.as_str().into_cursor()
274    }
275}
276
277impl IntoCursor for &String {
278    fn into_cursor(self) -> Option<Cursor> {
279        self.as_str().into_cursor()
280    }
281}
282
283impl<T: IntoCursor> IntoCursor for Option<T> {
284    fn into_cursor(self) -> Option<Cursor> {
285        self.and_then(IntoCursor::into_cursor)
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use crate::pagination::encoding::base64_encode;
293
294    #[test]
295    fn test_cursor_encode_decode() {
296        let cursor = Cursor::new().int("id", 100).string("name", "Alice");
297
298        let encoded = cursor.encode();
299        let decoded = Cursor::decode(&encoded).unwrap();
300
301        assert_eq!(cursor.fields, decoded.fields);
302    }
303
304    #[test]
305    fn test_cursor_empty() {
306        let cursor = Cursor::new();
307        let encoded = cursor.encode();
308        let decoded = Cursor::decode(&encoded).unwrap();
309        assert!(decoded.fields.is_empty());
310    }
311
312    #[test]
313    fn test_cursor_with_special_chars() {
314        let cursor = Cursor::new().string("name", "Hello \"World\"");
315
316        let encoded = cursor.encode();
317        let decoded = Cursor::decode(&encoded).unwrap();
318
319        assert_eq!(cursor.fields, decoded.fields);
320    }
321
322    #[test]
323    fn test_cursor_with_float() {
324        let cursor = Cursor::new().field("score", 1.234f64);
325
326        let encoded = cursor.encode();
327        let decoded = Cursor::decode(&encoded).unwrap();
328
329        assert_eq!(decoded.fields.len(), 1);
330        let Value::Float(f) = &decoded.fields[0].1 else {
331            panic!("expected Value::Float, got {:?}", decoded.fields[0].1)
332        };
333        assert!((f - 1.234).abs() < 0.001);
334    }
335
336    #[test]
337    fn test_cursor_invalid_base64() {
338        let result = Cursor::decode("not valid base64!!!");
339        assert!(matches!(result, Err(CursorError::InvalidBase64)));
340    }
341
342    #[test]
343    fn test_cursor_too_large() {
344        // Create a cursor string larger than MAX_CURSOR_SIZE (4KB)
345        let oversized = "a".repeat(5 * 1024);
346        let result = Cursor::decode(&oversized);
347        assert!(matches!(result, Err(CursorError::TooLarge)));
348
349        // IntoCursor should return None for oversized cursors
350        let cursor: Option<Cursor> = oversized.as_str().into_cursor();
351        assert!(cursor.is_none());
352    }
353
354    #[test]
355    fn test_cursor_too_many_fields() {
356        // Create JSON with more than MAX_CURSOR_FIELDS (16) fields
357        let mut fields = Vec::new();
358        for i in 0..20 {
359            fields.push(format!("\"f{i}\":1"));
360        }
361        let json = format!("{{{}}}", fields.join(","));
362        let encoded = base64_encode(&json);
363
364        let result = Cursor::decode(&encoded);
365        assert!(matches!(result, Err(CursorError::TooManyFields)));
366
367        // IntoCursor should return None for cursors with too many fields
368        let cursor: Option<Cursor> = encoded.as_str().into_cursor();
369        assert!(cursor.is_none());
370    }
371
372    #[test]
373    fn test_cursor_exactly_at_max_fields() {
374        // Create JSON with exactly MAX_CURSOR_FIELDS (16) fields - should succeed
375        let mut fields = Vec::new();
376        for i in 0..16 {
377            fields.push(format!("\"f{i}\":1"));
378        }
379        let json = format!("{{{}}}", fields.join(","));
380        let encoded = base64_encode(&json);
381
382        let result = Cursor::decode(&encoded);
383        assert!(
384            result.is_ok(),
385            "Cursor with exactly 16 fields should succeed"
386        );
387        assert_eq!(result.unwrap().fields.len(), 16);
388    }
389
390    #[test]
391    fn test_cursor_one_under_max_fields() {
392        // Create JSON with MAX_CURSOR_FIELDS - 1 (15) fields - should succeed
393        let mut fields = Vec::new();
394        for i in 0..15 {
395            fields.push(format!("\"f{i}\":1"));
396        }
397        let json = format!("{{{}}}", fields.join(","));
398        let encoded = base64_encode(&json);
399
400        let result = Cursor::decode(&encoded);
401        assert!(result.is_ok(), "Cursor with 15 fields should succeed");
402        assert_eq!(result.unwrap().fields.len(), 15);
403    }
404
405    #[test]
406    fn test_cursor_one_over_max_fields() {
407        // Create JSON with MAX_CURSOR_FIELDS + 1 (17) fields - should fail
408        let mut fields = Vec::new();
409        for i in 0..17 {
410            fields.push(format!("\"f{i}\":1"));
411        }
412        let json = format!("{{{}}}", fields.join(","));
413        let encoded = base64_encode(&json);
414
415        let result = Cursor::decode(&encoded);
416        assert!(matches!(result, Err(CursorError::TooManyFields)));
417    }
418
419    #[test]
420    fn test_cursor_near_max_size() {
421        // Create a cursor near MAX_CURSOR_SIZE (4KB) but under
422        // Each field "fXXX":1 is about 9 chars, we need ~450 fields for 4KB
423        // But we're limited to 16 fields, so use long string values instead
424        let long_value = "x".repeat(200);
425        let cursor = Cursor::new()
426            .string("f1", &long_value)
427            .string("f2", &long_value)
428            .string("f3", &long_value)
429            .string("f4", &long_value);
430
431        let encoded = cursor.encode();
432        assert!(encoded.len() < 4096, "Cursor should be under 4KB limit");
433
434        // Should decode successfully
435        let decoded = Cursor::decode(&encoded);
436        assert!(decoded.is_ok());
437    }
438
439    #[test]
440    fn test_cursor_exactly_at_max_size_boundary() {
441        // The check is `> MAX_CURSOR_SIZE`, so exactly 4096 passes
442        // Test cursor at exactly 4097 bytes (should fail)
443        let oversized = "a".repeat(4097);
444        let result = Cursor::decode(&oversized);
445        assert!(matches!(result, Err(CursorError::TooLarge)));
446
447        // Test at exactly 4096 bytes (should attempt decode, not TooLarge)
448        let at_limit = "a".repeat(4096);
449        let result = Cursor::decode(&at_limit);
450        // May be InvalidBase64 or InvalidFormat, but not TooLarge
451        assert!(!matches!(result, Err(CursorError::TooLarge)));
452    }
453
454    #[test]
455    fn test_into_cursor_boundary_behavior() {
456        // Empty string
457        let cursor: Option<Cursor> = "".into_cursor();
458        assert!(cursor.is_none(), "Empty string should return None");
459
460        // At size limit
461        let oversized = "a".repeat(4097);
462        let cursor: Option<Cursor> = oversized.as_str().into_cursor();
463        assert!(cursor.is_none(), "Oversized cursor should return None");
464    }
465
466    #[test]
467    fn test_cursor_with_various_value_types() {
468        // Test cursor with all supported value types
469        let cursor = Cursor::new()
470            .int("int_field", 42)
471            .string("str_field", "hello")
472            .field("float_field", 1.234f64)
473            .field("bool_field", true);
474
475        let encoded = cursor.encode();
476        let decoded = Cursor::decode(&encoded).unwrap();
477
478        assert_eq!(decoded.fields.len(), 4);
479
480        // Verify each field type
481        assert!(matches!(
482            decoded.fields.iter().find(|(k, _)| k == "int_field"),
483            Some((_, Value::Int(42)))
484        ));
485        assert!(matches!(
486            decoded.fields.iter().find(|(k, _)| k == "str_field"),
487            Some((_, Value::String(s))) if s == "hello"
488        ));
489    }
490
491    #[test]
492    fn test_cursor_with_special_json_characters() {
493        // Test cursor with values that need JSON escaping
494        let cursor = Cursor::new()
495            .string("quotes", "say \"hello\"")
496            .string("backslash", "path\\to\\file")
497            .string("newline", "line1\nline2");
498
499        let encoded = cursor.encode();
500        let decoded = Cursor::decode(&encoded).unwrap();
501
502        assert_eq!(decoded.fields.len(), 3);
503    }
504
505    #[test]
506    fn test_cursor_from_helper() {
507        use super::super::PageInfo;
508
509        #[derive(Debug)]
510        struct User {
511            id: i64,
512        }
513
514        let user = User { id: 42 };
515        let cursor = PageInfo::cursor_from(Some(&user), |u| Cursor::new().int("id", u.id));
516
517        assert!(cursor.is_some());
518        let decoded = Cursor::decode(&cursor.unwrap()).unwrap();
519        assert_eq!(decoded.fields[0], ("id".to_string(), Value::Int(42)));
520    }
521}