cairn_mod/server/xrpc.rs
1//! Shared XRPC primitives used across `queryLabels`, `createReport`,
2//! and the admin handlers. Currently just the opaque cursor codec —
3//! factoring it here prevents the three callers from drifting on
4//! encoding decisions (base64url vs standard, trailing newline, etc.).
5
6use base64::Engine as _;
7
8/// Encode an `i64` row identifier as an opaque client cursor.
9/// Format: base64url-no-pad of the decimal-stringified value.
10///
11/// Distinct from `subscribeLabels`' bare-integer cursor by type (these
12/// are strings), per §F3.
13pub fn encode_cursor(id: i64) -> String {
14 base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(id.to_string())
15}
16
17/// Decode a client-supplied cursor. Callers map [`CursorError`] to
18/// their XRPC error shape (typically `InvalidRequest` with a generic
19/// message).
20pub fn decode_cursor(s: &str) -> Result<i64, CursorError> {
21 let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
22 .decode(s)
23 .map_err(|_| CursorError::BadBase64)?;
24 let text = std::str::from_utf8(&bytes).map_err(|_| CursorError::BadUtf8)?;
25 text.parse::<i64>().map_err(|_| CursorError::NotInteger)
26}
27
28/// Failure modes of [`decode_cursor`]. All map to
29/// `AdminError::InvalidRequest("malformed cursor")` at the handler
30/// boundary; variants exist for test discrimination.
31#[derive(Debug, PartialEq, Eq)]
32pub enum CursorError {
33 /// Input was not valid base64url.
34 BadBase64,
35 /// Decoded bytes were not valid UTF-8.
36 BadUtf8,
37 /// UTF-8 payload didn't parse as a signed integer.
38 NotInteger,
39}
40
41#[cfg(test)]
42mod tests {
43 use super::*;
44
45 #[test]
46 fn roundtrips() {
47 for id in [1_i64, 42, 1_000_000, i64::MAX] {
48 assert_eq!(decode_cursor(&encode_cursor(id)).unwrap(), id);
49 }
50 }
51
52 #[test]
53 fn rejects_non_base64() {
54 assert_eq!(decode_cursor("!!!").unwrap_err(), CursorError::BadBase64);
55 }
56
57 #[test]
58 fn rejects_non_integer_payload() {
59 let encoded = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode("hello");
60 assert_eq!(
61 decode_cursor(&encoded).unwrap_err(),
62 CursorError::NotInteger
63 );
64 }
65}