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#[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	/// Creates a required field error
69	///
70	/// # Examples
71	///
72	/// ```
73	/// use reinhardt_forms::FieldError;
74	///
75	/// let error = FieldError::required(None);
76	/// assert_eq!(error.to_string(), "This field is required.");
77	///
78	/// let custom_error = FieldError::required(Some("Name is mandatory"));
79	/// assert_eq!(custom_error.to_string(), "Name is mandatory");
80	/// ```
81	pub fn required(custom_msg: Option<&str>) -> Self {
82		FieldError::Required(custom_msg.unwrap_or("This field is required.").to_string())
83	}
84	/// Creates an invalid field error
85	///
86	/// # Examples
87	///
88	/// ```
89	/// use reinhardt_forms::FieldError;
90	///
91	/// let error = FieldError::invalid(None, "Invalid input format");
92	/// assert_eq!(error.to_string(), "Invalid input format");
93	///
94	/// let custom_error = FieldError::invalid(Some("Must be a number"), "Invalid");
95	/// assert_eq!(custom_error.to_string(), "Must be a number");
96	/// ```
97	pub fn invalid(custom_msg: Option<&str>, default_msg: &str) -> Self {
98		FieldError::Invalid(custom_msg.unwrap_or(default_msg).to_string())
99	}
100	/// Creates a validation field error
101	///
102	/// # Examples
103	///
104	/// ```
105	/// use reinhardt_forms::FieldError;
106	///
107	/// let error = FieldError::validation(None, "Value out of range");
108	/// assert_eq!(error.to_string(), "Value out of range");
109	///
110	/// let custom_error = FieldError::validation(Some("Too long"), "Length exceeded");
111	/// assert_eq!(custom_error.to_string(), "Too long");
112	/// ```
113	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/// Field widget type
119#[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	/// Renders the widget as HTML with XSS protection.
137	///
138	/// All user-controlled values (name, value, attributes, choices) are
139	/// HTML-escaped to prevent Cross-Site Scripting (XSS) attacks.
140	///
141	/// # Examples
142	///
143	/// ```
144	/// use reinhardt_forms::Widget;
145	///
146	/// let widget = Widget::TextInput;
147	/// let html = widget.render_html("username", Some("john_doe"), None);
148	/// assert!(html.contains("<input"));
149	/// assert!(html.contains("type=\"text\""));
150	/// assert!(html.contains("name=\"username\""));
151	/// assert!(html.contains("value=\"john_doe\""));
152	/// ```
153	///
154	/// # XSS Protection
155	///
156	/// ```
157	/// use reinhardt_forms::Widget;
158	///
159	/// let widget = Widget::TextInput;
160	/// // Malicious input is escaped
161	/// let html = widget.render_html("field", Some("<script>alert('xss')</script>"), None);
162	/// assert!(!html.contains("<script>"));
163	/// assert!(html.contains("&lt;script&gt;"));
164	/// ```
165	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		// Escape name for use in attributes
176		let escaped_name = escape_attribute(name);
177
178		// Build common attributes with escaping
179		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				// Security: Password fields NEVER render the value attribute
203				// to prevent password leakage in HTML source
204				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				// TextArea content is in HTML body context - must escape
247				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
359/// Base field trait for forms
360///
361/// This trait is specifically for form fields. For ORM fields, use `reinhardt_db::orm::Field`.
362pub 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	/// Check if the field value has changed from its initial value
373	fn has_changed(
374		&self,
375		initial: Option<&serde_json::Value>,
376		data: Option<&serde_json::Value>,
377	) -> bool {
378		// Default implementation: compare values directly
379		match (initial, data) {
380			(None, None) => false,
381			(Some(_), None) | (None, Some(_)) => true,
382			(Some(i), Some(d)) => i != d,
383		}
384	}
385
386	/// Get custom error messages for this field
387	fn error_messages(&self) -> HashMap<ErrorType, String> {
388		HashMap::new()
389	}
390}
391
392#[cfg(test)]
393mod tests {
394	use super::*;
395
396	// Note: Field-specific tests have been moved to their respective field modules
397	// in the fields/ directory. Only FormField trait tests remain here.
398
399	#[test]
400	fn test_field_has_changed() {
401		use crate::fields::CharField;
402
403		let field = CharField::new("name".to_string());
404
405		// No change: both None
406		assert!(!field.has_changed(None, None));
407
408		// Change: initial None, data Some
409		assert!(field.has_changed(None, Some(&serde_json::json!("John"))));
410
411		// Change: initial Some, data None
412		assert!(field.has_changed(Some(&serde_json::json!("John")), None));
413
414		// No change: same value
415		assert!(!field.has_changed(
416			Some(&serde_json::json!("John")),
417			Some(&serde_json::json!("John"))
418		));
419
420		// Change: different value
421		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		// Default implementation returns empty HashMap
434		assert!(field.error_messages().is_empty());
435	}
436
437	// ============================================================================
438	// XSS Prevention Tests (Issue #547)
439	// ============================================================================
440
441	#[test]
442	fn test_html_escape_basic() {
443		assert_eq!(html_escape("<script>"), "&lt;script&gt;");
444		assert_eq!(html_escape("a & b"), "a &amp; b");
445		assert_eq!(html_escape("\"quoted\""), "&quot;quoted&quot;");
446		assert_eq!(html_escape("'single'"), "&#x27;single&#x27;");
447	}
448
449	#[test]
450	fn test_html_escape_all_special_chars() {
451		let input = "<script>alert('xss')&\"</script>";
452		let expected = "&lt;script&gt;alert(&#x27;xss&#x27;)&amp;&quot;&lt;/script&gt;";
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&quot;click");
465		assert_eq!(
466			escape_attribute("javascript:alert('xss')"),
467			"javascript:alert(&#x27;xss&#x27;)"
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		// Should NOT contain raw script tag
478		assert!(!html.contains("<script>"));
479		// Should contain escaped version
480		assert!(html.contains("&lt;script&gt;"));
481		assert!(html.contains("&quot;"));
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		// Should NOT contain raw script tag
491		assert!(!html.contains("<script>"));
492		// Should contain escaped version
493		assert!(html.contains("&lt;script&gt;"));
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		// Should NOT contain raw script tag
503		assert!(!html.contains("<script>"));
504		// Should contain escaped version
505		assert!(html.contains("&lt;script&gt;"));
506		// Raw </textarea> should be escaped
507		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		// Should NOT contain raw script tags
528		assert!(!html.contains("<script>"));
529		// Should contain escaped versions
530		assert!(html.contains("&lt;script&gt;"));
531		// The malicious </option> in the label should be escaped
532		assert!(html.contains("&lt;/option&gt;"));
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		// Should NOT contain raw script tags
547		assert!(!html.contains("<script>"));
548		// Should contain escaped versions
549		assert!(html.contains("&lt;script&gt;"));
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		// Should NOT contain raw script tags or unescaped quotes that could break out
565		assert!(!html.contains("<script>"));
566		assert!(html.contains("&lt;script&gt;"));
567		assert!(html.contains("&quot;"));
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		// Test widget types that render a value attribute
575		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("&lt;script&gt;"),
594				"Widget {:?} did not encode < and > characters",
595				widget
596			);
597		}
598
599		// PasswordInput intentionally does not render the value attribute
600		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		// Normal values should work correctly
613		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		// Critical test: & must be escaped FIRST to prevent double-encoding
620		// e.g., if we escape < first, & becomes &amp;, then if we escape & again,
621		// it becomes &amp;amp;
622		let input = "&lt;"; // This is already an entity
623		let result = html_escape(input);
624		// Should become &amp;lt; (the & is escaped, not the <)
625		assert_eq!(result, "&amp;lt;");
626	}
627}