1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4pub fn html_escape(s: &str) -> String {
23 s.replace('&', "&")
24 .replace('<', "<")
25 .replace('>', ">")
26 .replace('"', """)
27 .replace('\'', "'")
28}
29
30pub fn escape_attribute(s: &str) -> String {
41 html_escape(s)
42}
43
44#[derive(Debug, Clone, PartialEq, Eq, Hash)]
48pub enum ErrorType {
49 Required,
51 Invalid,
53 MinLength,
55 MaxLength,
57 MinValue,
59 MaxValue,
61 Custom(String),
63}
64
65#[derive(Debug, thiserror::Error)]
67pub enum FieldError {
68 #[error("{0}")]
70 Required(String),
71 #[error("{0}")]
73 Invalid(String),
74 #[error("{0}")]
76 Validation(String),
77}
78
79pub type FieldResult<T> = Result<T, FieldError>;
81
82impl FieldError {
83 pub fn required(custom_msg: Option<&str>) -> Self {
97 FieldError::Required(custom_msg.unwrap_or("This field is required.").to_string())
98 }
99 pub fn invalid(custom_msg: Option<&str>, default_msg: &str) -> Self {
113 FieldError::Invalid(custom_msg.unwrap_or(default_msg).to_string())
114 }
115 pub fn validation(custom_msg: Option<&str>, default_msg: &str) -> Self {
129 FieldError::Validation(custom_msg.unwrap_or(default_msg).to_string())
130 }
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
135pub enum Widget {
136 TextInput,
138 PasswordInput,
140 EmailInput,
142 NumberInput,
144 TextArea,
146 Select {
148 choices: Vec<(String, String)>,
150 },
151 CheckboxInput,
153 RadioSelect {
155 choices: Vec<(String, String)>,
157 },
158 DateInput,
160 DateTimeInput,
162 FileInput,
164 HiddenInput,
166}
167
168impl Widget {
169 pub fn render_html(
199 &self,
200 name: &str,
201 value: Option<&str>,
202 attrs: Option<&HashMap<String, String>>,
203 ) -> String {
204 let mut html = String::new();
205 let default_attrs = HashMap::new();
206 let attrs = attrs.unwrap_or(&default_attrs);
207
208 let escaped_name = escape_attribute(name);
210
211 let mut common_attrs = String::new();
213 for (key, val) in attrs {
214 common_attrs.push_str(&format!(
215 " {}=\"{}\"",
216 escape_attribute(key),
217 escape_attribute(val)
218 ));
219 }
220
221 match self {
222 Widget::TextInput => {
223 html.push_str(&format!(
224 "<input type=\"text\" name=\"{}\" value=\"{}\"{}",
225 escaped_name,
226 escape_attribute(value.unwrap_or("")),
227 common_attrs
228 ));
229 if !attrs.contains_key("id") {
230 html.push_str(&format!(" id=\"id_{}\"", escaped_name));
231 }
232 html.push_str(" />");
233 }
234 Widget::PasswordInput => {
235 html.push_str(&format!(
238 "<input type=\"password\" name=\"{}\"{}",
239 escaped_name, common_attrs
240 ));
241 if !attrs.contains_key("id") {
242 html.push_str(&format!(" id=\"id_{}\"", escaped_name));
243 }
244 html.push_str(" />");
245 }
246 Widget::EmailInput => {
247 html.push_str(&format!(
248 "<input type=\"email\" name=\"{}\" value=\"{}\"{}",
249 escaped_name,
250 escape_attribute(value.unwrap_or("")),
251 common_attrs
252 ));
253 if !attrs.contains_key("id") {
254 html.push_str(&format!(" id=\"id_{}\"", escaped_name));
255 }
256 html.push_str(" />");
257 }
258 Widget::NumberInput => {
259 html.push_str(&format!(
260 "<input type=\"number\" name=\"{}\" value=\"{}\"{}",
261 escaped_name,
262 escape_attribute(value.unwrap_or("")),
263 common_attrs
264 ));
265 if !attrs.contains_key("id") {
266 html.push_str(&format!(" id=\"id_{}\"", escaped_name));
267 }
268 html.push_str(" />");
269 }
270 Widget::TextArea => {
271 html.push_str(&format!(
272 "<textarea name=\"{}\"{}",
273 escaped_name, common_attrs
274 ));
275 if !attrs.contains_key("id") {
276 html.push_str(&format!(" id=\"id_{}\"", escaped_name));
277 }
278 html.push('>');
279 html.push_str(&html_escape(value.unwrap_or("")));
281 html.push_str("</textarea>");
282 }
283 Widget::Select { choices } => {
284 html.push_str(&format!(
285 "<select name=\"{}\"{}",
286 escaped_name, common_attrs
287 ));
288 if !attrs.contains_key("id") {
289 html.push_str(&format!(" id=\"id_{}\"", escaped_name));
290 }
291 html.push('>');
292 for (choice_value, choice_label) in choices {
293 let selected = if Some(choice_value.as_str()) == value {
294 " selected"
295 } else {
296 ""
297 };
298 html.push_str(&format!(
299 "<option value=\"{}\"{}>{}</option>",
300 escape_attribute(choice_value),
301 selected,
302 html_escape(choice_label)
303 ));
304 }
305 html.push_str("</select>");
306 }
307 Widget::CheckboxInput => {
308 html.push_str(&format!(
309 "<input type=\"checkbox\" name=\"{}\"",
310 escaped_name
311 ));
312 if value == Some("true") || value == Some("on") {
313 html.push_str(" checked");
314 }
315 html.push_str(&common_attrs);
316 if !attrs.contains_key("id") {
317 html.push_str(&format!(" id=\"id_{}\"", escaped_name));
318 }
319 html.push_str(" />");
320 }
321 Widget::RadioSelect { choices } => {
322 for (i, (choice_value, choice_label)) in choices.iter().enumerate() {
323 let checked = if Some(choice_value.as_str()) == value {
324 " checked"
325 } else {
326 ""
327 };
328 html.push_str(&format!(
329 "<input type=\"radio\" name=\"{}\" value=\"{}\" id=\"id_{}_{}\"{}{} />",
330 escaped_name,
331 escape_attribute(choice_value),
332 escaped_name,
333 i,
334 checked,
335 common_attrs
336 ));
337 html.push_str(&format!(
338 "<label for=\"id_{}_{}\">{}</label>",
339 escaped_name,
340 i,
341 html_escape(choice_label)
342 ));
343 }
344 }
345 Widget::DateInput => {
346 html.push_str(&format!(
347 "<input type=\"date\" name=\"{}\" value=\"{}\"{}",
348 escaped_name,
349 escape_attribute(value.unwrap_or("")),
350 common_attrs
351 ));
352 if !attrs.contains_key("id") {
353 html.push_str(&format!(" id=\"id_{}\"", escaped_name));
354 }
355 html.push_str(" />");
356 }
357 Widget::DateTimeInput => {
358 html.push_str(&format!(
359 "<input type=\"datetime-local\" name=\"{}\" value=\"{}\"{}",
360 escaped_name,
361 escape_attribute(value.unwrap_or("")),
362 common_attrs
363 ));
364 if !attrs.contains_key("id") {
365 html.push_str(&format!(" id=\"id_{}\"", escaped_name));
366 }
367 html.push_str(" />");
368 }
369 Widget::FileInput => {
370 html.push_str(&format!(
371 "<input type=\"file\" name=\"{}\"{}",
372 escaped_name, common_attrs
373 ));
374 if !attrs.contains_key("id") {
375 html.push_str(&format!(" id=\"id_{}\"", escaped_name));
376 }
377 html.push_str(" />");
378 }
379 Widget::HiddenInput => {
380 html.push_str(&format!(
381 "<input type=\"hidden\" name=\"{}\" value=\"{}\" />",
382 escaped_name,
383 escape_attribute(value.unwrap_or(""))
384 ));
385 }
386 }
387
388 html
389 }
390}
391
392pub trait FormField: Send + Sync {
396 fn name(&self) -> &str;
398 fn label(&self) -> Option<&str>;
400 fn required(&self) -> bool;
402 fn help_text(&self) -> Option<&str>;
404 fn widget(&self) -> &Widget;
406 fn initial(&self) -> Option<&serde_json::Value>;
408
409 fn clean(&self, value: Option<&serde_json::Value>) -> FieldResult<serde_json::Value>;
411
412 fn has_changed(
414 &self,
415 initial: Option<&serde_json::Value>,
416 data: Option<&serde_json::Value>,
417 ) -> bool {
418 match (initial, data) {
420 (None, None) => false,
421 (Some(_), None) | (None, Some(_)) => true,
422 (Some(i), Some(d)) => i != d,
423 }
424 }
425
426 fn error_messages(&self) -> HashMap<ErrorType, String> {
428 HashMap::new()
429 }
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435
436 #[test]
440 fn test_field_has_changed() {
441 use crate::fields::CharField;
442
443 let field = CharField::new("name".to_string());
444
445 assert!(!field.has_changed(None, None));
447
448 assert!(field.has_changed(None, Some(&serde_json::json!("John"))));
450
451 assert!(field.has_changed(Some(&serde_json::json!("John")), None));
453
454 assert!(!field.has_changed(
456 Some(&serde_json::json!("John")),
457 Some(&serde_json::json!("John"))
458 ));
459
460 assert!(field.has_changed(
462 Some(&serde_json::json!("John")),
463 Some(&serde_json::json!("Jane"))
464 ));
465 }
466
467 #[test]
468 fn test_field_error_messages() {
469 use crate::fields::CharField;
470
471 let field = CharField::new("name".to_string());
472
473 assert!(field.error_messages().is_empty());
475 }
476
477 #[test]
482 fn test_html_escape_basic() {
483 assert_eq!(html_escape("<script>"), "<script>");
484 assert_eq!(html_escape("a & b"), "a & b");
485 assert_eq!(html_escape("\"quoted\""), ""quoted"");
486 assert_eq!(html_escape("'single'"), "'single'");
487 }
488
489 #[test]
490 fn test_html_escape_all_special_chars() {
491 let input = "<script>alert('xss')&\"</script>";
492 let expected = "<script>alert('xss')&"</script>";
493 assert_eq!(html_escape(input), expected);
494 }
495
496 #[test]
497 fn test_html_escape_no_special_chars() {
498 assert_eq!(html_escape("normal text"), "normal text");
499 assert_eq!(html_escape(""), "");
500 }
501
502 #[test]
503 fn test_escape_attribute() {
504 assert_eq!(escape_attribute("on\"click"), "on"click");
505 assert_eq!(
506 escape_attribute("javascript:alert('xss')"),
507 "javascript:alert('xss')"
508 );
509 }
510
511 #[test]
512 fn test_widget_render_html_escapes_value_in_text_input() {
513 let widget = Widget::TextInput;
514 let xss_payload = "\"><script>alert('xss')</script>";
515 let html = widget.render_html("field", Some(xss_payload), None);
516
517 assert!(!html.contains("<script>"));
519 assert!(html.contains("<script>"));
521 assert!(html.contains("""));
522 }
523
524 #[test]
525 fn test_widget_render_html_escapes_name() {
526 let widget = Widget::TextInput;
527 let xss_name = "field\"><script>alert('xss')</script>";
528 let html = widget.render_html(xss_name, Some("value"), None);
529
530 assert!(!html.contains("<script>"));
532 assert!(html.contains("<script>"));
534 }
535
536 #[test]
537 fn test_widget_render_html_escapes_textarea_content() {
538 let widget = Widget::TextArea;
539 let xss_content = "</textarea><script>alert('xss')</script>";
540 let html = widget.render_html("comment", Some(xss_content), None);
541
542 assert!(!html.contains("<script>"));
544 assert!(html.contains("<script>"));
546 assert!(!html.contains("</textarea><"));
548 }
549
550 #[test]
551 fn test_widget_render_html_escapes_select_choices() {
552 let widget = Widget::Select {
553 choices: vec![
554 (
555 "value\"><script>alert('xss')</script>".to_string(),
556 "Label".to_string(),
557 ),
558 (
559 "safe_value".to_string(),
560 "</option><script>alert('xss')</script>".to_string(),
561 ),
562 ],
563 };
564
565 let html = widget.render_html("choice", Some("safe_value"), None);
566
567 assert!(!html.contains("<script>"));
569 assert!(html.contains("<script>"));
571 assert!(html.contains("</option>"));
573 }
574
575 #[test]
576 fn test_widget_render_html_escapes_radio_choices() {
577 let widget = Widget::RadioSelect {
578 choices: vec![(
579 "value\"><script>alert('xss')</script>".to_string(),
580 "</label><script>alert('xss')</script>".to_string(),
581 )],
582 };
583
584 let html = widget.render_html("radio", None, None);
585
586 assert!(!html.contains("<script>"));
588 assert!(html.contains("<script>"));
590 }
591
592 #[test]
593 fn test_widget_render_html_escapes_attributes() {
594 let widget = Widget::TextInput;
595 let mut attrs = HashMap::new();
596 attrs.insert("class".to_string(), "\" onclick=\"alert('xss')".to_string());
597 attrs.insert(
598 "data-evil".to_string(),
599 "\"><script>alert('xss')</script>".to_string(),
600 );
601
602 let html = widget.render_html("field", Some("value"), Some(&attrs));
603
604 assert!(!html.contains("<script>"));
606 assert!(html.contains("<script>"));
607 assert!(html.contains("""));
608 }
609
610 #[test]
611 fn test_widget_render_html_all_widget_types_escape_value() {
612 let xss_payload = "\"><script>alert('xss')</script>";
613
614 let widgets_with_value: Vec<Widget> = vec![
616 Widget::TextInput,
617 Widget::EmailInput,
618 Widget::NumberInput,
619 Widget::TextArea,
620 Widget::DateInput,
621 Widget::DateTimeInput,
622 Widget::HiddenInput,
623 ];
624
625 for widget in widgets_with_value {
626 let html = widget.render_html("field", Some(xss_payload), None);
627 assert!(
628 !html.contains("<script>"),
629 "Widget {:?} did not escape XSS payload",
630 widget
631 );
632 assert!(
633 html.contains("<script>"),
634 "Widget {:?} did not encode < and > characters",
635 widget
636 );
637 }
638
639 let password_html = Widget::PasswordInput.render_html("field", Some(xss_payload), None);
641 assert!(
642 !password_html.contains("value="),
643 "PasswordInput should never render the value attribute"
644 );
645 }
646
647 #[test]
648 fn test_widget_render_html_normal_values_preserved() {
649 let widget = Widget::TextInput;
650 let html = widget.render_html("username", Some("john_doe"), None);
651
652 assert!(html.contains("name=\"username\""));
654 assert!(html.contains("value=\"john_doe\""));
655 }
656
657 #[test]
658 fn test_widget_render_html_ampersand_escaped_first() {
659 let input = "<"; let result = html_escape(input);
664 assert_eq!(result, "&lt;");
666 }
667}