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 whether successful cleaning produces a sensitive value.
407	///
408	/// Sensitive fields can override this when their presentation widget is
409	/// customized, so value redaction does not depend on the widget type.
410	fn is_sensitive(&self) -> bool {
411		false
412	}
413	/// Returns the initial (default) value for this field, if any.
414	fn initial(&self) -> Option<&serde_json::Value>;
415
416	/// Validates and cleans the submitted value, returning the cleaned result.
417	fn clean(&self, value: Option<&serde_json::Value>) -> FieldResult<serde_json::Value>;
418
419	/// Check if the field value has changed from its initial value
420	fn has_changed(
421		&self,
422		initial: Option<&serde_json::Value>,
423		data: Option<&serde_json::Value>,
424	) -> bool {
425		// Default implementation: compare values directly
426		match (initial, data) {
427			(None, None) => false,
428			(Some(_), None) | (None, Some(_)) => true,
429			(Some(i), Some(d)) => i != d,
430		}
431	}
432
433	/// Get custom error messages for this field
434	fn error_messages(&self) -> HashMap<ErrorType, String> {
435		HashMap::new()
436	}
437}
438
439#[cfg(test)]
440mod tests {
441	use super::*;
442
443	// Note: Field-specific tests have been moved to their respective field modules
444	// in the fields/ directory. Only FormField trait tests remain here.
445
446	#[test]
447	fn test_field_has_changed() {
448		use crate::fields::CharField;
449
450		let field = CharField::new("name".to_string());
451
452		// No change: both None
453		assert!(!field.has_changed(None, None));
454
455		// Change: initial None, data Some
456		assert!(field.has_changed(None, Some(&serde_json::json!("John"))));
457
458		// Change: initial Some, data None
459		assert!(field.has_changed(Some(&serde_json::json!("John")), None));
460
461		// No change: same value
462		assert!(!field.has_changed(
463			Some(&serde_json::json!("John")),
464			Some(&serde_json::json!("John"))
465		));
466
467		// Change: different value
468		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		// Default implementation returns empty HashMap
481		assert!(field.error_messages().is_empty());
482	}
483
484	// ============================================================================
485	// XSS Prevention Tests (Issue #547)
486	// ============================================================================
487
488	#[test]
489	fn test_html_escape_basic() {
490		assert_eq!(html_escape("<script>"), "&lt;script&gt;");
491		assert_eq!(html_escape("a & b"), "a &amp; b");
492		assert_eq!(html_escape("\"quoted\""), "&quot;quoted&quot;");
493		assert_eq!(html_escape("'single'"), "&#x27;single&#x27;");
494	}
495
496	#[test]
497	fn test_html_escape_all_special_chars() {
498		let input = "<script>alert('xss')&\"</script>";
499		let expected = "&lt;script&gt;alert(&#x27;xss&#x27;)&amp;&quot;&lt;/script&gt;";
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&quot;click");
512		assert_eq!(
513			escape_attribute("javascript:alert('xss')"),
514			"javascript:alert(&#x27;xss&#x27;)"
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		// Should NOT contain raw script tag
525		assert!(!html.contains("<script>"));
526		// Should contain escaped version
527		assert!(html.contains("&lt;script&gt;"));
528		assert!(html.contains("&quot;"));
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		// Should NOT contain raw script tag
538		assert!(!html.contains("<script>"));
539		// Should contain escaped version
540		assert!(html.contains("&lt;script&gt;"));
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		// Should NOT contain raw script tag
550		assert!(!html.contains("<script>"));
551		// Should contain escaped version
552		assert!(html.contains("&lt;script&gt;"));
553		// Raw </textarea> should be escaped
554		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		// Should NOT contain raw script tags
575		assert!(!html.contains("<script>"));
576		// Should contain escaped versions
577		assert!(html.contains("&lt;script&gt;"));
578		// The malicious </option> in the label should be escaped
579		assert!(html.contains("&lt;/option&gt;"));
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		// Should NOT contain raw script tags
594		assert!(!html.contains("<script>"));
595		// Should contain escaped versions
596		assert!(html.contains("&lt;script&gt;"));
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		// Should NOT contain raw script tags or unescaped quotes that could break out
612		assert!(!html.contains("<script>"));
613		assert!(html.contains("&lt;script&gt;"));
614		assert!(html.contains("&quot;"));
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		// Test widget types that render a value attribute
622		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("&lt;script&gt;"),
641				"Widget {:?} did not encode < and > characters",
642				widget
643			);
644		}
645
646		// PasswordInput intentionally does not render the value attribute
647		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		// Normal values should work correctly
660		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		// Critical test: & must be escaped FIRST to prevent double-encoding
667		// e.g., if we escape < first, & becomes &amp;, then if we escape & again,
668		// it becomes &amp;amp;
669		let input = "&lt;"; // This is already an entity
670		let result = html_escape(input);
671		// Should become &amp;lt; (the & is escaped, not the <)
672		assert_eq!(result, "&amp;lt;");
673	}
674}