remora/crates/remora-durable/src/cursor.rs
ms2k 957d170150 Add PostgreSQL DurableStore substrate for WP-03
Introduce remora-durable with pool/migrations, a RemoraApiMutation-shaped
ledger, claim-fenced outbox projection, CAS helpers, and keyset cursors,
plus a kind Job acceptance suite against the hermetic fixture.
2026-07-16 03:56:44 +09:00

231 lines
7.9 KiB
Rust

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<Sha256>;
/// 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<KeysetPosition>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct KeysetCursor {
pub subject: String,
pub filter_fingerprint: [u8; 32],
pub high_water: KeysetPosition,
pub after: Option<KeysetPosition>,
}
#[derive(Clone, Debug)]
pub struct CursorCodec {
secret: Vec<u8>,
}
impl CursorCodec {
pub fn new(secret: impl Into<Vec<u8>>) -> Result<Self, DurableError> {
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<String, DurableError> {
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<KeysetCursor, DurableError> {
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<KeysetCursor, DurableError> {
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<String>,
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)
));
}
}