Skip to main content

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}