diff --git a/crates/lib/opaque_cursors/src/v2/opaque_cursor_encoder_v2.rs b/crates/lib/opaque_cursors/src/v2/opaque_cursor_encoder_v2.rs index 23ceba54fa..a42ed3ff3d 100644 --- a/crates/lib/opaque_cursors/src/v2/opaque_cursor_encoder_v2.rs +++ b/crates/lib/opaque_cursors/src/v2/opaque_cursor_encoder_v2.rs @@ -33,8 +33,11 @@ impl OpaqueCursorEncoderV2 { } /// Encode a last-id cursor into an opaque string. - pub fn encode_last_id_cursor(&self, id: u64) -> Result { - let cursor = OpaqueCursorV2 { last_id: Some(id) }; + pub fn encode_last_id_cursor(&self, name: &str, id: u64) -> Result { + let cursor = OpaqueCursorV2 { + name: Some(name.to_string()), + last_id: Some(id), + }; self.encode_cursor(&cursor) } @@ -52,6 +55,26 @@ impl OpaqueCursorEncoderV2 { Ok(engine.encode(&writer)) } + /// Decode an opaque cursor string and verify the name matches `expected_name`. + pub fn decode_cursor_expecting_name( + &self, + expected_name: &str, + cursor: &str, + ) -> Result { + let decoded = self.decode_cursor(cursor)?; + + match &decoded.name { + None => Err(OpaqueCursorErrorV2::DecodedNameNotPresent), + Some(actual) if actual != expected_name => { + Err(OpaqueCursorErrorV2::DecodedNameMismatch { + expected: expected_name.to_string(), + actual: actual.clone(), + }) + } + _ => Ok(decoded), + } + } + /// Decode an opaque cursor string back into a cursor payload. pub fn decode_cursor(&self, cursor: &str) -> Result { let engine = GeneralPurpose::new(&self.alphabet, self.base64_config); @@ -75,32 +98,35 @@ mod tests { #[test] fn roundtrip_last_id() { let encoder = OpaqueCursorEncoderV2::new(SECRET); - let encoded = encoder.encode_last_id_cursor(42).unwrap(); + let encoded = encoder.encode_last_id_cursor("test", 42).unwrap(); let decoded = encoder.decode_cursor(&encoded).unwrap(); + assert_eq!(decoded.name, Some("test".to_string())); assert_eq!(decoded.last_id, Some(42)); } #[test] - fn roundtrip_none_last_id() { + fn roundtrip_none_fields() { let encoder = OpaqueCursorEncoderV2::new(SECRET); - let cursor = OpaqueCursorV2 { last_id: None }; + let cursor = OpaqueCursorV2 { name: None, last_id: None }; let encoded = encoder.encode_cursor(&cursor).unwrap(); let decoded = encoder.decode_cursor(&encoded).unwrap(); + assert_eq!(decoded.name, None); assert_eq!(decoded.last_id, None); } #[test] fn roundtrip_zero() { let encoder = OpaqueCursorEncoderV2::new(SECRET); - let encoded = encoder.encode_last_id_cursor(0).unwrap(); + let encoded = encoder.encode_last_id_cursor("zero", 0).unwrap(); let decoded = encoder.decode_cursor(&encoded).unwrap(); + assert_eq!(decoded.name, Some("zero".to_string())); assert_eq!(decoded.last_id, Some(0)); } #[test] fn roundtrip_large_id() { let encoder = OpaqueCursorEncoderV2::new(SECRET); - let encoded = encoder.encode_last_id_cursor(u64::MAX).unwrap(); + let encoded = encoder.encode_last_id_cursor("big", u64::MAX).unwrap(); let decoded = encoder.decode_cursor(&encoded).unwrap(); assert_eq!(decoded.last_id, Some(u64::MAX)); } @@ -109,19 +135,29 @@ mod tests { fn roundtrip_many_ids() { let encoder = OpaqueCursorEncoderV2::new(SECRET); for id in [0, 1, 100, 999, 100_000, 1_000_000_000, u64::MAX - 1] { - let encoded = encoder.encode_last_id_cursor(id).unwrap(); + let encoded = encoder.encode_last_id_cursor("batch", id).unwrap(); let decoded = encoder.decode_cursor(&encoded).unwrap(); + assert_eq!(decoded.name, Some("batch".to_string())); assert_eq!(decoded.last_id, Some(id), "roundtrip failed for id={}", id); } } + #[test] + fn name_preserved_in_roundtrip() { + let encoder = OpaqueCursorEncoderV2::new(SECRET); + let encoded = encoder.encode_last_id_cursor("media_files_list", 999).unwrap(); + let decoded = encoder.decode_cursor(&encoded).unwrap(); + assert_eq!(decoded.name, Some("media_files_list".to_string())); + assert_eq!(decoded.last_id, Some(999)); + } + #[test] fn different_secrets_produce_different_output() { let encoder_a = OpaqueCursorEncoderV2::new("secret_a"); let encoder_b = OpaqueCursorEncoderV2::new("secret_b"); - let encoded_a = encoder_a.encode_last_id_cursor(42).unwrap(); - let encoded_b = encoder_b.encode_last_id_cursor(42).unwrap(); + let encoded_a = encoder_a.encode_last_id_cursor("test", 42).unwrap(); + let encoded_b = encoder_b.encode_last_id_cursor("test", 42).unwrap(); assert_ne!(encoded_a, encoded_b); } @@ -131,7 +167,7 @@ mod tests { let encoder_a = OpaqueCursorEncoderV2::new("secret_a"); let encoder_b = OpaqueCursorEncoderV2::new("secret_b"); - let encoded = encoder_a.encode_last_id_cursor(42).unwrap(); + let encoded = encoder_a.encode_last_id_cursor("test", 42).unwrap(); let result = encoder_b.decode_cursor(&encoded); assert!(result.is_err()); @@ -155,8 +191,7 @@ mod tests { fn encoded_cursor_is_url_safe() { let encoder = OpaqueCursorEncoderV2::new(SECRET); for id in [0, 1, 42, 999_999_999] { - let encoded = encoder.encode_last_id_cursor(id).unwrap(); - // URL-safe base64 uses only alphanumerics, '-', '_', and no padding '=' + let encoded = encoder.encode_last_id_cursor("safe", id).unwrap(); assert!( encoded.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'), "non-URL-safe character in cursor: {:?}", @@ -165,12 +200,37 @@ mod tests { } } + #[test] + fn decode_expecting_name_matches() { + let encoder = OpaqueCursorEncoderV2::new(SECRET); + let encoded = encoder.encode_last_id_cursor("my_list", 100).unwrap(); + let decoded = encoder.decode_cursor_expecting_name("my_list", &encoded).unwrap(); + assert_eq!(decoded.name, Some("my_list".to_string())); + assert_eq!(decoded.last_id, Some(100)); + } + + #[test] + fn decode_expecting_name_mismatch() { + let encoder = OpaqueCursorEncoderV2::new(SECRET); + let encoded = encoder.encode_last_id_cursor("wrong_name", 100).unwrap(); + let result = encoder.decode_cursor_expecting_name("expected_name", &encoded); + assert!(matches!(result, Err(OpaqueCursorErrorV2::DecodedNameMismatch { .. }))); + } + + #[test] + fn decode_expecting_name_not_present() { + let encoder = OpaqueCursorEncoderV2::new(SECRET); + let cursor = OpaqueCursorV2 { name: None, last_id: Some(42) }; + let encoded = encoder.encode_cursor(&cursor).unwrap(); + let result = encoder.decode_cursor_expecting_name("anything", &encoded); + assert!(matches!(result, Err(OpaqueCursorErrorV2::DecodedNameNotPresent))); + } + #[test] fn deterministic_output() { let encoder = OpaqueCursorEncoderV2::new(SECRET); - let a = encoder.encode_last_id_cursor(42).unwrap(); - let b = encoder.encode_last_id_cursor(42).unwrap(); - // No entropy means same input produces same output. + let a = encoder.encode_last_id_cursor("det", 42).unwrap(); + let b = encoder.encode_last_id_cursor("det", 42).unwrap(); assert_eq!(a, b); } } diff --git a/crates/lib/opaque_cursors/src/v2/opaque_cursor_error_v2.rs b/crates/lib/opaque_cursors/src/v2/opaque_cursor_error_v2.rs index 4d533fa300..cb9dde7d18 100644 --- a/crates/lib/opaque_cursors/src/v2/opaque_cursor_error_v2.rs +++ b/crates/lib/opaque_cursors/src/v2/opaque_cursor_error_v2.rs @@ -9,6 +9,10 @@ pub enum OpaqueCursorErrorV2 { MagicCryptError(MagicCryptError), Base64DecodeError(base64::DecodeError), Utf8Error(FromUtf8Error), + /// The decoded cursor did not contain a name at all. + DecodedNameNotPresent, + /// The decoded cursor's name did not match the expected name. + DecodedNameMismatch { expected: String, actual: String }, } impl fmt::Display for OpaqueCursorErrorV2 { diff --git a/crates/lib/opaque_cursors/src/v2/opaque_cursor_v2.rs b/crates/lib/opaque_cursors/src/v2/opaque_cursor_v2.rs index d94948ea5d..346c8eb037 100644 --- a/crates/lib/opaque_cursors/src/v2/opaque_cursor_v2.rs +++ b/crates/lib/opaque_cursors/src/v2/opaque_cursor_v2.rs @@ -6,6 +6,11 @@ use serde_derive::{Deserialize, Serialize}; /// The frontend cannot read or forge these cursors. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct OpaqueCursorV2 { + /// Name for the cursor type. + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "n")] + pub name: Option, + /// The id of the last ordered element we fetched, so the next page /// will start after (or before) this id depending on sort direction. #[serde(skip_serializing_if = "Option::is_none")]