twiml_rust/
validation.rs

1//! TwiML Validation
2//!
3//! This module provides validation for TwiML responses to ensure they conform
4//! to Twilio's requirements and will be accepted by Twilio's servers.
5
6use crate::error::{Error, Result};
7
8/// Validation error details
9#[derive(Debug, Clone, PartialEq)]
10pub struct ValidationError {
11    /// The type of validation error
12    pub error_type: ValidationErrorType,
13    /// Human-readable error message
14    pub message: String,
15    /// Optional context (e.g., verb name, attribute name)
16    pub context: Option<String>,
17}
18
19/// Types of validation errors
20#[derive(Debug, Clone, PartialEq)]
21pub enum ValidationErrorType {
22    /// XML is not well-formed
23    MalformedXml,
24    /// Missing required attribute
25    MissingRequiredAttribute,
26    /// Invalid attribute value
27    InvalidAttributeValue,
28    /// Invalid verb nesting
29    InvalidNesting,
30    /// Content exceeds maximum length
31    ContentTooLong,
32    /// Invalid URL format
33    InvalidUrl,
34    /// Invalid phone number format
35    InvalidPhoneNumber,
36    /// Empty required field
37    EmptyRequiredField,
38    /// Invalid enum value
39    InvalidEnumValue,
40    /// Unsupported feature combination
41    UnsupportedCombination,
42}
43
44impl ValidationError {
45    /// Create a new validation error
46    pub fn new(error_type: ValidationErrorType, message: impl Into<String>) -> Self {
47        Self {
48            error_type,
49            message: message.into(),
50            context: None,
51        }
52    }
53
54    /// Add context to the error
55    pub fn with_context(mut self, context: impl Into<String>) -> Self {
56        self.context = Some(context.into());
57        self
58    }
59}
60
61impl std::fmt::Display for ValidationError {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        if let Some(context) = &self.context {
64            write!(f, "[{}] {}: {}", context, self.error_type, self.message)
65        } else {
66            write!(f, "{}: {}", self.error_type, self.message)
67        }
68    }
69}
70
71impl std::fmt::Display for ValidationErrorType {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        match self {
74            Self::MalformedXml => write!(f, "Malformed XML"),
75            Self::MissingRequiredAttribute => write!(f, "Missing Required Attribute"),
76            Self::InvalidAttributeValue => write!(f, "Invalid Attribute Value"),
77            Self::InvalidNesting => write!(f, "Invalid Nesting"),
78            Self::ContentTooLong => write!(f, "Content Too Long"),
79            Self::InvalidUrl => write!(f, "Invalid URL"),
80            Self::InvalidPhoneNumber => write!(f, "Invalid Phone Number"),
81            Self::EmptyRequiredField => write!(f, "Empty Required Field"),
82            Self::InvalidEnumValue => write!(f, "Invalid Enum Value"),
83            Self::UnsupportedCombination => write!(f, "Unsupported Combination"),
84        }
85    }
86}
87
88/// TwiML Validator
89pub struct TwiMLValidator {
90    /// Whether to perform strict validation
91    strict: bool,
92}
93
94impl TwiMLValidator {
95    /// Create a new validator with default settings
96    pub fn new() -> Self {
97        Self { strict: false }
98    }
99
100    /// Create a new validator with strict validation enabled
101    pub fn strict() -> Self {
102        Self { strict: true }
103    }
104
105    /// Enable or disable strict validation
106    pub fn set_strict(mut self, strict: bool) -> Self {
107        self.strict = strict;
108        self
109    }
110
111    /// Validate XML well-formedness
112    pub fn validate_xml(&self, xml: &str) -> Result<()> {
113        // Check for basic XML structure
114        if !xml.contains("<?xml") {
115            return Err(Error::Validation("XML declaration missing".to_string()));
116        }
117
118        if !xml.contains("<Response>") || !xml.contains("</Response>") {
119            return Err(Error::Validation(
120                "Response element missing or malformed".to_string(),
121            ));
122        }
123
124        // Check for balanced tags (basic check)
125        let open_tags = xml.matches('<').count();
126        let close_tags = xml.matches('>').count();
127        if open_tags != close_tags {
128            return Err(Error::Validation("Unbalanced XML tags".to_string()));
129        }
130
131        // More sophisticated check: ensure all opening tags have closing tags
132        // This is a simple heuristic - count opening tags vs closing tags
133        let mut tag_stack = Vec::new();
134        let mut in_tag = false;
135        let mut tag_name = String::new();
136        let mut is_closing = false;
137        let mut is_self_closing = false;
138        let mut in_attributes = false; // Track if we're in the attributes section
139
140        for ch in xml.chars() {
141            if ch == '<' {
142                in_tag = true;
143                tag_name.clear();
144                is_closing = false;
145                is_self_closing = false;
146                in_attributes = false;
147            } else if ch == '>' && in_tag {
148                in_tag = false;
149
150                // Skip XML declaration, comments, and processing instructions
151                if tag_name.starts_with('?') || tag_name.starts_with('!') {
152                    continue;
153                }
154
155                // Handle self-closing tags
156                if is_self_closing {
157                    continue;
158                }
159
160                // Extract just the tag name (before any space or attribute)
161                let tag = tag_name
162                    .trim()
163                    .split_whitespace()
164                    .next()
165                    .unwrap_or("")
166                    .trim();
167
168                if is_closing {
169                    // Closing tag - should match the last opening tag
170                    if let Some(last) = tag_stack.pop() {
171                        if last != tag {
172                            return Err(Error::Validation(format!(
173                                "Mismatched closing tag: expected </{}>, found </{}>",
174                                last, tag
175                            )));
176                        }
177                    } else {
178                        return Err(Error::Validation(format!(
179                            "Unexpected closing tag: </{}>",
180                            tag
181                        )));
182                    }
183                } else if !tag.is_empty() {
184                    // Opening tag
185                    tag_stack.push(tag.to_string());
186                }
187            } else if in_tag {
188                if ch == '/' {
189                    if tag_name.is_empty() {
190                        // This is a closing tag like </Say>
191                        is_closing = true;
192                    } else {
193                        // This might be a self-closing tag like <Hangup />
194                        // or a / in the attributes (like in https://)
195                        // We'll mark it as self-closing and check later
196                        is_self_closing = true;
197                    }
198                } else if is_self_closing && ch != ' ' {
199                    // If we already marked as self-closing and we see a non-space character,
200                    // it means the / was in the middle of attributes (like https://),
201                    // so reset is_self_closing
202                    is_self_closing = false;
203                } else if ch == ' ' && !in_attributes {
204                    // Space marks the end of the tag name (start of attributes)
205                    in_attributes = true;
206                } else if !in_attributes && !is_self_closing {
207                    // Only add to tag_name if we're not in attributes yet and not self-closing
208                    tag_name.push(ch);
209                }
210            }
211        }
212
213        // Check if there are unclosed tags
214        if !tag_stack.is_empty() {
215            return Err(Error::Validation(format!(
216                "Unclosed tags: {}",
217                tag_stack.join(", ")
218            )));
219        }
220
221        Ok(())
222    }
223
224    /// Validate a complete TwiML response
225    pub fn validate(&self, xml: &str) -> Result<Vec<ValidationError>> {
226        let mut errors = Vec::new();
227
228        // Validate XML well-formedness
229        if let Err(e) = self.validate_xml(xml) {
230            errors.push(ValidationError::new(
231                ValidationErrorType::MalformedXml,
232                e.to_string(),
233            ));
234            // If XML is malformed, return early
235            return Ok(errors);
236        }
237
238        // Validate URLs in the TwiML
239        errors.extend(self.validate_urls(xml));
240
241        // Validate phone numbers in the TwiML
242        errors.extend(self.validate_phone_numbers(xml));
243
244        // Validate content lengths
245        errors.extend(self.validate_content_lengths(xml));
246
247        Ok(errors)
248    }
249
250    /// Validate URLs in TwiML
251    fn validate_urls(&self, xml: &str) -> Vec<ValidationError> {
252        let mut errors = Vec::new();
253
254        // Common URL attributes to check
255        let url_attributes = [
256            "action=",
257            "url=",
258            "statusCallback=",
259            "recordingStatusCallback=",
260            "transcribeCallback=",
261            "statusCallbackUrl=",
262            "fallbackUrl=",
263        ];
264
265        for attr in &url_attributes {
266            if let Some(start) = xml.find(attr) {
267                let after_attr = &xml[start + attr.len()..];
268                if let Some(quote_start) = after_attr.find('"') {
269                    let url_part = &after_attr[quote_start + 1..];
270                    if let Some(quote_end) = url_part.find('"') {
271                        let url = &url_part[..quote_end];
272
273                        // Basic URL validation
274                        if !url.is_empty()
275                            && !url.starts_with("http://")
276                            && !url.starts_with("https://")
277                            && !url.starts_with('/')
278                        {
279                            if self.strict {
280                                errors.push(
281                                    ValidationError::new(
282                                        ValidationErrorType::InvalidUrl,
283                                        format!(
284                                            "URL should start with http://, https://, or /: {}",
285                                            url
286                                        ),
287                                    )
288                                    .with_context(attr.trim_end_matches('=')),
289                                );
290                            }
291                        }
292                    }
293                }
294            }
295        }
296
297        errors
298    }
299
300    /// Validate phone numbers in TwiML
301    fn validate_phone_numbers(&self, xml: &str) -> Vec<ValidationError> {
302        let mut errors = Vec::new();
303
304        // Check for Number elements
305        if xml.contains("<Number>") {
306            let parts: Vec<&str> = xml.split("<Number>").collect();
307            for (i, part) in parts.iter().enumerate().skip(1) {
308                if let Some(end) = part.find("</Number>") {
309                    let number = &part[..end];
310
311                    // Basic phone number validation
312                    if !number.is_empty()
313                        && !number.starts_with('+')
314                        && !number.starts_with("client:")
315                        && !number.starts_with("sip:")
316                    {
317                        if self.strict {
318                            errors.push(
319                                ValidationError::new(
320                                    ValidationErrorType::InvalidPhoneNumber,
321                                    format!("Phone number should start with + or be a client/sip identifier: {}", number),
322                                )
323                                .with_context(format!("Number element #{}", i)),
324                            );
325                        }
326                    }
327                }
328            }
329        }
330
331        errors
332    }
333
334    /// Validate content lengths
335    fn validate_content_lengths(&self, xml: &str) -> Vec<ValidationError> {
336        let mut errors = Vec::new();
337
338        // Check Say content length (max 4096 characters for text, more for SSML)
339        if xml.contains("<Say>") {
340            let parts: Vec<&str> = xml.split("<Say>").collect();
341            for (i, part) in parts.iter().enumerate().skip(1) {
342                if let Some(end) = part.find("</Say>") {
343                    let content = &part[..end];
344
345                    // Basic length check (4096 for plain text)
346                    if content.len() > 4096 && !content.contains('<') {
347                        errors.push(
348                            ValidationError::new(
349                                ValidationErrorType::ContentTooLong,
350                                format!(
351                                    "Say content exceeds 4096 characters: {} characters",
352                                    content.len()
353                                ),
354                            )
355                            .with_context(format!("Say element #{}", i)),
356                        );
357                    }
358                }
359            }
360        }
361
362        // Check Message body length (max 1600 characters)
363        if xml.contains("<Body>") {
364            let parts: Vec<&str> = xml.split("<Body>").collect();
365            for (i, part) in parts.iter().enumerate().skip(1) {
366                if let Some(end) = part.find("</Body>") {
367                    let content = &part[..end];
368
369                    if content.len() > 1600 {
370                        errors.push(
371                            ValidationError::new(
372                                ValidationErrorType::ContentTooLong,
373                                format!(
374                                    "Message body exceeds 1600 characters: {} characters",
375                                    content.len()
376                                ),
377                            )
378                            .with_context(format!("Body element #{}", i)),
379                        );
380                    }
381                }
382            }
383        }
384
385        errors
386    }
387}
388
389impl Default for TwiMLValidator {
390    fn default() -> Self {
391        Self::new()
392    }
393}
394
395/// Validate a TwiML XML string
396pub fn validate_twiml(xml: &str) -> Result<Vec<ValidationError>> {
397    TwiMLValidator::new().validate(xml)
398}
399
400/// Validate a TwiML XML string with strict validation
401pub fn validate_twiml_strict(xml: &str) -> Result<Vec<ValidationError>> {
402    TwiMLValidator::strict().validate(xml)
403}
404