Skip to main content

omgbase_surface/
cursor.rs

1//! Keyset cursors (`spec/surface/README.md` ยง1.4): `base64url(JSON
2//! [parts...])`, one encoding for every paged surface โ€” `[path, id]` for
3//! `query`, `[path]` for `docs_list`/`docs_tree`. Decoding requires exactly
4//! `arity` string parts; anything else is [`SurfaceError::cursor_invalid`]
5//! naming the surface.
6
7use serde_json::Value;
8
9use crate::error::{Result, SurfaceError};
10
11const URL_ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
12
13/// Unpadded base64url of `bytes` (Node's `Buffer#toString("base64url")`).
14#[must_use]
15pub fn base64url_encode(bytes: &[u8]) -> String {
16    let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
17    for chunk in bytes.chunks(3) {
18        let b = [
19            chunk[0],
20            chunk.get(1).copied().unwrap_or(0),
21            chunk.get(2).copied().unwrap_or(0),
22        ];
23        let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
24        let count = chunk.len() + 1;
25        for i in 0..count {
26            let idx = (n >> (18 - 6 * i)) & 0x3f;
27            out.push(URL_ALPHABET[idx as usize] as char);
28        }
29    }
30    out
31}
32
33fn sextet(c: u8) -> Option<u32> {
34    match c {
35        b'A'..=b'Z' => Some(u32::from(c - b'A')),
36        b'a'..=b'z' => Some(u32::from(c - b'a') + 26),
37        b'0'..=b'9' => Some(u32::from(c - b'0') + 52),
38        // Both alphabets decode, as Node's lenient decoder accepts.
39        b'-' | b'+' => Some(62),
40        b'_' | b'/' => Some(63),
41        _ => None,
42    }
43}
44
45/// Decode base64 or base64url, padding optional. `None` on a foreign byte.
46#[must_use]
47pub fn base64url_decode(text: &str) -> Option<Vec<u8>> {
48    let mut out = Vec::with_capacity(text.len() * 3 / 4);
49    let mut acc: u32 = 0;
50    let mut bits = 0u32;
51    for &c in text.as_bytes() {
52        if c == b'=' {
53            break;
54        }
55        let v = sextet(c)?;
56        acc = (acc << 6) | v;
57        bits += 6;
58        if bits >= 8 {
59            bits -= 8;
60            out.push(((acc >> bits) & 0xff) as u8);
61        }
62    }
63    Some(out)
64}
65
66/// Encode a keyset position as an opaque cursor.
67#[must_use]
68pub fn encode_cursor(parts: &[&str]) -> String {
69    let json = Value::Array(
70        parts
71            .iter()
72            .map(|p| Value::String((*p).to_owned()))
73            .collect(),
74    );
75    base64url_encode(json.to_string().as_bytes())
76}
77
78/// Decode a cursor issued by [`encode_cursor`], requiring exactly `arity`
79/// string parts; `surface` names the caller for the error.
80pub fn decode_cursor(cursor: &str, surface: &str, arity: usize) -> Result<Vec<String>> {
81    let invalid = || SurfaceError::cursor_invalid(surface);
82    let bytes = base64url_decode(cursor).ok_or_else(invalid)?;
83    let text = String::from_utf8(bytes).map_err(|_| invalid())?;
84    let parsed: Value = serde_json::from_str(&text).map_err(|_| invalid())?;
85    let Value::Array(items) = parsed else {
86        return Err(invalid());
87    };
88    if items.len() != arity {
89        return Err(invalid());
90    }
91    items
92        .into_iter()
93        .map(|v| match v {
94            Value::String(s) => Ok(s),
95            _ => Err(invalid()),
96        })
97        .collect()
98}
99
100/// [`decode_cursor`] for the keysets whose FIRST part is a document path
101/// (`query`'s `[path, id]`, `docs_list`/`docs_tree`'s `[path]`). Since
102/// `spec/surface` 2.0 the surface speaks the reference form, so an issued
103/// cursor's path is `/`-rooted; a bare path can only come from a 1.x cursor,
104/// whose keyset would sort before every rooted row and silently replay the
105/// first page โ€” it is refused with a reason that names the cause.
106pub fn decode_path_cursor(cursor: &str, surface: &str, arity: usize) -> Result<Vec<String>> {
107    let parts = decode_cursor(cursor, surface, arity)?;
108    if !parts[0].starts_with('/') {
109        return Err(SurfaceError::cursor_invalid_reason(&format!(
110            "cursor was issued before surface 2.0 (its path {} is not /-rooted); start the page sequence again without a cursor",
111            Value::String(parts[0].clone())
112        )));
113    }
114    Ok(parts)
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn a_cursor_from_1x_is_refused_by_the_path_decoder() {
123        let rooted = encode_cursor(&["/a.md", "d_1"]);
124        assert_eq!(
125            decode_path_cursor(&rooted, "query", 2).unwrap(),
126            ["/a.md", "d_1"]
127        );
128        let bare = encode_cursor(&["a.md", "d_1"]);
129        let e = decode_path_cursor(&bare, "query", 2).unwrap_err();
130        assert_eq!(e.code, "filter_invalid");
131        assert!(e.message.contains("not /-rooted"), "{}", e.message);
132        assert!(e.message.starts_with("invalid cursor: "));
133        assert_eq!(
134            e.data.as_ref().unwrap()["reason"],
135            e.message["invalid cursor: ".len()..]
136        );
137        // the generic decoder is unchanged (the cursor.json suite pins it)
138        assert_eq!(decode_cursor(&bare, "query", 2).unwrap(), ["a.md", "d_1"]);
139    }
140
141    #[test]
142    fn base64url_round_trips_and_matches_node() {
143        assert_eq!(base64url_encode(b""), "");
144        assert_eq!(base64url_encode(b"f"), "Zg");
145        assert_eq!(base64url_encode(b"fo"), "Zm8");
146        assert_eq!(base64url_encode(b"foo"), "Zm9v");
147        assert_eq!(base64url_encode(&[0xfb, 0xff]), "-_8");
148        for s in ["", "a", "ab", "abc", "abcd", "hello world!"] {
149            assert_eq!(
150                base64url_decode(&base64url_encode(s.as_bytes())).unwrap(),
151                s.as_bytes()
152            );
153        }
154        assert_eq!(base64url_decode("Zm9v=").unwrap(), b"foo");
155        assert_eq!(base64url_decode("+/8").unwrap(), [0xfb, 0xff]);
156        assert!(base64url_decode("a b").is_none());
157    }
158
159    #[test]
160    fn cursors_encode_json_tuples() {
161        let c = encode_cursor(&["a.md", "d_1"]);
162        assert_eq!(base64url_encode(br#"["a.md","d_1"]"#), c);
163        assert_eq!(decode_cursor(&c, "query", 2).unwrap(), ["a.md", "d_1"]);
164        assert_eq!(
165            decode_cursor(&c, "query", 1).unwrap_err().code,
166            "filter_invalid"
167        );
168        let bad = decode_cursor("not base64!", "docs_list", 1).unwrap_err();
169        assert_eq!(bad.message, "invalid cursor");
170        assert_eq!(
171            bad.data.unwrap()["reason"],
172            "cursor was not issued by docs_list"
173        );
174        let non_string = base64url_encode(br"[1]");
175        assert!(decode_cursor(&non_string, "x", 1).is_err());
176        let not_array = base64url_encode(br#"{"a":1}"#);
177        assert!(decode_cursor(&not_array, "x", 1).is_err());
178    }
179}