use hemx_core::{Effect, EffectBatch, IntoEffect}; use serde::{de, Deserialize, Deserializer, Serialize}; use std::{error::Error, fmt}; pub const PATCH_SCHEMA_VERSION: u16 = 1; pub const PATCH_EVENT: &str = "hemx:sync-patch"; const INTERACTION_ID: &str = "$hemx-interaction"; pub const BROWSER_RUNTIME: &str = include_str!("../runtime/hemx-sync.js"); #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub struct Channel(String); impl Channel { pub fn new(value: impl Into) -> Result { let value = value.into(); if value.is_empty() { return Err(ChannelError::Empty); } if value.len() > 128 { return Err(ChannelError::TooLong); } if !value .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'_' | b'-' | b'.')) { return Err(ChannelError::InvalidCharacter); } Ok(Self(value)) } pub fn as_str(&self) -> &str { &self.0 } } #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub enum ChannelError { Empty, TooLong, InvalidCharacter, } impl fmt::Display for ChannelError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Empty => formatter.write_str("sync channel must not be empty"), Self::TooLong => formatter.write_str("sync channel is too long"), Self::InvalidCharacter => { formatter.write_str("sync channel contains an invalid character") } } } } impl Error for ChannelError {} #[derive(Clone, Debug, Eq, PartialEq)] pub struct Broadcast { channel: Channel, effect_batch: EffectBatch, } impl Broadcast { pub fn channel(&self) -> &Channel { &self.channel } pub fn effect_batch(&self) -> &EffectBatch { &self.effect_batch } pub fn into_parts(self) -> (Channel, EffectBatch) { (self.channel, self.effect_batch) } } #[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)] #[serde(untagged)] pub enum PatchValue { Boolean(bool), Integer(i64), String(String), } #[derive(Clone, Debug, Eq, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub struct FlatPatch { // req: sync/003 schema_version: u16, idempotency_key: String, operation_id: String, key: String, value: PatchValue, } #[derive(Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] struct FlatPatchWire { schema_version: u16, idempotency_key: String, operation_id: String, key: String, value: PatchValue, } impl<'de> Deserialize<'de> for FlatPatch { fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { let wire = FlatPatchWire::deserialize(deserializer)?; let patch = Self { schema_version: wire.schema_version, idempotency_key: wire.idempotency_key, operation_id: wire.operation_id, key: wire.key, value: wire.value, }; patch.validate().map_err(de::Error::custom)?; Ok(patch) } } impl FlatPatch { pub fn for_interaction(key: impl Into, value: PatchValue) -> Result { let key = key.into(); validate_key(&key)?; validate_value(&value)?; Ok(Self { schema_version: PATCH_SCHEMA_VERSION, idempotency_key: INTERACTION_ID.to_owned(), operation_id: INTERACTION_ID.to_owned(), key, value, }) } pub fn new( idempotency_key: impl Into, operation_id: impl Into, key: impl Into, value: PatchValue, ) -> Result { let patch = Self { schema_version: PATCH_SCHEMA_VERSION, idempotency_key: idempotency_key.into(), operation_id: operation_id.into(), key: key.into(), value, }; patch.validate()?; Ok(patch) } pub fn payload(&self) -> String { let value = match &self.value { PatchValue::Boolean(value) => value.to_string(), PatchValue::Integer(value) => value.to_string(), PatchValue::String(value) => json_string(value), }; format!( r#"{{"schemaVersion":{},"idempotencyKey":{},"operationId":{},"key":{},"value":{value}}}"#, self.schema_version, json_string(&self.idempotency_key), json_string(&self.operation_id), json_string(&self.key), ) } pub fn validate(&self) -> Result<(), PatchError> { if self.schema_version != PATCH_SCHEMA_VERSION { return Err(PatchError::SchemaVersion(self.schema_version)); } if self.idempotency_key != INTERACTION_ID || self.operation_id != INTERACTION_ID { validate_identifier("idempotency_key", &self.idempotency_key, 128)?; validate_identifier("operation_id", &self.operation_id, 128)?; } validate_key(&self.key)?; validate_value(&self.value) } } #[derive(Clone, Debug, Eq, PartialEq)] pub enum PatchError { Empty(&'static str), InvalidCharacter(&'static str), TooLong(&'static str), ReservedKey, SchemaVersion(u16), ValueTooLong, IntegerOutOfRange, } impl fmt::Display for PatchError { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::Empty(field) => write!(formatter, "{field} must not be empty"), Self::InvalidCharacter(field) => { write!(formatter, "{field} contains an invalid character") } Self::TooLong(field) => write!(formatter, "{field} is too long"), Self::ReservedKey => formatter.write_str("patch key is reserved"), Self::SchemaVersion(version) => { write!(formatter, "unsupported patch schema version {version}") } Self::ValueTooLong => formatter.write_str("patch string value is too long"), Self::IntegerOutOfRange => { formatter.write_str("patch integer value exceeds JavaScript's safe range") } } } } impl Error for PatchError {} fn json_string(value: &str) -> String { const HEX: &[u8; 16] = b"0123456789abcdef"; let mut encoded = String::with_capacity(value.len() + 2); encoded.push('"'); for character in value.chars() { match character { '"' => encoded.push_str("\\\""), '\\' => encoded.push_str("\\\\"), '\n' => encoded.push_str("\\n"), '\r' => encoded.push_str("\\r"), '\t' => encoded.push_str("\\t"), character if character <= '\u{1f}' => { let byte = character as u8; encoded.push_str("\\u00"); encoded.push(HEX[(byte >> 4) as usize] as char); encoded.push(HEX[(byte & 0x0f) as usize] as char); } character => encoded.push(character), } } encoded.push('"'); encoded } fn validate_value(value: &PatchValue) -> Result<(), PatchError> { match value { PatchValue::String(value) if value.len() > 4096 => Err(PatchError::ValueTooLong), PatchValue::Integer(value) if value.unsigned_abs() > 9_007_199_254_740_991 => { Err(PatchError::IntegerOutOfRange) } _ => Ok(()), } } fn validate_identifier(field: &'static str, value: &str, maximum: usize) -> Result<(), PatchError> { if value.is_empty() { return Err(PatchError::Empty(field)); } if value.len() > maximum { return Err(PatchError::TooLong(field)); } if !value .bytes() .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b':' | b'_' | b'-' | b'.')) { return Err(PatchError::InvalidCharacter(field)); } Ok(()) } fn validate_key(key: &str) -> Result<(), PatchError> { if key.is_empty() { return Err(PatchError::Empty("key")); } if key.len() > 64 { return Err(PatchError::TooLong("key")); } if matches!( key, "schemaVersion" | "idempotencyKey" | "operationId" | "key" | "value" ) { return Err(PatchError::ReservedKey); } let mut bytes = key.bytes(); if !bytes.next().is_some_and(|byte| byte.is_ascii_alphabetic()) || !bytes.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) { return Err(PatchError::InvalidCharacter("key")); } Ok(()) } #[derive(Clone, Debug, Eq, PartialEq)] pub struct SyncEffect(Effect); // req: sync/002 impl SyncEffect { // req: sync/004 pub fn broadcast(channel: Channel, effect_batch: EffectBatch) -> Broadcast { Broadcast { channel, effect_batch, } } pub fn send_patch(patch: FlatPatch) -> Self { patch.validate().expect("FlatPatch must remain valid"); Self(Effect::Emit { name: PATCH_EVENT.to_owned(), payload: patch.payload(), }) } } impl IntoEffect for SyncEffect { fn append_to(self, ops: &mut Vec) { self.0.append_to(ops); } } #[cfg(test)] mod tests { use super::*; #[test] fn broadcast_preserves_typed_channel_and_ordinary_batch() { let channel = Channel::new("board:alpha").unwrap(); let batch = EffectBatch { abi_version: 1, fingerprint: hemx_core::BuildFingerprint(7), ops: vec![], }; let broadcast = SyncEffect::broadcast(channel.clone(), batch.clone()); assert_eq!(broadcast.into_parts(), (channel, batch)); assert_eq!( Channel::new("board alpha"), Err(ChannelError::InvalidCharacter) ); } #[test] fn schema_is_flat_and_rejects_reserved_keys() { let patch = FlatPatch::new( "actor:1", "move-card-to-done", "cardColumn", PatchValue::String("done".to_owned()), ) .unwrap(); assert_eq!( patch.payload(), r#"{"schemaVersion":1,"idempotencyKey":"actor:1","operationId":"move-card-to-done","key":"cardColumn","value":"done"}"# ); assert_eq!( FlatPatch::new("actor:1", "move", "value", PatchValue::Integer(1)), Err(PatchError::ReservedKey) ); assert_eq!( FlatPatch::new( "actor:1", "move", "rank", PatchValue::Integer(9_007_199_254_740_992), ), Err(PatchError::IntegerOutOfRange) ); assert!(serde_json::from_str::( r#"{"schemaVersion":1,"idempotencyKey":"actor:1","operationId":"move","key":"rank","value":9007199254740992}"#, ) .unwrap_err() .to_string() .contains("safe range")); } }