use base64::Engine; use base64::engine::general_purpose::URL_SAFE_NO_PAD; use hmac::{Hmac, KeyInit, Mac}; use remora_domain::fingerprint; use serde::{Deserialize, Serialize}; use sha2::Sha256; use time::OffsetDateTime; use crate::error::DurableError; type HmacSha256 = Hmac; /// Stable keyset order for job-style listings: `(created_at DESC, id DESC)`. #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] pub struct KeysetPosition { #[serde(with = "time::serde::rfc3339")] pub created_at: OffsetDateTime, pub id: String, } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] struct CursorPayload { subject: String, filter_fingerprint: String, #[serde(with = "time::serde::rfc3339")] high_water_created_at: OffsetDateTime, high_water_id: String, after: Option, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct KeysetCursor { pub subject: String, pub filter_fingerprint: [u8; 32], pub high_water: KeysetPosition, pub after: Option, } #[derive(Clone, Debug)] pub struct CursorCodec { secret: Vec, } impl CursorCodec { pub fn new(secret: impl Into>) -> Result { let secret = secret.into(); if secret.len() < 16 { return Err(DurableError::Config( "cursor integrity secret must be at least 16 bytes".into(), )); } Ok(Self { secret }) } pub fn filter_fingerprint(filters: &impl Serialize) -> Result<[u8; 32], DurableError> { let digest = fingerprint(filters).map_err(|_| { DurableError::Config("filters cannot be represented as canonical JSON".into()) })?; Ok(*digest.as_bytes()) } pub fn encode(&self, cursor: &KeysetCursor) -> Result { let payload = CursorPayload { subject: cursor.subject.clone(), filter_fingerprint: hex::encode(cursor.filter_fingerprint), high_water_created_at: cursor.high_water.created_at, high_water_id: cursor.high_water.id.clone(), after: cursor.after.clone(), }; let body = serde_json::to_vec(&payload).map_err(|error| { DurableError::Config(format!("cursor payload is not JSON-serializable: {error}")) })?; let mut mac = HmacSha256::new_from_slice(&self.secret) .map_err(|error| DurableError::Config(format!("invalid cursor secret: {error}")))?; mac.update(&body); let tag = mac.finalize().into_bytes(); let mut framed = Vec::with_capacity(body.len() + tag.len()); framed.extend_from_slice(&body); framed.extend_from_slice(&tag); Ok(URL_SAFE_NO_PAD.encode(framed)) } pub fn decode(&self, token: &str) -> Result { let framed = URL_SAFE_NO_PAD .decode(token.as_bytes()) .map_err(|_| DurableError::InvalidCursor)?; if framed.len() <= 32 { return Err(DurableError::InvalidCursor); } let (body, tag) = framed.split_at(framed.len() - 32); let mut mac = HmacSha256::new_from_slice(&self.secret) .map_err(|error| DurableError::Config(format!("invalid cursor secret: {error}")))?; mac.update(body); mac.verify_slice(tag) .map_err(|_| DurableError::InvalidCursor)?; let payload: CursorPayload = serde_json::from_slice(body).map_err(|_| DurableError::InvalidCursor)?; let filter_fingerprint = parse_hex32(&payload.filter_fingerprint)?; Ok(KeysetCursor { subject: payload.subject, filter_fingerprint, high_water: KeysetPosition { created_at: payload.high_water_created_at, id: payload.high_water_id, }, after: payload.after, }) } pub fn bind( &self, token: &str, subject: &str, filter_fingerprint: [u8; 32], ) -> Result { let cursor = self.decode(token)?; if cursor.subject != subject || cursor.filter_fingerprint != filter_fingerprint { return Err(DurableError::CursorBindingMismatch); } Ok(cursor) } } /// Build a first-page cursor after returning one page. /// /// `high_water` is the newest visible row at snapshot time (membership bound). /// `last_on_page` is the last row of the returned page and becomes the exclusive /// keyset lower bound for the next page. For a single-item page the two /// positions are equal. pub fn first_page_cursor( subject: impl Into, filter_fingerprint: [u8; 32], high_water: KeysetPosition, last_on_page: KeysetPosition, ) -> KeysetCursor { KeysetCursor { subject: subject.into(), filter_fingerprint, high_water, after: Some(last_on_page), } } /// Advance a keyset cursor after returning `last` on the current page. pub fn next_page_cursor(current: &KeysetCursor, last: KeysetPosition) -> KeysetCursor { KeysetCursor { subject: current.subject.clone(), filter_fingerprint: current.filter_fingerprint, high_water: current.high_water.clone(), after: Some(last), } } fn parse_hex32(value: &str) -> Result<[u8; 32], DurableError> { if value.len() != 64 { return Err(DurableError::InvalidCursor); } let mut out = [0_u8; 32]; for (index, slot) in out.iter_mut().enumerate() { *slot = u8::from_str_radix(&value[index * 2..index * 2 + 2], 16) .map_err(|_| DurableError::InvalidCursor)?; } Ok(out) } // Minimal hex encode without an extra dependency. mod hex { pub fn encode(bytes: [u8; 32]) -> String { const HEX: &[u8; 16] = b"0123456789abcdef"; let mut output = String::with_capacity(64); for byte in bytes { output.push(char::from(HEX[usize::from(byte >> 4)])); output.push(char::from(HEX[usize::from(byte & 0x0f)])); } output } } #[cfg(test)] mod tests { use super::*; use serde_json::json; use time::macros::datetime; #[test] fn round_trips_and_binds_subject_and_filters() { let codec = CursorCodec::new(b"test-cursor-secret").unwrap(); let filters = json!({"bucket": "alpha", "lifecycle": "visible"}); let fingerprint = CursorCodec::filter_fingerprint(&filters).unwrap(); let newest = KeysetPosition { created_at: datetime!(2026-07-16 12:00:00 UTC), id: "job-zzzzzzzz".into(), }; let last = KeysetPosition { created_at: datetime!(2026-07-16 11:00:00 UTC), id: "job-aaaaaaaa".into(), }; let cursor = first_page_cursor("subject-001", fingerprint, newest, last); let token = codec.encode(&cursor).unwrap(); let decoded = codec.bind(&token, "subject-001", fingerprint).unwrap(); assert_eq!(decoded, cursor); assert!(matches!( codec.bind(&token, "other-subject", fingerprint), Err(DurableError::CursorBindingMismatch) )); let other = CursorCodec::filter_fingerprint(&json!({"bucket": "beta"})).unwrap(); assert!(matches!( codec.bind(&token, "subject-001", other), Err(DurableError::CursorBindingMismatch) )); } #[test] fn rejects_tampered_tokens() { let codec = CursorCodec::new(b"test-cursor-secret").unwrap(); let fingerprint = [7_u8; 32]; let position = KeysetPosition { created_at: datetime!(2026-07-16 12:00:00 UTC), id: "job-aaaaaaaa".into(), }; let cursor = first_page_cursor("subject-001", fingerprint, position.clone(), position); let mut token = codec.encode(&cursor).unwrap(); token.replace_range(0..1, if token.starts_with('A') { "B" } else { "A" }); assert!(matches!( codec.decode(&token), Err(DurableError::InvalidCursor) )); } }