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)]
45pub enum ErrorType {
46 Required,
47 Invalid,
48 MinLength,
49 MaxLength,
50 MinValue,
51 MaxValue,
52 Custom(String),
53}
54
55#[derive(Debug, thiserror::Error)]
56pub enum FieldError {
57 #[error("{0}")]
58 Required(String),
59 #[error("{0}")]
60 Invalid(String),
61 #[error("{0}")]
62 Validation(String),
63}
64
65pub type FieldResult<T> = Result<T, FieldError>;
66
67impl FieldError {
68 pub fn required(custom_msg: Option<&str>) -> Self {
82 FieldError::Required(custom_msg.unwrap_or("This field is required.").to_string())
83 }
84 pub fn invalid(custom_msg: Option<&str>, default_msg: &str) -> Self {
98 FieldError::Invalid(custom_msg.unwrap_or(default_msg).to_string())
99 }
100 pub fn validation(custom_msg: Option<&str>, default_msg: &str) -> Self {
114 FieldError::Validation(custom_msg.unwrap_or(default_msg).to_string())
115 }
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
120pub enum Widget {
121 TextInput,
122 PasswordInput,
123 EmailInput,
124 NumberInput,
125 TextArea,
126 Select { choices: Vec<(String, String)> },
127 CheckboxInput,
128 RadioSelect { choices: Vec<(String, String)> },
129 DateInput,
130 DateTimeInput,
131 FileInput,
132 HiddenInput,
133}
134
135impl Widget {
136 pub fn render_html(
166 &self,
167 name: &str,
168 value: Option<&str>,
169 attrs: Option<&HashMap<String, String>>,
170 ) -> String {
171 let mut html = String::new();
172 let default_attrs = HashMap::new();
173 let attrs = attrs.unwrap_or(&default_attrs);
174
175 let escaped_name = escape_attribute(name);
177
178 let mut common_attrs = String::new();
180 for (key, val) in attrs {
181 common_attrs.push_str(&format!(
182 " {}=\"{}\"",
183 escape_attribute(key),
184 escape_attribute(val)
185 ));
186 }
187
188 match self {
189 Widget::TextInput => {
190 html.push_str(&format!(
191 "<input type=\"text\" name=\"{}\" value=\"{}\"{}",
192 escaped_name,
193 escape_attribute(value.unwrap_or("")),
194 common_attrs
195 ));
196 if !attrs.contains_key("id") {
197 html.push_str(&format!(" id=\"id_{}\"", escaped_name));
198 }
199 html.push_str(" />");
200 }
201 Widget::PasswordInput => {
202 html.push_str(&format!(
205 "<input type=\"password\" name=\"{}\"{}",
206 escaped_name, common_attrs
207 ));
208 if !attrs.contains_key("id") {
209 html.push_str(&format!(" id=\"id_{}\"", escaped_name));
210 }
211 html.push_str(" />");
212 }
213 Widget::EmailInput => {
214 html.push_str(&format!(
215 "<input type=\"email\" name=\"{}\" value=\"{}\"{}",
216 escaped_name,
217 escape_attribute(value.unwrap_or("")),
218 common_attrs
219 ));
220 if !attrs.contains_key("id") {
221 html.push_str(&format!(" id=\"id_{}\"", escaped_name));
222 }
223 html.push_str(" />");
224 }
225 Widget::NumberInput => {
226 html.push_str(&format!(
227 "<input type=\"number\" name=\"{}\" value=\"{}\"{}",
228 escaped_name,
229 escape_attribute(value.unwrap_or("")),
230 common_attrs
231 ));
232 if !attrs.contains_key("id") {
233 html.push_str(&format!(" id=\"id_{}\"", escaped_name));
234 }
235 html.push_str(" />");
236 }
237 Widget::TextArea => {
238 html.push_str(&format!(
239 "<textarea name=\"{}\"{}",
240 escaped_name, common_attrs
241 ));
242 if !attrs.contains_key("id") {
243 html.push_str(&format!(" id=\"id_{}\"", escaped_name));
244 }
245 html.push('>');
246 html.push_str(&html_escape(value.unwrap_or("")));
248 html.push_str("</textarea>");
249 }
250 Widget::Select { choices } => {
251 html.push_str(&format!(
252 "<select name=\"{}\"{}",
253 escaped_name, common_attrs
254 ));
255 if !attrs.contains_key("id") {
256 html.push_str(&format!(" id=\"id_{}\"", escaped_name));
257 }
258 html.push('>');
259 for (choice_value, choice_label) in choices {
260 let selected = if Some(choice_value.as_str()) == value {
261 " selected"
262 } else {
263 ""
264 };
265 html.push_str(&format!(
266 "<option value=\"{}\"{}>{}</option>",
267 escape_attribute(choice_value),
268 selected,
269 html_escape(choice_label)
270 ));
271 }
272 html.push_str("</select>");
273 }
274 Widget::CheckboxInput => {
275 html.push_str(&format!(
276 "<input type=\"checkbox\" name=\"{}\"",
277 escaped_name
278 ));
279 if value == Some("true") || value == Some("on") {
280 html.push_str(" checked");
281 }
282 html.push_str(&common_attrs);
283 if !attrs.contains_key("id") {
284 html.push_str(&format!(" id=\"id_{}\"", escaped_name));
285 }
286 html.push_str(" />");
287 }
288 Widget::RadioSelect { choices } => {
289 for (i, (choice_value, choice_label)) in choices.iter().enumerate() {
290 let checked = if Some(choice_value.as_str()) == value {
291 " checked"
292 } else {
293 ""
294 };
295 html.push_str(&format!(
296 "<input type=\"radio\" name=\"{}\" value=\"{}\" id=\"id_{}_{}\"{}{} />",
297 escaped_name,
298 escape_attribute(choice_value),
299 escaped_name,
300 i,
301 checked,
302 common_attrs
303 ));
304 html.push_str(&format!(
305 "<label for=\"id_{}_{}\">{}</label>",
306 escaped_name,
307 i,
308 html_escape(choice_label)
309 ));
310 }
311 }
312 Widget::DateInput => {
313 html.push_str(&format!(
314 "<input type=\"date\" name=\"{}\" value=\"{}\"{}",
315 escaped_name,
316 escape_attribute(value.unwrap_or("")),
317 common_attrs
318 ));
319 if !attrs.contains_key("id") {
320 html.push_str(&format!(" id=\"id_{}\"", escaped_name));
321 }
322 html.push_str(" />");
323 }
324 Widget::DateTimeInput => {
325 html.push_str(&format!(
326 "<input type=\"datetime-local\" name=\"{}\" value=\"{}\"{}",
327 escaped_name,
328 escape_attribute(value.unwrap_or("")),
329 common_attrs
330 ));
331 if !attrs.contains_key("id") {
332 html.push_str(&format!(" id=\"id_{}\"", escaped_name));
333 }
334 html.push_str(" />");
335 }
336 Widget::FileInput => {
337 html.push_str(&format!(
338 "<input type=\"file\" name=\"{}\"{}",
339 escaped_name, common_attrs
340 ));
341 if !attrs.contains_key("id") {
342 html.push_str(&format!(" id=\"id_{}\"", escaped_name));
343 }
344 html.push_str(" />");
345 }
346 Widget::HiddenInput => {
347 html.push_str(&format!(
348 "<input type=\"hidden\" name=\"{}\" value=\"{}\" />",
349 escaped_name,
350 escape_attribute(value.unwrap_or(""))
351 ));
352 }
353 }
354
355 html
356 }
357}
358
359pub trait FormField: Send + Sync {
363 fn name(&self) -> &str;
364 fn label(&self) -> Option<&str>;
365 fn required(&self) -> bool;
366 fn help_text(&self) -> Option<&str>;
367 fn widget(&self) -> &Widget;
368 fn initial(&self) -> Option<&serde_json::Value>;
369
370 fn clean(&self, value: Option<&serde_json::Value>) -> FieldResult<serde_json::Value>;
371
372 fn has_changed(
374 &self,
375 initial: Option<&serde_json::Value>,
376 data: Option<&serde_json::Value>,
377 ) -> bool {
378 match (initial, data) {
380 (None, None) => false,
381 (Some(_), None) | (None, Some(_)) => true,
382 (Some(i), Some(d)) => i != d,
383 }
384 }
385
386 fn error_messages(&self) -> HashMap<ErrorType, String> {
388 HashMap::new()
389 }
390}
391
392#[cfg(test)]
393mod tests {
394 use super::*;
395
396 #[test]
400 fn test_field_has_changed() {
401 use crate::fields::CharField;
402
403 let field = CharField::new("name".to_string());
404
405 assert!(!field.has_changed(None, None));
407
408 assert!(field.has_changed(None, Some(&serde_json::json!("John"))));
410
411 assert!(field.has_changed(Some(&serde_json::json!("John")), None));
413
414 assert!(!field.has_changed(
416 Some(&serde_json::json!("John")),
417 Some(&serde_json::json!("John"))
418 ));
419
420 assert!(field.has_changed(
422 Some(&serde_json::json!("John")),
423 Some(&serde_json::json!("Jane"))
424 ));
425 }
426
427 #[test]
428 fn test_field_error_messages() {
429 use crate::fields::CharField;
430
431 let field = CharField::new("name".to_string());
432
433 assert!(field.error_messages().is_empty());
435 }
436
437 #[test]
442 fn test_html_escape_basic() {
443 assert_eq!(html_escape("<script>"), "<script>");
444 assert_eq!(html_escape("a & b"), "a & b");
445 assert_eq!(html_escape("\"quoted\""), ""quoted"");
446 assert_eq!(html_escape("'single'"), "'single'");
447 }
448
449 #[test]
450 fn test_html_escape_all_special_chars() {
451 let input = "<script>alert('xss')&\"</script>";
452 let expected = "<script>alert('xss')&"</script>";
453 assert_eq!(html_escape(input), expected);
454 }
455
456 #[test]
457 fn test_html_escape_no_special_chars() {
458 assert_eq!(html_escape("normal text"), "normal text");
459 assert_eq!(html_escape(""), "");
460 }
461
462 #[test]
463 fn test_escape_attribute() {
464 assert_eq!(escape_attribute("on\"click"), "on"click");
465 assert_eq!(
466 escape_attribute("javascript:alert('xss')"),
467 "javascript:alert('xss')"
468 );
469 }
470
471 #[test]
472 fn test_widget_render_html_escapes_value_in_text_input() {
473 let widget = Widget::TextInput;
474 let xss_payload = "\"><script>alert('xss')</script>";
475 let html = widget.render_html("field", Some(xss_payload), None);
476
477 assert!(!html.contains("<script>"));
479 assert!(html.contains("<script>"));
481 assert!(html.contains("""));
482 }
483
484 #[test]
485 fn test_widget_render_html_escapes_name() {
486 let widget = Widget::TextInput;
487 let xss_name = "field\"><script>alert('xss')</script>";
488 let html = widget.render_html(xss_name, Some("value"), None);
489
490 assert!(!html.contains("<script>"));
492 assert!(html.contains("<script>"));
494 }
495
496 #[test]
497 fn test_widget_render_html_escapes_textarea_content() {
498 let widget = Widget::TextArea;
499 let xss_content = "</textarea><script>alert('xss')</script>";
500 let html = widget.render_html("comment", Some(xss_content), None);
501
502 assert!(!html.contains("<script>"));
504 assert!(html.contains("<script>"));
506 assert!(!html.contains("</textarea><"));
508 }
509
510 #[test]
511 fn test_widget_render_html_escapes_select_choices() {
512 let widget = Widget::Select {
513 choices: vec![
514 (
515 "value\"><script>alert('xss')</script>".to_string(),
516 "Label".to_string(),
517 ),
518 (
519 "safe_value".to_string(),
520 "</option><script>alert('xss')</script>".to_string(),
521 ),
522 ],
523 };
524
525 let html = widget.render_html("choice", Some("safe_value"), None);
526
527 assert!(!html.contains("<script>"));
529 assert!(html.contains("<script>"));
531 assert!(html.contains("</option>"));
533 }
534
535 #[test]
536 fn test_widget_render_html_escapes_radio_choices() {
537 let widget = Widget::RadioSelect {
538 choices: vec![(
539 "value\"><script>alert('xss')</script>".to_string(),
540 "</label><script>alert('xss')</script>".to_string(),
541 )],
542 };
543
544 let html = widget.render_html("radio", None, None);
545
546 assert!(!html.contains("<script>"));
548 assert!(html.contains("<script>"));
550 }
551
552 #[test]
553 fn test_widget_render_html_escapes_attributes() {
554 let widget = Widget::TextInput;
555 let mut attrs = HashMap::new();
556 attrs.insert("class".to_string(), "\" onclick=\"alert('xss')".to_string());
557 attrs.insert(
558 "data-evil".to_string(),
559 "\"><script>alert('xss')</script>".to_string(),
560 );
561
562 let html = widget.render_html("field", Some("value"), Some(&attrs));
563
564 assert!(!html.contains("<script>"));
566 assert!(html.contains("<script>"));
567 assert!(html.contains("""));
568 }
569
570 #[test]
571 fn test_widget_render_html_all_widget_types_escape_value() {
572 let xss_payload = "\"><script>alert('xss')</script>";
573
574 let widgets_with_value: Vec<Widget> = vec![
576 Widget::TextInput,
577 Widget::EmailInput,
578 Widget::NumberInput,
579 Widget::TextArea,
580 Widget::DateInput,
581 Widget::DateTimeInput,
582 Widget::HiddenInput,
583 ];
584
585 for widget in widgets_with_value {
586 let html = widget.render_html("field", Some(xss_payload), None);
587 assert!(
588 !html.contains("<script>"),
589 "Widget {:?} did not escape XSS payload",
590 widget
591 );
592 assert!(
593 html.contains("<script>"),
594 "Widget {:?} did not encode < and > characters",
595 widget
596 );
597 }
598
599 let password_html = Widget::PasswordInput.render_html("field", Some(xss_payload), None);
601 assert!(
602 !password_html.contains("value="),
603 "PasswordInput should never render the value attribute"
604 );
605 }
606
607 #[test]
608 fn test_widget_render_html_normal_values_preserved() {
609 let widget = Widget::TextInput;
610 let html = widget.render_html("username", Some("john_doe"), None);
611
612 assert!(html.contains("name=\"username\""));
614 assert!(html.contains("value=\"john_doe\""));
615 }
616
617 #[test]
618 fn test_widget_render_html_ampersand_escaped_first() {
619 let input = "<"; let result = html_escape(input);
624 assert_eq!(result, "&lt;");
626 }
627}