umsh_text/
validate.rs

1//! Semantic validation of decoded text messages in conversation context.
2//!
3//! The codec guarantees only syntactic validity. This layer applies the rules
4//! that depend on how a message arrived and which conversation it belongs to,
5//! selected by a [`TextProfile`].
6
7use crate::ParseError;
8use crate::codec::ParseInfo;
9use crate::model::{
10    ConversationKey, ExtensionOptions, MessageSequence, MessageType, Regarding, SenderScope,
11    TextMessage, option,
12};
13
14/// How a MAC-validated packet arrived.
15#[derive(Clone, Copy, Debug, PartialEq, Eq)]
16pub enum DeliveryPath {
17    /// Individually addressed and pairwise authenticated.
18    Unicast,
19    /// Channel-addressed with a single logical destination.
20    BlindUnicast,
21    /// Channel-addressed group delivery.
22    Multicast,
23}
24
25/// MAC-validated context for one received text payload.
26#[derive(Clone, Copy, Debug, PartialEq, Eq)]
27pub struct Envelope {
28    pub path: DeliveryPath,
29    pub conversation: ConversationKey,
30    pub sender: SenderScope,
31}
32
33/// Why a syntactically valid message was rejected in context.
34#[derive(Clone, Copy, Debug, PartialEq, Eq)]
35pub enum ValidateError {
36    /// Propagated syntactic error (UTF-8 of a complete body, extension
37    /// decoding).
38    Parse(ParseError),
39    /// A resend request arrived via multicast or broadcast.
40    ResendRequestPath,
41    /// A resend request did not carry a Message Sequence option.
42    ResendRequestMissingSequence,
43    /// The Channel Group Resend flag appeared on a resend request that did
44    /// not arrive by blind-unicast.
45    ChannelGroupResendPath,
46    /// A Message Unavailable response did not carry a Message Sequence
47    /// option.
48    UnavailableMissingSequence,
49    /// The Regarding option width does not match the conversation type.
50    RegardingWidth,
51    /// The message type is not recognized by the selected profile.
52    UnrecognizedMessageType(u8),
53    /// A recognized extension option was duplicated in a role where
54    /// duplication is unresolvable.
55    DuplicateExtensionOption(u16),
56    /// A recognized extension option had an invalid value.
57    InvalidExtensionOption(u16),
58}
59
60impl From<ParseError> for ValidateError {
61    fn from(value: ParseError) -> Self {
62        Self::Parse(value)
63    }
64}
65
66/// Treatment of a duplicated recognized option.
67#[derive(Clone, Copy, Debug, PartialEq, Eq)]
68pub enum DuplicateTreatment {
69    /// Identity, sequencing, or reference semantics: duplication is fatal.
70    Fatal,
71    /// Presentation: first occurrence wins, later ones are diagnostics.
72    FirstWins,
73    /// Zero-length flag: duplicates are idempotent.
74    Idempotent,
75}
76
77/// Profile contract selecting which message types and extension options a
78/// deployment recognizes.
79///
80/// Profiles are static implementations; `umsh-chat-room` implements the room
81/// profiles on top of this contract.
82pub trait TextProfile {
83    /// Whether a non-control message type is recognized as displayable
84    /// content in this profile.
85    fn recognizes_content_type(&self, message_type: MessageType) -> bool;
86
87    /// Duplicate treatment for a recognized extension option, or `None` when
88    /// the option is unrecognized (and therefore ignorable).
89    fn extension_treatment(&self, number: u16) -> Option<DuplicateTreatment>;
90}
91
92/// Base profile for direct and channel conversations: base message types
93/// only, no recognized extension options.
94#[derive(Clone, Copy, Debug, Default)]
95pub struct DirectChannelProfile;
96
97impl TextProfile for DirectChannelProfile {
98    fn recognizes_content_type(&self, message_type: MessageType) -> bool {
99        matches!(message_type, MessageType::Basic | MessageType::Status)
100    }
101
102    fn extension_treatment(&self, _number: u16) -> Option<DuplicateTreatment> {
103        None
104    }
105}
106
107/// Non-fatal observations recorded during validation.
108#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
109pub struct ValidationNotes {
110    /// Presentation options that were duplicated (first occurrence kept).
111    pub repeated_presentation_mask: u16,
112    /// A continuation fragment carried message-level metadata options, which
113    /// were ignored.
114    pub ignored_continuation_metadata: bool,
115    /// A Channel Group Resend flag appeared on a non-request message and was
116    /// ignored.
117    pub ignored_channel_group_resend: bool,
118}
119
120/// A displayable content message after contextual validation.
121#[derive(Clone, Copy, Debug, PartialEq, Eq)]
122pub struct ContentMessage<'a> {
123    pub message_type: MessageType,
124    pub sender_handle: Option<&'a str>,
125    pub sequence: Option<MessageSequence>,
126    pub sequence_reset: bool,
127    pub regarding: Option<Regarding>,
128    pub editing: Option<u8>,
129    pub bg_color: Option<[u8; 3]>,
130    pub text_color: Option<[u8; 3]>,
131    pub extensions: ExtensionOptions<'a>,
132    /// Raw body bytes; guaranteed UTF-8 when the message is unfragmented.
133    pub body: &'a [u8],
134}
135
136/// A validated received text payload.
137#[derive(Clone, Copy, Debug, PartialEq, Eq)]
138pub enum Validated<'a> {
139    Content(ContentMessage<'a>),
140    /// A request to resend one frame from the selected archive stream.
141    ResendRequest {
142        sequence: MessageSequence,
143        channel_group: bool,
144    },
145    /// The named frame is not available for resend.
146    Unavailable {
147        sequence: MessageSequence,
148    },
149}
150
151/// Validate a decoded message in its conversation context.
152pub fn validate<'a>(
153    profile: &dyn TextProfile,
154    envelope: &Envelope,
155    message: &TextMessage<'a>,
156    info: &ParseInfo,
157) -> Result<(Validated<'a>, ValidationNotes), ValidateError> {
158    let mut notes = ValidationNotes {
159        repeated_presentation_mask: info.repeated_presentation_mask,
160        ..ValidationNotes::default()
161    };
162
163    match message.message_type {
164        MessageType::ResendRequest => {
165            if !matches!(
166                envelope.path,
167                DeliveryPath::Unicast | DeliveryPath::BlindUnicast
168            ) {
169                return Err(ValidateError::ResendRequestPath);
170            }
171            let sequence = message
172                .sequence
173                .ok_or(ValidateError::ResendRequestMissingSequence)?;
174            if message.channel_group_resend && envelope.path != DeliveryPath::BlindUnicast {
175                return Err(ValidateError::ChannelGroupResendPath);
176            }
177            // All other options and the body are ignored once the required
178            // fields validate.
179            return Ok((
180                Validated::ResendRequest {
181                    sequence,
182                    channel_group: message.channel_group_resend,
183                },
184                notes,
185            ));
186        }
187        MessageType::MessageUnavailable => {
188            let sequence = message
189                .sequence
190                .ok_or(ValidateError::UnavailableMissingSequence)?;
191            if message.channel_group_resend {
192                notes.ignored_channel_group_resend = true;
193            }
194            return Ok((Validated::Unavailable { sequence }, notes));
195        }
196        other => {
197            if !profile.recognizes_content_type(other) {
198                return Err(ValidateError::UnrecognizedMessageType(other.to_byte()));
199            }
200        }
201    }
202
203    if let Some(regarding) = message.regarding {
204        let multicast_form = matches!(regarding, Regarding::Multicast { .. });
205        if multicast_form != envelope.conversation.uses_multicast_references() {
206            return Err(ValidateError::RegardingWidth);
207        }
208    }
209
210    validate_extensions(profile, &message.extensions, &mut notes)?;
211
212    let mut content = ContentMessage {
213        message_type: message.message_type,
214        sender_handle: message.sender_handle,
215        sequence: message.sequence,
216        sequence_reset: message.sequence_reset,
217        regarding: message.regarding,
218        editing: message.editing,
219        bg_color: message.bg_color,
220        text_color: message.text_color,
221        extensions: message.extensions,
222        body: message.body,
223    };
224
225    if message.channel_group_resend {
226        notes.ignored_channel_group_resend = true;
227    }
228
229    let fragmented = message.sequence.and_then(|sequence| sequence.fragment);
230    let is_continuation = fragmented.is_some_and(|fragment| fragment.index > 0);
231    if is_continuation {
232        // Continuation fragments carry only sequence metadata; message-level
233        // metadata belongs to fragment zero and must be ignored here.
234        let metadata_mask = (1 << option::MESSAGE_TYPE)
235            | (1 << option::SENDER_HANDLE)
236            | (1 << option::SEQUENCE_RESET)
237            | (1 << option::REGARDING)
238            | (1 << option::EDITING)
239            | (1 << option::BACKGROUND_COLOR)
240            | (1 << option::TEXT_COLOR);
241        if info.seen_mask & metadata_mask != 0 || !message.extensions.is_empty() {
242            notes.ignored_continuation_metadata = true;
243        }
244        content.message_type = MessageType::Basic;
245        content.sender_handle = None;
246        content.sequence_reset = false;
247        content.regarding = None;
248        content.editing = None;
249        content.bg_color = None;
250        content.text_color = None;
251        content.extensions = ExtensionOptions::empty();
252    } else if fragmented.is_none() {
253        // Unfragmented content bodies must be complete UTF-8. Fragment bodies
254        // (including fragment zero) are validated only after reassembly.
255        core::str::from_utf8(message.body).map_err(|_| ParseError::InvalidUtf8)?;
256    }
257
258    Ok((Validated::Content(content), notes))
259}
260
261fn validate_extensions(
262    profile: &dyn TextProfile,
263    extensions: &ExtensionOptions<'_>,
264    notes: &mut ValidationNotes,
265) -> Result<(), ValidateError> {
266    // Recognized extension options are singletons; track the ones seen in a
267    // small fixed window relative to the extension base.
268    let mut seen: u64 = 0;
269    for item in extensions.iter() {
270        let (number, _value) = item.map_err(ValidateError::Parse)?;
271        let Some(treatment) = profile.extension_treatment(number) else {
272            continue;
273        };
274        let bit_index = number - option::EXTENSION_BASE;
275        if bit_index >= 64 {
276            continue;
277        }
278        let bit = 1u64 << bit_index;
279        if seen & bit != 0 {
280            match treatment {
281                DuplicateTreatment::Fatal => {
282                    return Err(ValidateError::DuplicateExtensionOption(number));
283                }
284                DuplicateTreatment::FirstWins => {
285                    notes.repeated_presentation_mask |= 1 << (number.min(15));
286                }
287                DuplicateTreatment::Idempotent => {}
288            }
289        }
290        seen |= bit;
291    }
292    Ok(())
293}