Skip to main content

reinhardt_forms/fields/
regex_field.rs

1use crate::field::{FieldError, FieldResult, FormField, Widget};
2use regex::Regex;
3use std::net::Ipv6Addr;
4use std::sync::OnceLock;
5
6/// RegexField for pattern-based validation
7///
8/// Compiled regex is cached using `OnceLock` to avoid repeated
9/// compilation which could lead to ReDoS via allocation overhead.
10pub struct RegexField {
11	pub name: String,
12	pub label: Option<String>,
13	pub required: bool,
14	pub help_text: Option<String>,
15	pub widget: Widget,
16	pub initial: Option<serde_json::Value>,
17	/// Cached compiled regex to prevent repeated compilation (ReDoS mitigation)
18	regex_cache: OnceLock<Regex>,
19	/// Raw pattern string stored for lazy compilation
20	pattern: String,
21	pub error_message: String,
22	pub max_length: Option<usize>,
23	pub min_length: Option<usize>,
24}
25
26impl RegexField {
27	/// Create a new RegexField
28	///
29	/// The regex is compiled lazily on first use and cached for subsequent calls.
30	///
31	/// # Examples
32	///
33	/// ```
34	/// use reinhardt_forms::fields::RegexField;
35	///
36	/// let field = RegexField::new("pattern".to_string(), r"^\d+$").unwrap();
37	/// assert_eq!(field.name, "pattern");
38	/// ```
39	pub fn new(name: String, pattern: &str) -> Result<Self, regex::Error> {
40		// Validate the pattern eagerly so callers get errors at construction time
41		let compiled = Regex::new(pattern)?;
42		let cache = OnceLock::new();
43		cache
44			.set(compiled)
45			.unwrap_or_else(|_| panic!("OnceLock should be empty at construction"));
46		Ok(Self {
47			name,
48			label: None,
49			required: true,
50			help_text: None,
51			widget: Widget::TextInput,
52			initial: None,
53			regex_cache: cache,
54			pattern: pattern.to_string(),
55			error_message: "Enter a valid value".to_string(),
56			max_length: None,
57			min_length: None,
58		})
59	}
60
61	/// Get the cached compiled regex
62	fn regex(&self) -> &Regex {
63		self.regex_cache.get_or_init(|| {
64			Regex::new(&self.pattern).expect("Pattern was validated at construction")
65		})
66	}
67	pub fn with_error_message(mut self, message: String) -> Self {
68		self.error_message = message;
69		self
70	}
71}
72
73impl FormField for RegexField {
74	fn name(&self) -> &str {
75		&self.name
76	}
77
78	fn label(&self) -> Option<&str> {
79		self.label.as_deref()
80	}
81
82	fn required(&self) -> bool {
83		self.required
84	}
85
86	fn help_text(&self) -> Option<&str> {
87		self.help_text.as_deref()
88	}
89
90	fn widget(&self) -> &Widget {
91		&self.widget
92	}
93
94	fn initial(&self) -> Option<&serde_json::Value> {
95		self.initial.as_ref()
96	}
97
98	fn clean(&self, value: Option<&serde_json::Value>) -> FieldResult<serde_json::Value> {
99		match value {
100			None if self.required => Err(FieldError::required(None)),
101			None => Ok(serde_json::Value::Null),
102			Some(v) => {
103				let s = v
104					.as_str()
105					.ok_or_else(|| FieldError::invalid(None, "Expected string"))?;
106
107				if s.is_empty() {
108					if self.required {
109						return Err(FieldError::required(None));
110					}
111					return Ok(serde_json::Value::Null);
112				}
113
114				// Length validation using character count (not byte count)
115				// for correct multi-byte character handling
116				let char_count = s.chars().count();
117				if let Some(max) = self.max_length
118					&& char_count > max
119				{
120					return Err(FieldError::validation(
121						None,
122						&format!("Ensure this value has at most {} characters", max),
123					));
124				}
125
126				if let Some(min) = self.min_length
127					&& char_count < min
128				{
129					return Err(FieldError::validation(
130						None,
131						&format!("Ensure this value has at least {} characters", min),
132					));
133				}
134
135				// Regex validation (uses cached compiled regex)
136				if !self.regex().is_match(s) {
137					return Err(FieldError::validation(None, &self.error_message));
138				}
139
140				Ok(serde_json::Value::String(s.to_string()))
141			}
142		}
143	}
144}
145
146/// SlugField for URL-safe slugs
147pub struct SlugField {
148	pub name: String,
149	pub label: Option<String>,
150	pub required: bool,
151	pub help_text: Option<String>,
152	pub widget: Widget,
153	pub initial: Option<serde_json::Value>,
154	pub max_length: Option<usize>,
155	pub allow_unicode: bool,
156}
157
158impl SlugField {
159	pub fn new(name: String) -> Self {
160		Self {
161			name,
162			label: None,
163			required: true,
164			help_text: None,
165			widget: Widget::TextInput,
166			initial: None,
167			max_length: Some(50),
168			allow_unicode: false,
169		}
170	}
171
172	fn is_valid_slug(&self, s: &str) -> bool {
173		if self.allow_unicode {
174			s.chars().all(|c| {
175				c.is_alphanumeric() || c == '-' || c == '_' || (!c.is_ascii() && c.is_alphabetic())
176			})
177		} else {
178			s.chars()
179				.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
180		}
181	}
182}
183
184impl FormField for SlugField {
185	fn name(&self) -> &str {
186		&self.name
187	}
188
189	fn label(&self) -> Option<&str> {
190		self.label.as_deref()
191	}
192
193	fn required(&self) -> bool {
194		self.required
195	}
196
197	fn help_text(&self) -> Option<&str> {
198		self.help_text.as_deref()
199	}
200
201	fn widget(&self) -> &Widget {
202		&self.widget
203	}
204
205	fn initial(&self) -> Option<&serde_json::Value> {
206		self.initial.as_ref()
207	}
208
209	fn clean(&self, value: Option<&serde_json::Value>) -> FieldResult<serde_json::Value> {
210		match value {
211			None if self.required => Err(FieldError::required(None)),
212			None => Ok(serde_json::Value::Null),
213			Some(v) => {
214				let s = v
215					.as_str()
216					.ok_or_else(|| FieldError::invalid(None, "Expected string"))?;
217
218				if s.is_empty() {
219					if self.required {
220						return Err(FieldError::required(None));
221					}
222					return Ok(serde_json::Value::Null);
223				}
224
225				// Use character count for correct multi-byte handling
226				if let Some(max) = self.max_length
227					&& s.chars().count() > max
228				{
229					return Err(FieldError::validation(
230						None,
231						&format!("Ensure this value has at most {} characters", max),
232					));
233				}
234
235				if !self.is_valid_slug(s) {
236					let msg = if self.allow_unicode {
237						"Enter a valid slug consisting of Unicode letters, numbers, underscores, or hyphens"
238					} else {
239						"Enter a valid slug consisting of letters, numbers, underscores or hyphens"
240					};
241					return Err(FieldError::validation(None, msg));
242				}
243
244				Ok(serde_json::Value::String(s.to_string()))
245			}
246		}
247	}
248}
249
250/// GenericIPAddressField for IPv4 and IPv6 addresses
251pub struct GenericIPAddressField {
252	pub name: String,
253	pub label: Option<String>,
254	pub required: bool,
255	pub help_text: Option<String>,
256	pub widget: Widget,
257	pub initial: Option<serde_json::Value>,
258	pub protocol: IPProtocol,
259}
260
261#[derive(Debug, Clone, Copy)]
262pub enum IPProtocol {
263	Both,
264	IPv4,
265	IPv6,
266}
267
268impl GenericIPAddressField {
269	pub fn new(name: String) -> Self {
270		Self {
271			name,
272			label: None,
273			required: true,
274			help_text: None,
275			widget: Widget::TextInput,
276			initial: None,
277			protocol: IPProtocol::Both,
278		}
279	}
280
281	fn is_valid_ipv4(&self, s: &str) -> bool {
282		let parts: Vec<&str> = s.split('.').collect();
283		if parts.len() != 4 {
284			return false;
285		}
286
287		parts.iter().all(|part| {
288			part.parse::<u8>()
289				.map(|_| !part.starts_with('0') || part.len() == 1)
290				.unwrap_or(false)
291		})
292	}
293
294	fn is_valid_ipv6(&self, s: &str) -> bool {
295		// Use std::net::Ipv6Addr for comprehensive IPv6 validation,
296		// covering compressed (::1), IPv4-mapped (::ffff:192.0.2.1),
297		// and all other valid IPv6 address formats.
298		s.parse::<Ipv6Addr>().is_ok()
299	}
300}
301
302impl FormField for GenericIPAddressField {
303	fn name(&self) -> &str {
304		&self.name
305	}
306
307	fn label(&self) -> Option<&str> {
308		self.label.as_deref()
309	}
310
311	fn required(&self) -> bool {
312		self.required
313	}
314
315	fn help_text(&self) -> Option<&str> {
316		self.help_text.as_deref()
317	}
318
319	fn widget(&self) -> &Widget {
320		&self.widget
321	}
322
323	fn initial(&self) -> Option<&serde_json::Value> {
324		self.initial.as_ref()
325	}
326
327	fn clean(&self, value: Option<&serde_json::Value>) -> FieldResult<serde_json::Value> {
328		match value {
329			None if self.required => Err(FieldError::required(None)),
330			None => Ok(serde_json::Value::Null),
331			Some(v) => {
332				let s = v
333					.as_str()
334					.ok_or_else(|| FieldError::invalid(None, "Expected string"))?;
335
336				if s.is_empty() {
337					if self.required {
338						return Err(FieldError::required(None));
339					}
340					return Ok(serde_json::Value::Null);
341				}
342
343				let is_valid = match self.protocol {
344					IPProtocol::IPv4 => self.is_valid_ipv4(s),
345					IPProtocol::IPv6 => self.is_valid_ipv6(s),
346					IPProtocol::Both => self.is_valid_ipv4(s) || self.is_valid_ipv6(s),
347				};
348
349				if !is_valid {
350					return Err(FieldError::validation(None, "Enter a valid IP address"));
351				}
352
353				Ok(serde_json::Value::String(s.to_string()))
354			}
355		}
356	}
357}
358
359#[cfg(test)]
360mod tests {
361	use super::*;
362	use rstest::rstest;
363
364	#[test]
365	fn test_regex_field() {
366		let field = RegexField::new("code".to_string(), r"^[A-Z]{3}\d{3}$").unwrap();
367
368		assert!(field.clean(Some(&serde_json::json!("ABC123"))).is_ok());
369		assert!(matches!(
370			field.clean(Some(&serde_json::json!("abc123"))),
371			Err(FieldError::Validation(_))
372		));
373	}
374
375	#[test]
376	fn test_forms_regex_field_slug() {
377		let field = SlugField::new("slug".to_string());
378
379		assert!(field.clean(Some(&serde_json::json!("my-slug"))).is_ok());
380		assert!(field.clean(Some(&serde_json::json!("my_slug"))).is_ok());
381		assert!(matches!(
382			field.clean(Some(&serde_json::json!("my slug"))),
383			Err(FieldError::Validation(_))
384		));
385	}
386
387	#[test]
388	fn test_ip_field_ipv4() {
389		let mut field = GenericIPAddressField::new("ip".to_string());
390		field.protocol = IPProtocol::IPv4;
391
392		assert!(field.clean(Some(&serde_json::json!("192.168.1.1"))).is_ok());
393		assert!(matches!(
394			field.clean(Some(&serde_json::json!("999.999.999.999"))),
395			Err(FieldError::Validation(_))
396		));
397	}
398
399	#[test]
400	fn test_ip_field_ipv6() {
401		let mut field = GenericIPAddressField::new("ip".to_string());
402		field.protocol = IPProtocol::IPv6;
403
404		assert!(
405			field
406				.clean(Some(&serde_json::json!(
407					"2001:0db8:85a3:0000:0000:8a2e:0370:7334"
408				)))
409				.is_ok()
410		);
411		assert!(field.clean(Some(&serde_json::json!("::1"))).is_ok());
412	}
413
414	#[rstest]
415	#[case("::1", true)]
416	#[case("::", true)]
417	#[case("::ffff:192.0.2.1", true)]
418	#[case("2001:db8::1", true)]
419	#[case("fe80::1%eth0", false)]
420	#[case("2001:db8:85a3::8a2e:370:7334", true)]
421	#[case("::ffff:10.0.0.1", true)]
422	#[case("2001:db8::", true)]
423	#[case("::192.168.1.1", true)]
424	#[case("not-an-ip", false)]
425	#[case("2001:db8::g1", false)]
426	#[case("12345::1", false)]
427	fn test_ipv6_comprehensive_validation(#[case] input: &str, #[case] should_accept: bool) {
428		// Arrange
429		let mut field = GenericIPAddressField::new("ip".to_string());
430		field.protocol = IPProtocol::IPv6;
431
432		// Act
433		let result = field.clean(Some(&serde_json::json!(input)));
434
435		// Assert
436		if should_accept {
437			assert!(
438				result.is_ok(),
439				"Expected valid IPv6 '{}' to be accepted, got: {:?}",
440				input,
441				result,
442			);
443		} else {
444			assert!(
445				result.is_err(),
446				"Expected invalid IPv6 '{}' to be rejected, got: {:?}",
447				input,
448				result,
449			);
450		}
451	}
452}