1use crate::error::{Error, Result};
7
8#[derive(Debug, Clone, PartialEq)]
10pub struct ValidationError {
11 pub error_type: ValidationErrorType,
13 pub message: String,
15 pub context: Option<String>,
17}
18
19#[derive(Debug, Clone, PartialEq)]
21pub enum ValidationErrorType {
22 MalformedXml,
24 MissingRequiredAttribute,
26 InvalidAttributeValue,
28 InvalidNesting,
30 ContentTooLong,
32 InvalidUrl,
34 InvalidPhoneNumber,
36 EmptyRequiredField,
38 InvalidEnumValue,
40 UnsupportedCombination,
42}
43
44impl ValidationError {
45 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 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
88pub struct TwiMLValidator {
90 strict: bool,
92}
93
94impl TwiMLValidator {
95 pub fn new() -> Self {
97 Self { strict: false }
98 }
99
100 pub fn strict() -> Self {
102 Self { strict: true }
103 }
104
105 pub fn set_strict(mut self, strict: bool) -> Self {
107 self.strict = strict;
108 self
109 }
110
111 pub fn validate_xml(&self, xml: &str) -> Result<()> {
113 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 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 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; 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 if tag_name.starts_with('?') || tag_name.starts_with('!') {
152 continue;
153 }
154
155 if is_self_closing {
157 continue;
158 }
159
160 let tag = tag_name
162 .trim()
163 .split_whitespace()
164 .next()
165 .unwrap_or("")
166 .trim();
167
168 if is_closing {
169 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 tag_stack.push(tag.to_string());
186 }
187 } else if in_tag {
188 if ch == '/' {
189 if tag_name.is_empty() {
190 is_closing = true;
192 } else {
193 is_self_closing = true;
197 }
198 } else if is_self_closing && ch != ' ' {
199 is_self_closing = false;
203 } else if ch == ' ' && !in_attributes {
204 in_attributes = true;
206 } else if !in_attributes && !is_self_closing {
207 tag_name.push(ch);
209 }
210 }
211 }
212
213 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 pub fn validate(&self, xml: &str) -> Result<Vec<ValidationError>> {
226 let mut errors = Vec::new();
227
228 if let Err(e) = self.validate_xml(xml) {
230 errors.push(ValidationError::new(
231 ValidationErrorType::MalformedXml,
232 e.to_string(),
233 ));
234 return Ok(errors);
236 }
237
238 errors.extend(self.validate_urls(xml));
240
241 errors.extend(self.validate_phone_numbers(xml));
243
244 errors.extend(self.validate_content_lengths(xml));
246
247 Ok(errors)
248 }
249
250 fn validate_urls(&self, xml: &str) -> Vec<ValidationError> {
252 let mut errors = Vec::new();
253
254 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 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 fn validate_phone_numbers(&self, xml: &str) -> Vec<ValidationError> {
302 let mut errors = Vec::new();
303
304 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 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 fn validate_content_lengths(&self, xml: &str) -> Vec<ValidationError> {
336 let mut errors = Vec::new();
337
338 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 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 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
395pub fn validate_twiml(xml: &str) -> Result<Vec<ValidationError>> {
397 TwiMLValidator::new().validate(xml)
398}
399
400pub fn validate_twiml_strict(xml: &str) -> Result<Vec<ValidationError>> {
402 TwiMLValidator::strict().validate(xml)
403}
404