omgbase_surface/
cursor.rs1use serde_json::Value;
8
9use crate::error::{Result, SurfaceError};
10
11const URL_ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
12
13#[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 b'-' | b'+' => Some(62),
40 b'_' | b'/' => Some(63),
41 _ => None,
42 }
43}
44
45#[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#[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
78pub 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#[cfg(test)]
101mod tests {
102 use super::*;
103
104 #[test]
105 fn base64url_round_trips_and_matches_node() {
106 assert_eq!(base64url_encode(b""), "");
107 assert_eq!(base64url_encode(b"f"), "Zg");
108 assert_eq!(base64url_encode(b"fo"), "Zm8");
109 assert_eq!(base64url_encode(b"foo"), "Zm9v");
110 assert_eq!(base64url_encode(&[0xfb, 0xff]), "-_8");
111 for s in ["", "a", "ab", "abc", "abcd", "hello world!"] {
112 assert_eq!(
113 base64url_decode(&base64url_encode(s.as_bytes())).unwrap(),
114 s.as_bytes()
115 );
116 }
117 assert_eq!(base64url_decode("Zm9v=").unwrap(), b"foo");
118 assert_eq!(base64url_decode("+/8").unwrap(), [0xfb, 0xff]);
119 assert!(base64url_decode("a b").is_none());
120 }
121
122 #[test]
123 fn cursors_encode_json_tuples() {
124 let c = encode_cursor(&["a.md", "d_1"]);
125 assert_eq!(base64url_encode(br#"["a.md","d_1"]"#), c);
126 assert_eq!(decode_cursor(&c, "query", 2).unwrap(), ["a.md", "d_1"]);
127 assert_eq!(
128 decode_cursor(&c, "query", 1).unwrap_err().code,
129 "filter_invalid"
130 );
131 let bad = decode_cursor("not base64!", "docs_list", 1).unwrap_err();
132 assert_eq!(bad.message, "invalid cursor");
133 assert_eq!(
134 bad.data.unwrap()["reason"],
135 "cursor was not issued by docs_list"
136 );
137 let non_string = base64url_encode(br"[1]");
138 assert!(decode_cursor(&non_string, "x", 1).is_err());
139 let not_array = base64url_encode(br#"{"a":1}"#);
140 assert!(decode_cursor(¬_array, "x", 1).is_err());
141 }
142}