Skip to main content

reinhardt_forms/
field.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4/// Escapes special HTML characters to prevent XSS attacks.
5///
6/// This function converts the following characters to their HTML entity equivalents:
7/// - `&` → `&`
8/// - `<` → `&lt;`
9/// - `>` → `&gt;`
10/// - `"` → `&quot;`
11/// - `'` → `&#x27;`
12///
13/// # Examples
14///
15/// ```
16/// use reinhardt_forms::field::html_escape;
17///
18/// assert_eq!(html_escape("<script>"), "&lt;script&gt;");
19/// assert_eq!(html_escape("a & b"), "a &amp; b");
20/// assert_eq!(html_escape("\"quoted\""), "&quot;quoted&quot;");
21/// ```
22pub fn html_escape(s: &str) -> String {
23	s.replace('&', "&amp;")
24		.replace('<', "&lt;")
25		.replace('>', "&gt;")
26		.replace('"', "&quot;")
27		.replace('\'', "&#x27;")
28}
29
30/// Escapes a value for use in an HTML attribute context.
31/// This is an alias for [`html_escape`] as the escaping rules are the same.
32///
33/// # Examples
34///
35/// ```
36/// use reinhardt_forms::field::escape_attribute;
37///
38/// assert_eq!(escape_attribute("on\"click"), "on&quot;click");
39/// ```
40pub fn escape_attribute(s: &str) -> String {
41	html_escape(s)
42}
43
44/// Categories of validation errors that can occur during field cleaning.
45///
46/// Used as keys in custom error message maps to override default messages.
47#[derive(Debug, Clone, PartialEq, Eq, Hash)]
48pub enum ErrorType {
49	/// The field value is missing but required.
50	Required,
51	/// The field value has an invalid format or type.
52	Invalid,
53	/// The field value is shorter than the minimum length.
54	MinLength,
55	/// The field value exceeds the maximum length.
56	MaxLength,
57	/// The field value is below the minimum allowed value.
58	MinValue,
59	/// The field value exceeds the maximum allowed value.
60	MaxValue,
61	/// A custom application-defined error category.
62	Custom(String),
63}
64
65/// Error type returned when field validation fails.
66#[derive(Debug, thiserror::Error)]
67pub enum FieldError {
68	/// The field is required but no value was provided.
69	#[error("{0}")]
70	Required(String),
71	/// The field value has an invalid format or type.
72	#[error("{0}")]
73	Invalid(String),
74	/// The field value failed a validation constraint (e.g., length, range).
75	#[error("{0}")]
76	Validation(String),
77}
78
79/// Result type alias for field validation operations.
80pub type FieldResult<T> = Result<T, FieldError>;
81
82impl FieldError {
83	/// Creates a required field error
84	///
85	/// # Examples
86	///
87	/// ```
88	/// use reinhardt_forms::FieldError;
89	///
90	/// let error = FieldError::required(None);
91	/// assert_eq!(error.to_string(), "This field is required.");
92	///
93	/// let custom_error = FieldError::required(Some("Name is mandatory"));
94	/// assert_eq!(custom_error.to_string(), "Name is mandatory");
95	/// ```
96	pub fn required(custom_msg: Option<&str>) -> Self {
97		FieldError::Required(custom_msg.unwrap_or("This field is required.").to_string())
98	}
99	/// Creates an invalid field error
100	///
101	/// # Examples
102	///
103	/// ```
104	/// use reinhardt_forms::FieldError;
105	///
106	/// let error = FieldError::invalid(None, "Invalid input format");
107	/// assert_eq!(error.to_string(), "Invalid input format");
108	///
109	/// let custom_error = FieldError::invalid(Some("Must be a number"), "Invalid");
110	/// assert_eq!(custom_error.to_string(), "Must be a number");
111	/// ```
112	pub fn invalid(custom_msg: Option<&str>, default_msg: &str) -> Self {
113		FieldError::Invalid(custom_msg.unwrap_or(default_msg).to_string())
114	}
115	/// Creates a validation field error
116	///
117	/// # Examples
118	///
119	/// ```
120	/// use reinhardt_forms::FieldError;
121	///
122	/// let error = FieldError::validation(None, "Value out of range");
123	/// assert_eq!(error.to_string(), "Value out of range");
124	///
125	/// let custom_error = FieldError::validation(Some("Too long"), "Length exceeded");
126	/// assert_eq!(custom_error.to_string(), "Too long");
127	/// ```
128	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/// Field widget type that determines HTML rendering.
134#[derive(Debug, Clone, Serialize, Deserialize)]
135pub enum Widget {
136	/// Single-line text input (`<input type="text">`).
137	TextInput,
138	/// Password input that never renders its value (`<input type="password">`).
139	PasswordInput,
140	/// Email address input (`<input type="email">`).
141	EmailInput,
142	/// Numeric input (`<input type="number">`).
143	NumberInput,
144	/// Multi-line text area (`<textarea>`).
145	TextArea,
146	/// Dropdown select with predefined (value, label) choices (`<select>`).
147	Select {
148		/// Available (value, label) pairs for the dropdown.
149		choices: Vec<(String, String)>,
150	},
151	/// Checkbox input (`<input type="checkbox">`).
152	CheckboxInput,
153	/// Radio button group with predefined (value, label) choices.
154	RadioSelect {
155		/// Available (value, label) pairs for the radio group.
156		choices: Vec<(String, String)>,
157	},
158	/// Date picker input (`<input type="date">`).
159	DateInput,
160	/// Date and time picker input (`<input type="datetime-local">`).
161	DateTimeInput,
162	/// File upload input (`<input type="file">`).
163	FileInput,
164	/// Hidden input for passing data without display (`<input type="hidden">`).
165	HiddenInput,
166}
167
168impl Widget {
169	/// Renders the widget as HTML with XSS protection.
170	///
171	/// All user-controlled values (name, value, attributes, choices) are
172	/// HTML-escaped to prevent Cross-Site Scripting (XSS) attacks.
173	///
174	/// # Examples
175	///
176	/// ```
177	/// use reinhardt_forms::Widget;
178	///
179	/// let widget = Widget::TextInput;
180	/// let html = widget.render_html("username", Some("john_doe"), None);
181	/// assert!(html.contains("<input"));
182	/// assert!(html.contains("type=\"text\""));
183	/// assert!(html.contains("name=\"username\""));
184	/// assert!(html.contains("value=\"john_doe\""));
185	/// ```
186	///
187	/// # XSS Protection
188	///
189	/// ```
190	/// use reinhardt_forms::Widget;
191	///
192	/// let widget = Widget::TextInput;
193	/// // Malicious input is escaped
194	/// let html = widget.render_html("field", Some("<script>alert('xss')</script>"), None);
195	/// assert!(!html.contains("<script>"));
196	/// assert!(html.contains("&lt;script&gt;"));
197	/// ```
198	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		// Escape name for use in attributes
209		let escaped_name = escape_attribute(name);
210
211		// Build common attributes with escaping
212		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				// Security: Password fields NEVER render the value attribute
236				// to prevent password leakage in HTML source
237				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				// TextArea content is in HTML body context - must escape
280				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
392/// Base field trait for forms
393///
394/// This trait is specifically for form fields. For ORM fields, use `reinhardt_db::orm::Field`.
395pub trait FormField: Send + Sync {
396	/// Returns the field's name used as the form data key.
397	fn name(&self) -> &str;
398	/// Returns the human-readable label, if set.
399	fn label(&self) -> Option<&str>;
400	/// Returns whether this field must have a non-empty value.
401	fn required(&self) -> bool;
402	/// Returns optional help text displayed alongside the field.
403	fn help_text(&self) -> Option<&str>;
404	/// Returns the widget type used for HTML rendering.
405	fn widget(&self) -> &Widget;
406	/// Returns the initial (default) value for this field, if any.
407	fn initial(&self) -> Option<&serde_json::Value>;
408
409	/// Validates and cleans the submitted value, returning the cleaned result.
410	fn clean(&self, value: Option<&serde_json::Value>) -> FieldResult<serde_json::Value>;
411
412	/// Check if the field value has changed from its initial value
413	fn has_changed(
414		&self,
415		initial: Option<&serde_json::Value>,
416		data: Option<&serde_json::Value>,
417	) -> bool {
418		// Default implementation: compare values directly
419		match (initial, data) {
420			(None, None) => false,
421			(Some(_), None) | (None, Some(_)) => true,
422			(Some(i), Some(d)) => i != d,
423		}
424	}
425
426	/// Get custom error messages for this field
427	fn error_messages(&self) -> HashMap<ErrorType, String> {
428		HashMap::new()
429	}
430}
431
432#[cfg(test)]
433mod tests {
434	use super::*;
435
436	// Note: Field-specific tests have been moved to their respective field modules
437	// in the fields/ directory. Only FormField trait tests remain here.
438
439	#[test]
440	fn test_field_has_changed() {
441		use crate::fields::CharField;
442
443		let field = CharField::new("name".to_string());
444
445		// No change: both None
446		assert!(!field.has_changed(None, None));
447
448		// Change: initial None, data Some
449		assert!(field.has_changed(None, Some(&serde_json::json!("John"))));
450
451		// Change: initial Some, data None
452		assert!(field.has_changed(Some(&serde_json::json!("John")), None));
453
454		// No change: same value
455		assert!(!field.has_changed(
456			Some(&serde_json::json!("John")),
457			Some(&serde_json::json!("John"))
458		));
459
460		// Change: different value
461		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		// Default implementation returns empty HashMap
474		assert!(field.error_messages().is_empty());
475	}
476
477	// ============================================================================
478	// XSS Prevention Tests (Issue #547)
479	// ============================================================================
480
481	#[test]
482	fn test_html_escape_basic() {
483		assert_eq!(html_escape("<script>"), "&lt;script&gt;");
484		assert_eq!(html_escape("a & b"), "a &amp; b");
485		assert_eq!(html_escape("\"quoted\""), "&quot;quoted&quot;");
486		assert_eq!(html_escape("'single'"), "&#x27;single&#x27;");
487	}
488
489	#[test]
490	fn test_html_escape_all_special_chars() {
491		let input = "<script>alert('xss')&\"</script>";
492		let expected = "&lt;script&gt;alert(&#x27;xss&#x27;)&amp;&quot;&lt;/script&gt;";
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&quot;click");
505		assert_eq!(
506			escape_attribute("javascript:alert('xss')"),
507			"javascript:alert(&#x27;xss&#x27;)"
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		// Should NOT contain raw script tag
518		assert!(!html.contains("<script>"));
519		// Should contain escaped version
520		assert!(html.contains("&lt;script&gt;"));
521		assert!(html.contains("&quot;"));
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		// Should NOT contain raw script tag
531		assert!(!html.contains("<script>"));
532		// Should contain escaped version
533		assert!(html.contains("&lt;script&gt;"));
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		// Should NOT contain raw script tag
543		assert!(!html.contains("<script>"));
544		// Should contain escaped version
545		assert!(html.contains("&lt;script&gt;"));
546		// Raw </textarea> should be escaped
547		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		// Should NOT contain raw script tags
568		assert!(!html.contains("<script>"));
569		// Should contain escaped versions
570		assert!(html.contains("&lt;script&gt;"));
571		// The malicious </option> in the label should be escaped
572		assert!(html.contains("&lt;/option&gt;"));
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		// Should NOT contain raw script tags
587		assert!(!html.contains("<script>"));
588		// Should contain escaped versions
589		assert!(html.contains("&lt;script&gt;"));
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		// Should NOT contain raw script tags or unescaped quotes that could break out
605		assert!(!html.contains("<script>"));
606		assert!(html.contains("&lt;script&gt;"));
607		assert!(html.contains("&quot;"));
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		// Test widget types that render a value attribute
615		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("&lt;script&gt;"),
634				"Widget {:?} did not encode < and > characters",
635				widget
636			);
637		}
638
639		// PasswordInput intentionally does not render the value attribute
640		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		// Normal values should work correctly
653		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		// Critical test: & must be escaped FIRST to prevent double-encoding
660		// e.g., if we escape < first, & becomes &amp;, then if we escape & again,
661		// it becomes &amp;amp;
662		let input = "&lt;"; // This is already an entity
663		let result = html_escape(input);
664		// Should become &amp;lt; (the & is escaped, not the <)
665		assert_eq!(result, "&amp;lt;");
666	}
667}