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 is_sensitive(&self) -> bool {
411 false
412 }
413 fn initial(&self) -> Option<&serde_json::Value>;
415
416 fn clean(&self, value: Option<&serde_json::Value>) -> FieldResult<serde_json::Value>;
418
419 fn has_changed(
421 &self,
422 initial: Option<&serde_json::Value>,
423 data: Option<&serde_json::Value>,
424 ) -> bool {
425 match (initial, data) {
427 (None, None) => false,
428 (Some(_), None) | (None, Some(_)) => true,
429 (Some(i), Some(d)) => i != d,
430 }
431 }
432
433 fn error_messages(&self) -> HashMap<ErrorType, String> {
435 HashMap::new()
436 }
437}
438
439#[cfg(test)]
440mod tests {
441 use super::*;
442
443 #[test]
447 fn test_field_has_changed() {
448 use crate::fields::CharField;
449
450 let field = CharField::new("name".to_string());
451
452 assert!(!field.has_changed(None, None));
454
455 assert!(field.has_changed(None, Some(&serde_json::json!("John"))));
457
458 assert!(field.has_changed(Some(&serde_json::json!("John")), None));
460
461 assert!(!field.has_changed(
463 Some(&serde_json::json!("John")),
464 Some(&serde_json::json!("John"))
465 ));
466
467 assert!(field.has_changed(
469 Some(&serde_json::json!("John")),
470 Some(&serde_json::json!("Jane"))
471 ));
472 }
473
474 #[test]
475 fn test_field_error_messages() {
476 use crate::fields::CharField;
477
478 let field = CharField::new("name".to_string());
479
480 assert!(field.error_messages().is_empty());
482 }
483
484 #[test]
489 fn test_html_escape_basic() {
490 assert_eq!(html_escape("<script>"), "<script>");
491 assert_eq!(html_escape("a & b"), "a & b");
492 assert_eq!(html_escape("\"quoted\""), ""quoted"");
493 assert_eq!(html_escape("'single'"), "'single'");
494 }
495
496 #[test]
497 fn test_html_escape_all_special_chars() {
498 let input = "<script>alert('xss')&\"</script>";
499 let expected = "<script>alert('xss')&"</script>";
500 assert_eq!(html_escape(input), expected);
501 }
502
503 #[test]
504 fn test_html_escape_no_special_chars() {
505 assert_eq!(html_escape("normal text"), "normal text");
506 assert_eq!(html_escape(""), "");
507 }
508
509 #[test]
510 fn test_escape_attribute() {
511 assert_eq!(escape_attribute("on\"click"), "on"click");
512 assert_eq!(
513 escape_attribute("javascript:alert('xss')"),
514 "javascript:alert('xss')"
515 );
516 }
517
518 #[test]
519 fn test_widget_render_html_escapes_value_in_text_input() {
520 let widget = Widget::TextInput;
521 let xss_payload = "\"><script>alert('xss')</script>";
522 let html = widget.render_html("field", Some(xss_payload), None);
523
524 assert!(!html.contains("<script>"));
526 assert!(html.contains("<script>"));
528 assert!(html.contains("""));
529 }
530
531 #[test]
532 fn test_widget_render_html_escapes_name() {
533 let widget = Widget::TextInput;
534 let xss_name = "field\"><script>alert('xss')</script>";
535 let html = widget.render_html(xss_name, Some("value"), None);
536
537 assert!(!html.contains("<script>"));
539 assert!(html.contains("<script>"));
541 }
542
543 #[test]
544 fn test_widget_render_html_escapes_textarea_content() {
545 let widget = Widget::TextArea;
546 let xss_content = "</textarea><script>alert('xss')</script>";
547 let html = widget.render_html("comment", Some(xss_content), None);
548
549 assert!(!html.contains("<script>"));
551 assert!(html.contains("<script>"));
553 assert!(!html.contains("</textarea><"));
555 }
556
557 #[test]
558 fn test_widget_render_html_escapes_select_choices() {
559 let widget = Widget::Select {
560 choices: vec![
561 (
562 "value\"><script>alert('xss')</script>".to_string(),
563 "Label".to_string(),
564 ),
565 (
566 "safe_value".to_string(),
567 "</option><script>alert('xss')</script>".to_string(),
568 ),
569 ],
570 };
571
572 let html = widget.render_html("choice", Some("safe_value"), None);
573
574 assert!(!html.contains("<script>"));
576 assert!(html.contains("<script>"));
578 assert!(html.contains("</option>"));
580 }
581
582 #[test]
583 fn test_widget_render_html_escapes_radio_choices() {
584 let widget = Widget::RadioSelect {
585 choices: vec![(
586 "value\"><script>alert('xss')</script>".to_string(),
587 "</label><script>alert('xss')</script>".to_string(),
588 )],
589 };
590
591 let html = widget.render_html("radio", None, None);
592
593 assert!(!html.contains("<script>"));
595 assert!(html.contains("<script>"));
597 }
598
599 #[test]
600 fn test_widget_render_html_escapes_attributes() {
601 let widget = Widget::TextInput;
602 let mut attrs = HashMap::new();
603 attrs.insert("class".to_string(), "\" onclick=\"alert('xss')".to_string());
604 attrs.insert(
605 "data-evil".to_string(),
606 "\"><script>alert('xss')</script>".to_string(),
607 );
608
609 let html = widget.render_html("field", Some("value"), Some(&attrs));
610
611 assert!(!html.contains("<script>"));
613 assert!(html.contains("<script>"));
614 assert!(html.contains("""));
615 }
616
617 #[test]
618 fn test_widget_render_html_all_widget_types_escape_value() {
619 let xss_payload = "\"><script>alert('xss')</script>";
620
621 let widgets_with_value: Vec<Widget> = vec![
623 Widget::TextInput,
624 Widget::EmailInput,
625 Widget::NumberInput,
626 Widget::TextArea,
627 Widget::DateInput,
628 Widget::DateTimeInput,
629 Widget::HiddenInput,
630 ];
631
632 for widget in widgets_with_value {
633 let html = widget.render_html("field", Some(xss_payload), None);
634 assert!(
635 !html.contains("<script>"),
636 "Widget {:?} did not escape XSS payload",
637 widget
638 );
639 assert!(
640 html.contains("<script>"),
641 "Widget {:?} did not encode < and > characters",
642 widget
643 );
644 }
645
646 let password_html = Widget::PasswordInput.render_html("field", Some(xss_payload), None);
648 assert!(
649 !password_html.contains("value="),
650 "PasswordInput should never render the value attribute"
651 );
652 }
653
654 #[test]
655 fn test_widget_render_html_normal_values_preserved() {
656 let widget = Widget::TextInput;
657 let html = widget.render_html("username", Some("john_doe"), None);
658
659 assert!(html.contains("name=\"username\""));
661 assert!(html.contains("value=\"john_doe\""));
662 }
663
664 #[test]
665 fn test_widget_render_html_ampersand_escaped_first() {
666 let input = "<"; let result = html_escape(input);
671 assert_eq!(result, "&lt;");
673 }
674}