1pub(crate) mod extract;
31mod key_values;
32mod messages;
33pub(crate) mod nested;
34mod value;
35
36use std::collections::BTreeMap;
37
38use axum::http::StatusCode;
39use axum::response::{IntoResponse, Response};
40use serde::{Deserialize, Serialize};
41use serde_json::{Map, Value, json};
42
43pub use extract::Valid;
44pub use key_values::KeyValues;
45pub(crate) use messages::{render, template_for};
46pub use value::{FieldValue, Inspected};
47
48use crate::Result;
49use crate::db::{Db, DbValue, ToDbValue, quote};
50use chrono::NaiveDateTime;
51
52#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
54#[serde(transparent)]
55pub struct Errors(BTreeMap<String, Vec<String>>);
56
57impl Errors {
58 pub fn new() -> Self {
60 Self::default()
61 }
62
63 pub fn add(&mut self, field: impl Into<String>, message: impl Into<String>) {
65 self.0.entry(field.into()).or_default().push(message.into());
66 }
67
68 pub fn is_empty(&self) -> bool {
70 self.0.is_empty()
71 }
72
73 pub fn has(&self, field: &str) -> bool {
75 self.0.contains_key(field)
76 }
77
78 pub fn first(&self, field: &str) -> Option<&str> {
80 self.0
81 .get(field)
82 .and_then(|m| m.first())
83 .map(String::as_str)
84 }
85
86 pub fn iter(&self) -> impl Iterator<Item = (&str, &[String])> {
88 self.0.iter().map(|(f, m)| (f.as_str(), m.as_slice()))
89 }
90}
91
92pub trait Validate {
134 const ERROR_BAG: Option<&'static str> = None;
140
141 fn rules(&self, v: &mut Validator);
143
144 fn prepare(&mut self) {}
148
149 fn authorize(
153 &self,
154 form: &FormContext<'_>,
155 ) -> impl std::future::Future<Output = Result<bool>> + Send {
156 let _ = form;
157 std::future::ready(Ok(true))
158 }
159
160 fn after(
164 &self,
165 form: &FormContext<'_>,
166 errors: &mut Errors,
167 ) -> impl std::future::Future<Output = Result> + Send {
168 let _ = (form, errors);
169 std::future::ready(Ok(()))
170 }
171}
172
173pub trait ValidateHooks {
200 fn prepare(&mut self) {}
202
203 fn authorize(
206 &self,
207 form: &FormContext<'_>,
208 ) -> impl std::future::Future<Output = Result<bool>> + Send {
209 let _ = form;
210 std::future::ready(Ok(true))
211 }
212
213 fn after(
215 &self,
216 form: &FormContext<'_>,
217 errors: &mut Errors,
218 ) -> impl std::future::Future<Output = Result> + Send {
219 let _ = (form, errors);
220 std::future::ready(Ok(()))
221 }
222}
223
224#[non_exhaustive]
226pub struct FormContext<'a> {
227 pub state: &'a crate::AppState,
229 pub user: Option<&'a crate::auth::User>,
231 pub method: &'a axum::http::Method,
233 pub path: &'a str,
235}
236
237struct Pending {
238 field: String,
239 label: String,
240 table: String,
241 column: String,
242 value: DbValue,
243 ignore_id: Option<crate::db::DbValue>,
244 scope: Vec<ScopeCondition>,
246 unique: bool,
247 message: Option<String>,
248}
249
250struct AsyncCheck {
252 field: String,
253 label: String,
254 kind: AsyncKind,
255 message: Option<String>,
256}
257
258enum AsyncKind {
259 CurrentPassword(String),
261 Uncompromised(String),
263}
264
265enum ScopeCondition {
267 Eq(String, DbValue),
268 Null(String),
269 NotNull(String),
270}
271
272impl ScopeCondition {
273 fn column(&self) -> &str {
274 match self {
275 Self::Eq(column, _) | Self::Null(column) | Self::NotNull(column) => column,
276 }
277 }
278}
279
280async fn ensure_sqlite_column(db: &Db, table: &str, column: &str) -> Result {
283 let found: i64 = crate::db::sql("SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?")
284 .bind(table)
285 .bind(column)
286 .scalar(db)
287 .await?;
288 if found == 0 {
289 return Err(anyhow::anyhow!(
290 "unique/exists rule: table `{table}` has no column `{column}`"
291 )
292 .into());
293 }
294 Ok(())
295}
296
297pub struct Validator {
300 texts: Option<crate::i18n::Texts>,
302 errors: Errors,
303 pending: Vec<Pending>,
304 checks: Vec<AsyncCheck>,
305}
306
307impl Default for Validator {
308 fn default() -> Self {
309 Self::new()
310 }
311}
312
313impl Validator {
314 pub fn new() -> Self {
316 Self {
317 texts: None,
318 errors: Errors::new(),
319 pending: Vec::new(),
320 checks: Vec::new(),
321 }
322 }
323
324 pub fn in_lang(self, lang: &crate::Lang) -> Self {
328 self.with_texts(lang.texts())
329 }
330
331 pub(crate) fn with_texts(mut self, texts: crate::i18n::Texts) -> Self {
334 self.texts = Some(texts);
335 self
336 }
337
338 fn template(&self, key: &str) -> std::borrow::Cow<'static, str> {
339 messages::template_for(self.texts.as_ref(), key)
340 }
341
342 fn translated_label(&self, name: &str) -> Option<String> {
345 self.texts.as_ref().and_then(|t| {
346 t.get(&format!("renox.validation.attributes.{name}"))
347 .cloned()
348 })
349 }
350
351 fn label_for(&self, name: &str) -> String {
352 nested::label(name, |key| self.translated_label(key))
353 }
354
355 pub fn each<T: FieldValue>(
371 &mut self,
372 name: &str,
373 items: &[T],
374 rules: impl for<'a> Fn(Field<'a>) -> Field<'a>,
375 ) {
376 let base = self.label_for(name);
377 for (i, item) in items.iter().enumerate() {
378 let key = format!("{name}.{i}");
379 let label = format!("{base} #{}", i + 1);
380 rules(self.field(&key, item).label(&label));
381 }
382 }
383
384 pub fn nested<T: Validate>(&mut self, name: &str, items: &[T]) {
387 for (i, item) in items.iter().enumerate() {
388 let mut inner = Validator {
389 texts: self.texts.clone(),
390 errors: Errors::new(),
391 pending: Vec::new(),
392 checks: Vec::new(),
393 };
394 item.rules(&mut inner);
395 for (field, messages) in inner.errors.iter() {
396 for message in messages {
397 self.errors
398 .add(format!("{name}.{i}.{field}"), message.clone());
399 }
400 }
401 for mut pending in inner.pending {
402 pending.field = format!("{name}.{i}.{}", pending.field);
403 self.pending.push(pending);
404 }
405 for mut check in inner.checks {
406 check.field = format!("{name}.{i}.{}", check.field);
407 self.checks.push(check);
408 }
409 }
410 }
411
412 pub fn distinct<T: FieldValue>(&mut self, name: &str, items: &[T]) {
415 let base = self.label_for(name);
416 let template = self.template("distinct");
417 let mut seen: Vec<Inspected> = Vec::new();
418 for (i, item) in items.iter().enumerate() {
419 let value = match item.inspect() {
420 Inspected::Text(text) => Inspected::Text(text.trim().to_lowercase()),
421 other => other,
422 };
423 if value == Inspected::Missing {
424 continue;
425 }
426 if seen.contains(&value) {
427 let label = format!("{base} #{}", i + 1);
428 self.errors
429 .add(format!("{name}.{i}"), render(&template, &label, &[]));
430 } else {
431 seen.push(value);
432 }
433 }
434 }
435
436 pub fn field<'v>(&'v mut self, name: &str, value: &impl FieldValue) -> Field<'v> {
439 let translated = self.translated_label(name).is_some();
440 let mut field = Field {
441 translated,
442 label: self.label_for(name),
444 name: name.to_owned(),
445 value: value.inspect(),
446 db_value: value.db_value(),
447 failed: false,
448 last_pending: None,
449 last_check: None,
450 v: self,
451 };
452 if let Inspected::Number(n) = field.value
454 && !n.is_finite()
455 {
456 field.fail("numeric", &[]);
457 }
458 field
459 }
460
461 pub fn error(&mut self, field: &str, message: impl Into<String>) {
463 self.errors.add(field, message);
464 }
465
466 pub async fn finish(self, db: &Db) -> Result<Errors> {
471 self.finish_with(db, None, None).await
472 }
473
474 pub async fn finish_for(
478 self,
479 state: &crate::AppState,
480 user: Option<&crate::auth::User>,
481 ) -> Result<Errors> {
482 self.finish_with(&state.db, Some(state), user).await
483 }
484
485 async fn finish_with(
486 self,
487 db: &Db,
488 state: Option<&crate::AppState>,
489 user: Option<&crate::auth::User>,
490 ) -> Result<Errors> {
491 let mut errors = self.errors;
492 for check in self.pending {
493 if errors.has(&check.field) {
494 continue;
495 }
496 let dialect = db.dialect();
497 if dialect == crate::db::Dialect::Sqlite {
498 ensure_sqlite_column(db, &check.table, &check.column).await?;
499 for condition in &check.scope {
500 ensure_sqlite_column(db, &check.table, condition.column()).await?;
501 }
502 }
503 let column = match (&check.value, dialect) {
506 (DbValue::Text(_), crate::db::Dialect::Postgres) => {
507 format!("CAST({} AS TEXT)", quote(&check.column))
508 }
509 _ => quote(&check.column),
510 };
511 let mut sql = format!(
512 "SELECT EXISTS(SELECT 1 FROM {} WHERE {column} = ?",
513 quote(&check.table),
514 );
515 if check.ignore_id.is_some() {
516 sql.push_str(" AND \"id\" != ?");
517 }
518 let mut scope_values = Vec::new();
519 for condition in check.scope {
520 match condition {
521 ScopeCondition::Eq(column, value) => {
522 sql.push_str(&format!(" AND {} = ?", quote(&column)));
523 scope_values.push(value);
524 }
525 ScopeCondition::Null(column) => {
526 sql.push_str(&format!(" AND {} IS NULL", quote(&column)));
527 }
528 ScopeCondition::NotNull(column) => {
529 sql.push_str(&format!(" AND {} IS NOT NULL", quote(&column)));
530 }
531 }
532 }
533 sql.push(')');
534 let mut query = crate::db::sql(sql).bind(check.value);
535 if let Some(id) = check.ignore_id {
536 query = query.bind(id);
537 }
538 let query = query.bind_all(scope_values);
539 let found: bool = query.scalar(db).await?;
540 if found == check.unique {
541 let key = if check.unique { "unique" } else { "exists" };
542 let message = check.message.unwrap_or_else(|| {
543 render(
544 &messages::template_for(self.texts.as_ref(), key),
545 &check.label,
546 &[],
547 )
548 });
549 errors.add(check.field, message);
550 }
551 }
552 for check in self.checks {
553 if errors.has(&check.field) {
554 continue;
555 }
556 let key = match &check.kind {
557 AsyncKind::CurrentPassword(password) => match user {
558 Some(user) if user.check_password(password).await => continue,
559 _ => "current_password",
560 },
561 AsyncKind::Uncompromised(password) => match state {
562 Some(state) if breached(state, password).await => "password.uncompromised",
563 _ => continue,
564 },
565 };
566 let message = check.message.unwrap_or_else(|| {
567 render(
568 &messages::template_for(self.texts.as_ref(), key),
569 &check.label,
570 &[],
571 )
572 });
573 errors.add(check.field, message);
574 }
575 Ok(errors)
576 }
577
578 pub(crate) fn rules_with_texts(data: &impl Validate, texts: crate::i18n::Texts) -> Self {
580 let mut validator = Self::new().with_texts(texts);
581 data.rules(&mut validator);
582 validator
583 }
584
585 pub fn rules_of(data: &impl Validate) -> Self {
596 let mut validator = Self::new();
597 data.rules(&mut validator);
598 validator
599 }
600}
601
602pub struct Field<'v> {
604 v: &'v mut Validator,
605 name: String,
606 label: String,
607 translated: bool,
609 value: Inspected,
610 db_value: DbValue,
611 failed: bool,
612 last_pending: Option<usize>,
613 last_check: Option<usize>,
615}
616
617fn number(n: f64) -> String {
618 if n.fract() == 0.0 && n.abs() < 1e15 {
619 format!("{}", n as i64)
620 } else {
621 n.to_string()
622 }
623}
624
625impl Field<'_> {
626 pub fn label(mut self, label: &str) -> Self {
628 self.label = label.to_owned();
629 self
630 }
631
632 pub(crate) fn fallback_label(mut self, label: &str) -> Self {
634 if !self.translated {
635 self.label = label.to_owned();
636 }
637 self
638 }
639
640 fn fail(&mut self, key: &str, params: &[(&str, String)]) {
641 if !self.failed {
642 let message = render(&self.v.template(key), &self.label, params);
643 self.v.errors.add(&self.name, message);
644 self.failed = true;
645 self.last_pending = None;
646 self.last_check = None;
647 }
648 }
649
650 fn check(&mut self, kind: AsyncKind) {
651 self.v.checks.push(AsyncCheck {
652 field: self.name.clone(),
653 label: self.label.clone(),
654 kind,
655 message: None,
656 });
657 self.last_pending = None;
658 self.last_check = Some(self.v.checks.len() - 1);
659 }
660
661 fn present(&self) -> bool {
662 !self.failed && self.value != Inspected::Missing
663 }
664
665 pub fn message(self, message: impl Into<String>) -> Self {
670 let message = message.into();
671 if let Some(i) = self.last_pending {
672 self.v.pending[i].message = Some(message);
673 } else if let Some(i) = self.last_check {
674 self.v.checks[i].message = Some(message);
675 } else if self.failed
676 && let Some(last) = self
677 .v
678 .errors
679 .0
680 .get_mut(&self.name)
681 .and_then(|messages| messages.last_mut())
682 {
683 *last = message;
684 }
685 self
686 }
687
688 pub fn required(mut self) -> Self {
690 if !self.failed && self.value == Inspected::Missing {
691 self.fail("required", &[]);
692 }
693 self
694 }
695
696 fn size_rule(
697 mut self,
698 kind: &str,
699 ok: impl Fn(f64) -> bool,
700 params: &[(&str, String)],
701 ) -> Self {
702 if !self.present() {
703 return self;
704 }
705 let (size, suffix) = match &self.value {
706 Inspected::Text(text) => (text.chars().count() as f64, "string"),
707 Inspected::Number(n) => (*n, "numeric"),
708 Inspected::Items(n) => (*n as f64, "array"),
709 Inspected::File { kilobytes, .. } => (*kilobytes, "file"),
710 _ => return self,
711 };
712 if !ok(size) {
713 self.fail(&format!("{kind}.{suffix}"), params);
714 }
715 self
716 }
717
718 pub fn min(self, min: impl Into<f64>) -> Self {
720 let min = min.into();
721 self.size_rule("min", |s| s >= min, &[("min", number(min))])
722 }
723
724 pub fn max(self, max: impl Into<f64>) -> Self {
726 let max = max.into();
727 self.size_rule("max", |s| s <= max, &[("max", number(max))])
728 }
729
730 pub fn between(self, min: impl Into<f64>, max: impl Into<f64>) -> Self {
732 let (min, max) = (min.into(), max.into());
733 self.size_rule(
734 "between",
735 |s| s >= min && s <= max,
736 &[("min", number(min)), ("max", number(max))],
737 )
738 }
739
740 pub fn email(mut self) -> Self {
742 if let (true, Inspected::Text(text)) = (self.present(), &self.value)
743 && !is_email(text)
744 {
745 self.fail("email", &[]);
746 }
747 self
748 }
749
750 pub fn url(mut self) -> Self {
752 if let (true, Inspected::Text(text)) = (self.present(), &self.value)
753 && !is_url(text)
754 {
755 self.fail("url", &[]);
756 }
757 self
758 }
759
760 pub fn image(mut self) -> Self {
763 if let (true, Inspected::File { image, .. }) = (self.present(), &self.value)
764 && !*image
765 {
766 self.fail("image", &[]);
767 }
768 self
769 }
770
771 pub fn mimes(mut self, extensions: &[&str]) -> Self {
775 if let (true, Inspected::File { extension, .. }) = (self.present(), &self.value) {
776 let normalise = |e: &str| match e.to_ascii_lowercase().as_str() {
777 "jpeg" => "jpg".to_owned(),
778 other => other.to_owned(),
779 };
780 let ok = extensions
781 .iter()
782 .any(|e| normalise(e) == normalise(extension));
783 if !ok {
784 self.fail("mimes", &[("values", extensions.join(", "))]);
785 }
786 }
787 self
788 }
789
790 pub fn one_of<V: FieldValue>(mut self, allowed: &[V]) -> Self {
792 if self.present() && !allowed.iter().any(|a| a.inspect() == self.value) {
793 self.fail("in", &[]);
794 }
795 self
796 }
797
798 pub fn confirmed(mut self, confirmation: &impl FieldValue) -> Self {
800 if self.present() && confirmation.inspect() != self.value {
801 self.fail("confirmed", &[]);
802 }
803 self
804 }
805
806 pub fn accepted(mut self) -> Self {
808 if !self.failed && self.value != Inspected::Bool(true) {
809 self.fail("accepted", &[]);
810 }
811 self
812 }
813
814 pub fn rule(mut self, valid: bool, message: impl Into<String>) -> Self {
816 if !self.failed && !valid {
817 self.v.errors.add(&self.name, message);
818 self.failed = true;
819 self.last_pending = None;
820 self.last_check = None;
821 }
822 self
823 }
824
825 pub fn matches(mut self, pattern: &str) -> Self {
828 if let (true, Inspected::Text(text)) = (self.present(), &self.value) {
829 let ok = match cached_regex(pattern) {
830 Ok(regex) => regex.is_match(text),
831 Err(err) => {
832 tracing::error!(pattern, error = %err, "invalid pattern in a `matches` rule");
833 false
834 }
835 };
836 if !ok {
837 self.fail("regex", &[]);
838 }
839 }
840 self
841 }
842
843 pub fn digits(mut self, n: usize) -> Self {
845 if self.present() && digit_count(&self.value) != Some(n) {
846 self.fail("digits", &[("digits", n.to_string())]);
847 }
848 self
849 }
850
851 pub fn digits_between(mut self, min: usize, max: usize) -> Self {
853 if self.present() && !digit_count(&self.value).is_some_and(|n| n >= min && n <= max) {
854 self.fail(
855 "digits_between",
856 &[("min", min.to_string()), ("max", max.to_string())],
857 );
858 }
859 self
860 }
861
862 pub fn date(mut self) -> Self {
864 if self.present() && self.as_date().is_none() {
865 self.fail("date", &[]);
866 }
867 self
868 }
869
870 fn date_rule(
871 mut self,
872 key: &str,
873 limit: NaiveDateTime,
874 ok: impl Fn(NaiveDateTime, NaiveDateTime) -> bool,
875 ) -> Self {
876 if !self.present() {
877 return self;
878 }
879 match self.as_date() {
880 None => self.fail("date", &[]),
881 Some(date) if !ok(date, limit) => {
882 let shown = if limit.time() == chrono::NaiveTime::MIN {
883 limit.date().to_string()
884 } else {
885 limit.format("%Y-%m-%d %H:%M").to_string()
886 };
887 self.fail(key, &[("date", shown)]);
888 }
889 Some(_) => {}
890 }
891 self
892 }
893
894 pub fn before(self, limit: impl FieldValue) -> Self {
897 match limit_date(&limit) {
898 Some(limit) => self.date_rule("before", limit, |d, l| d < l),
899 None => self,
900 }
901 }
902
903 pub fn before_or_equal(self, limit: impl FieldValue) -> Self {
905 match limit_date(&limit) {
906 Some(limit) => self.date_rule("before_or_equal", limit, |d, l| d <= l),
907 None => self,
908 }
909 }
910
911 pub fn after(self, limit: impl FieldValue) -> Self {
913 match limit_date(&limit) {
914 Some(limit) => self.date_rule("after", limit, |d, l| d > l),
915 None => self,
916 }
917 }
918
919 pub fn after_or_equal(self, limit: impl FieldValue) -> Self {
921 match limit_date(&limit) {
922 Some(limit) => self.date_rule("after_or_equal", limit, |d, l| d >= l),
923 None => self,
924 }
925 }
926
927 fn as_date(&self) -> Option<NaiveDateTime> {
928 match &self.value {
929 Inspected::Date(date) => Some(*date),
930 Inspected::Text(text) => parse_date(text.trim()),
931 _ => None,
932 }
933 }
934
935 pub fn none_of<V: FieldValue>(mut self, refused: &[V]) -> Self {
937 if self.present() && refused.iter().any(|r| r.inspect() == self.value) {
938 self.fail("not_in", &[]);
939 }
940 self
941 }
942
943 fn text_rule(
944 mut self,
945 key: &str,
946 ok: impl Fn(&str) -> bool,
947 params: &[(&str, String)],
948 ) -> Self {
949 if let (true, Inspected::Text(text)) = (self.present(), &self.value)
950 && !ok(text)
951 {
952 self.fail(key, params);
953 }
954 self
955 }
956
957 pub fn alpha(self) -> Self {
959 self.text_rule("alpha", |t| t.chars().all(char::is_alphabetic), &[])
960 }
961
962 pub fn alpha_num(self) -> Self {
964 self.text_rule("alpha_num", |t| t.chars().all(char::is_alphanumeric), &[])
965 }
966
967 pub fn alpha_dash(self) -> Self {
969 self.text_rule(
970 "alpha_dash",
971 |t| {
972 t.chars()
973 .all(|c| c.is_alphanumeric() || c == '-' || c == '_')
974 },
975 &[],
976 )
977 }
978
979 pub fn lowercase(self) -> Self {
981 self.text_rule("lowercase", |t| !t.chars().any(char::is_uppercase), &[])
982 }
983
984 pub fn uppercase(self) -> Self {
986 self.text_rule("uppercase", |t| !t.chars().any(char::is_lowercase), &[])
987 }
988
989 pub fn starts_with(self, prefixes: &[&str]) -> Self {
991 let values = prefixes.join(", ");
992 self.text_rule(
993 "starts_with",
994 |t| prefixes.iter().any(|p| t.starts_with(p)),
995 &[("values", values)],
996 )
997 }
998
999 pub fn ends_with(self, suffixes: &[&str]) -> Self {
1001 let values = suffixes.join(", ");
1002 self.text_rule(
1003 "ends_with",
1004 |t| suffixes.iter().any(|s| t.ends_with(s)),
1005 &[("values", values)],
1006 )
1007 }
1008
1009 pub fn uuid(self) -> Self {
1011 self.text_rule("uuid", is_uuid, &[])
1012 }
1013
1014 pub fn ip(self) -> Self {
1016 self.text_rule("ip", |t| t.trim().parse::<std::net::IpAddr>().is_ok(), &[])
1017 }
1018
1019 pub fn size(self, size: impl Into<f64>) -> Self {
1022 let size = size.into();
1023 self.size_rule("size", |s| s == size, &[("size", number(size))])
1024 }
1025
1026 pub fn required_without(self, other: &impl FieldValue) -> Self {
1028 let missing = other.inspect() == Inspected::Missing;
1029 self.required_if(missing)
1030 }
1031
1032 pub fn prohibited_if(mut self, condition: bool) -> Self {
1035 if condition && !self.failed && self.value != Inspected::Missing {
1036 self.fail("prohibited", &[]);
1037 }
1038 self
1039 }
1040
1041 pub fn required_if(self, condition: bool) -> Self {
1044 if condition { self.required() } else { self }
1045 }
1046
1047 pub fn required_unless(self, condition: bool) -> Self {
1049 self.required_if(!condition)
1050 }
1051
1052 pub fn required_with(self, other: &impl FieldValue) -> Self {
1055 let given = other.inspect() != Inspected::Missing;
1056 self.required_if(given)
1057 }
1058
1059 pub fn same(mut self, other: &str, value: &impl FieldValue) -> Self {
1061 if self.present() && value.inspect() != self.value {
1062 let other = self.v.label_for(other);
1063 self.fail("same", &[("other", other)]);
1064 }
1065 self
1066 }
1067
1068 pub fn different(mut self, other: &str, value: &impl FieldValue) -> Self {
1070 if self.present() && value.inspect() == self.value {
1071 let other = self.v.label_for(other);
1072 self.fail("different", &[("other", other)]);
1073 }
1074 self
1075 }
1076
1077 fn compare_rule(
1078 mut self,
1079 key: &str,
1080 other: &str,
1081 value: &impl FieldValue,
1082 ok: fn(std::cmp::Ordering) -> bool,
1083 ) -> Self {
1084 if !self.present() {
1085 return self;
1086 }
1087 let other_value = value.inspect();
1088 if other_value == Inspected::Missing {
1089 return self;
1090 }
1091 let both_numeric = matches!(
1094 (&self.value, &other_value),
1095 (Inspected::Text(_), Inspected::Text(_))
1096 ) && numeric_value(&self.value).is_some()
1097 && numeric_value(&other_value).is_some();
1098 let measured = |value: &Inspected| -> Option<(f64, &'static str)> {
1099 match value {
1100 Inspected::Text(_) if both_numeric => Some((numeric_value(value)?, "numeric")),
1101 Inspected::Number(n) => Some((*n, "numeric")),
1102 Inspected::Text(text) => Some((text.chars().count() as f64, "string")),
1103 Inspected::Items(n) => Some((*n as f64, "array")),
1104 Inspected::File { kilobytes, .. } => Some((*kilobytes, "file")),
1105 _ => None,
1106 }
1107 };
1108 let label = self.v.label_for(other);
1109 let as_date = |value: &Inspected| match value {
1111 Inspected::Date(date) => Some(*date),
1112 Inspected::Text(text) => parse_date(text.trim()),
1113 _ => None,
1114 };
1115 let dates = as_date(&self.value).zip(as_date(&other_value));
1116 if let Some((a, b)) = dates {
1117 if !ok(a.cmp(&b)) {
1118 self.fail(&format!("{key}.date"), &[("other", label)]);
1119 }
1120 return self;
1121 }
1122 match (measured(&self.value), measured(&other_value)) {
1123 (Some((a, kind)), Some((b, other_kind))) if kind == other_kind => {
1124 if !a.partial_cmp(&b).is_some_and(ok) {
1125 self.fail(&format!("{key}.{kind}"), &[("other", label)]);
1126 }
1127 }
1128 _ => self.fail(&format!("{key}.numeric"), &[("other", label)]),
1129 }
1130 self
1131 }
1132
1133 pub fn gt(self, other: &str, value: &impl FieldValue) -> Self {
1139 self.compare_rule("gt", other, value, |o| o.is_gt())
1140 }
1141
1142 pub fn gte(self, other: &str, value: &impl FieldValue) -> Self {
1144 self.compare_rule("gte", other, value, |o| o.is_ge())
1145 }
1146
1147 pub fn lt(self, other: &str, value: &impl FieldValue) -> Self {
1149 self.compare_rule("lt", other, value, |o| o.is_lt())
1150 }
1151
1152 pub fn lte(self, other: &str, value: &impl FieldValue) -> Self {
1154 self.compare_rule("lte", other, value, |o| o.is_le())
1155 }
1156
1157 pub fn decimal(mut self, min: usize, max: usize) -> Self {
1162 if !self.present() {
1163 return self;
1164 }
1165 let text = match &self.value {
1166 Inspected::Text(text) => text.trim().to_owned(),
1167 Inspected::Number(n) => n.to_string(),
1168 _ => String::new(),
1169 };
1170 let places = decimal_places(&text);
1171 if !places.is_some_and(|p| p >= min && p <= max) {
1172 let places = if min == max {
1173 min.to_string()
1174 } else {
1175 format!("{min}-{max}")
1176 };
1177 self.fail("decimal", &[("decimal", places)]);
1178 }
1179 self
1180 }
1181
1182 pub fn dimensions(mut self, limits: &Dimensions) -> Self {
1186 if let (true, Inspected::File { dimensions, .. }) = (self.present(), &self.value) {
1187 let ok = dimensions.is_some_and(|(w, h)| limits.allows(w, h));
1188 if !ok {
1189 self.fail("dimensions", &[]);
1190 }
1191 }
1192 self
1193 }
1194
1195 pub fn prohibited(self) -> Self {
1198 self.prohibited_if(true)
1199 }
1200
1201 pub fn prohibited_unless(self, condition: bool) -> Self {
1203 self.prohibited_if(!condition)
1204 }
1205
1206 pub fn prohibits(mut self, other: &str, value: &impl FieldValue) -> Self {
1209 if self.present() && value.inspect() != Inspected::Missing {
1210 let other = self.v.label_for(other);
1211 self.fail("prohibits", &[("other", other)]);
1212 }
1213 self
1214 }
1215
1216 pub fn required_with_all(self, others: &[&dyn FieldValue]) -> Self {
1218 let all = others.iter().all(|o| o.inspect() != Inspected::Missing);
1219 self.required_if(all)
1220 }
1221
1222 pub fn required_without_all(self, others: &[&dyn FieldValue]) -> Self {
1225 let none = others.iter().all(|o| o.inspect() == Inspected::Missing);
1226 self.required_if(none)
1227 }
1228
1229 pub fn min_digits(mut self, min: usize) -> Self {
1231 if self.present() && !integer_digits(&self.value).is_some_and(|n| n >= min) {
1232 self.fail("min_digits", &[("min", min.to_string())]);
1233 }
1234 self
1235 }
1236
1237 pub fn max_digits(mut self, max: usize) -> Self {
1239 if self.present() && !integer_digits(&self.value).is_some_and(|n| n <= max) {
1240 self.fail("max_digits", &[("max", max.to_string())]);
1241 }
1242 self
1243 }
1244
1245 pub fn multiple_of(mut self, step: impl Into<f64>) -> Self {
1248 let step = step.into();
1249 if !self.present() {
1250 return self;
1251 }
1252 let ok = numeric_value(&self.value).is_some_and(|n| {
1253 let ratio = n / step;
1254 step != 0.0 && (ratio - ratio.round()).abs() < 1e-9
1255 });
1256 if !ok {
1257 self.fail("multiple_of", &[("value", number(step))]);
1258 }
1259 self
1260 }
1261
1262 pub fn numeric(mut self) -> Self {
1264 if self.present() && numeric_value(&self.value).is_none() {
1265 self.fail("numeric", &[]);
1266 }
1267 self
1268 }
1269
1270 pub fn integer(mut self) -> Self {
1273 if self.present() && !numeric_value(&self.value).is_some_and(|n| n.fract() == 0.0) {
1274 self.fail("integer", &[]);
1275 }
1276 self
1277 }
1278
1279 pub fn json(self) -> Self {
1281 self.text_rule(
1282 "json",
1283 |t| serde_json::from_str::<serde_json::Value>(t).is_ok(),
1284 &[],
1285 )
1286 }
1287
1288 pub fn ulid(self) -> Self {
1290 self.text_rule("ulid", |t| t.trim().parse::<crate::db::Ulid>().is_ok(), &[])
1291 }
1292
1293 pub fn timezone(self) -> Self {
1295 self.text_rule(
1296 "timezone",
1297 |t| t.trim().parse::<chrono_tz::Tz>().is_ok(),
1298 &[],
1299 )
1300 }
1301
1302 pub fn mac_address(self) -> Self {
1305 self.text_rule("mac_address", is_mac_address, &[])
1306 }
1307
1308 pub fn ascii(self) -> Self {
1310 self.text_rule("ascii", |t| t.is_ascii(), &[])
1311 }
1312
1313 pub fn hex_color(self) -> Self {
1315 self.text_rule(
1316 "hex_color",
1317 |t| {
1318 t.strip_prefix('#').is_some_and(|hex| {
1319 matches!(hex.len(), 3 | 4 | 6 | 8) && hex.chars().all(|c| c.is_ascii_hexdigit())
1320 })
1321 },
1322 &[],
1323 )
1324 }
1325
1326 pub fn doesnt_start_with(self, prefixes: &[&str]) -> Self {
1328 let values = prefixes.join(", ");
1329 self.text_rule(
1330 "doesnt_start_with",
1331 |t| !prefixes.iter().any(|p| t.starts_with(p)),
1332 &[("values", values)],
1333 )
1334 }
1335
1336 pub fn doesnt_end_with(self, suffixes: &[&str]) -> Self {
1338 let values = suffixes.join(", ");
1339 self.text_rule(
1340 "doesnt_end_with",
1341 |t| !suffixes.iter().any(|s| t.ends_with(s)),
1342 &[("values", values)],
1343 )
1344 }
1345
1346 pub fn not_matches(mut self, pattern: &str) -> Self {
1348 if let (true, Inspected::Text(text)) = (self.present(), &self.value) {
1349 let matched = match cached_regex(pattern) {
1350 Ok(regex) => regex.is_match(text),
1351 Err(err) => {
1352 tracing::error!(pattern, error = %err, "invalid pattern in a `not_matches` rule");
1353 true
1354 }
1355 };
1356 if matched {
1357 self.fail("not_regex", &[]);
1358 }
1359 }
1360 self
1361 }
1362
1363 pub fn accepted_if(self, condition: bool) -> Self {
1365 if condition { self.accepted() } else { self }
1366 }
1367
1368 pub fn declined(mut self) -> Self {
1371 let declined = match &self.value {
1372 Inspected::Bool(b) => !b,
1373 Inspected::Number(n) => *n == 0.0,
1374 Inspected::Text(text) => {
1375 matches!(
1376 text.trim().to_ascii_lowercase().as_str(),
1377 "no" | "off" | "0" | "false"
1378 )
1379 }
1380 _ => false,
1381 };
1382 if !self.failed && !declined {
1383 self.fail("declined", &[]);
1384 }
1385 self
1386 }
1387
1388 pub fn declined_if(self, condition: bool) -> Self {
1390 if condition { self.declined() } else { self }
1391 }
1392
1393 pub fn password(mut self, policy: &Password) -> Self {
1396 if let (true, Inspected::Text(text)) = (self.present(), &self.value) {
1397 match policy.broken(text) {
1398 Some((key, params)) => self.fail(key, ¶ms),
1399 None if policy.uncompromised => {
1400 let text = text.clone();
1401 self.check(AsyncKind::Uncompromised(text));
1402 }
1403 None => {}
1404 }
1405 }
1406 self
1407 }
1408
1409 pub fn current_password(mut self) -> Self {
1414 if let (true, Inspected::Text(text)) = (self.present(), &self.value) {
1415 let text = text.clone();
1416 self.check(AsyncKind::CurrentPassword(text));
1417 }
1418 self
1419 }
1420
1421 pub fn apply(mut self, rule: &impl Rule) -> Self {
1423 if !self.present() {
1424 return self;
1425 }
1426 if let Err(message) = rule.check(&self.value) {
1427 let message = render(&message, &self.label, &[]);
1428 self.v.errors.add(&self.name, message);
1429 self.failed = true;
1430 self.last_pending = None;
1431 self.last_check = None;
1432 }
1433 self
1434 }
1435
1436 fn database(mut self, table: &str, column: &str, unique: bool) -> Self {
1437 if self.present() {
1438 self.v.pending.push(Pending {
1439 field: self.name.clone(),
1440 label: self.label.clone(),
1441 table: table.to_owned(),
1442 column: column.to_owned(),
1443 value: self.db_value.clone(),
1444 ignore_id: None,
1445 scope: Vec::new(),
1446 unique,
1447 message: None,
1448 });
1449 self.last_pending = Some(self.v.pending.len() - 1);
1450 self.last_check = None;
1451 }
1452 self
1453 }
1454
1455 pub fn unique(self, table: &str, column: &str) -> Self {
1457 self.database(table, column, true)
1458 }
1459
1460 pub fn ignore(self, id: impl crate::db::ToDbValue) -> Self {
1464 if let Some(i) = self.last_pending {
1465 self.v.pending[i].ignore_id = Some(id.to_db_value());
1466 }
1467 self
1468 }
1469
1470 pub fn exists(self, table: &str, column: &str) -> Self {
1472 self.database(table, column, false)
1473 }
1474
1475 pub fn where_eq(self, column: &str, value: impl ToDbValue) -> Self {
1495 self.scope(ScopeCondition::Eq(column.to_owned(), value.to_db_value()))
1496 }
1497
1498 pub fn where_null(self, column: &str) -> Self {
1500 self.scope(ScopeCondition::Null(column.to_owned()))
1501 }
1502
1503 pub fn where_not_null(self, column: &str) -> Self {
1505 self.scope(ScopeCondition::NotNull(column.to_owned()))
1506 }
1507
1508 fn scope(self, condition: ScopeCondition) -> Self {
1509 if let Some(i) = self.last_pending {
1510 self.v.pending[i].scope.push(condition);
1511 }
1512 self
1513 }
1514}
1515
1516#[derive(Debug, Clone, PartialEq, Eq)]
1532pub struct Password {
1533 min: usize,
1534 letters: bool,
1535 mixed_case: bool,
1536 numbers: bool,
1537 symbols: bool,
1538 uncompromised: bool,
1539}
1540
1541impl Password {
1542 pub fn min(min: usize) -> Self {
1544 Self {
1545 min,
1546 letters: false,
1547 mixed_case: false,
1548 numbers: false,
1549 symbols: false,
1550 uncompromised: false,
1551 }
1552 }
1553
1554 pub fn uncompromised(mut self) -> Self {
1561 self.uncompromised = true;
1562 self
1563 }
1564
1565 pub fn letters(mut self) -> Self {
1567 self.letters = true;
1568 self
1569 }
1570
1571 pub fn mixed_case(mut self) -> Self {
1573 self.mixed_case = true;
1574 self
1575 }
1576
1577 pub fn numbers(mut self) -> Self {
1579 self.numbers = true;
1580 self
1581 }
1582
1583 pub fn symbols(mut self) -> Self {
1585 self.symbols = true;
1586 self
1587 }
1588
1589 fn broken(&self, password: &str) -> Option<(&'static str, Vec<(&'static str, String)>)> {
1591 if password.chars().count() < self.min {
1592 return Some(("min.string", vec![("min", self.min.to_string())]));
1593 }
1594 let has = |test: fn(&char) -> bool| password.chars().any(|c| test(&c));
1595 if self.letters && !has(|c| c.is_alphabetic()) {
1596 return Some(("password.letters", Vec::new()));
1597 }
1598 if self.mixed_case && !(has(|c| c.is_uppercase()) && has(|c| c.is_lowercase())) {
1599 return Some(("password.mixed", Vec::new()));
1600 }
1601 if self.numbers && !has(|c| c.is_numeric()) {
1602 return Some(("password.numbers", Vec::new()));
1603 }
1604 if self.symbols && !has(|c| !c.is_alphanumeric() && !c.is_whitespace()) {
1605 return Some(("password.symbols", Vec::new()));
1606 }
1607 None
1608 }
1609}
1610
1611impl Default for Password {
1612 fn default() -> Self {
1613 Self::min(8)
1614 }
1615}
1616
1617pub trait Rule {
1647 fn check(&self, value: &Inspected) -> std::result::Result<(), String>;
1649}
1650
1651#[derive(Debug, Clone, Default, PartialEq)]
1666pub struct Dimensions {
1667 min_width: Option<u32>,
1668 max_width: Option<u32>,
1669 min_height: Option<u32>,
1670 max_height: Option<u32>,
1671 width: Option<u32>,
1672 height: Option<u32>,
1673 ratio: Option<(u32, u32)>,
1674}
1675
1676impl Dimensions {
1677 pub fn new() -> Self {
1679 Self::default()
1680 }
1681
1682 pub fn min_width(mut self, px: u32) -> Self {
1684 self.min_width = Some(px);
1685 self
1686 }
1687
1688 pub fn max_width(mut self, px: u32) -> Self {
1690 self.max_width = Some(px);
1691 self
1692 }
1693
1694 pub fn min_height(mut self, px: u32) -> Self {
1696 self.min_height = Some(px);
1697 self
1698 }
1699
1700 pub fn max_height(mut self, px: u32) -> Self {
1702 self.max_height = Some(px);
1703 self
1704 }
1705
1706 pub fn width(mut self, px: u32) -> Self {
1708 self.width = Some(px);
1709 self
1710 }
1711
1712 pub fn height(mut self, px: u32) -> Self {
1714 self.height = Some(px);
1715 self
1716 }
1717
1718 pub fn ratio(mut self, width: u32, height: u32) -> Self {
1721 self.ratio = Some((width, height));
1722 self
1723 }
1724
1725 fn allows(&self, w: u32, h: u32) -> bool {
1726 let at_least = |limit: Option<u32>, v: u32| limit.is_none_or(|l| v >= l);
1727 let at_most = |limit: Option<u32>, v: u32| limit.is_none_or(|l| v <= l);
1728 let exactly = |limit: Option<u32>, v: u32| limit.is_none_or(|l| v == l);
1729 let ratio = self.ratio.is_none_or(|(rw, rh)| {
1730 rw > 0 && (h as f64 - w as f64 * rh as f64 / rw as f64).abs() <= 1.0
1732 });
1733 at_least(self.min_width, w)
1734 && at_most(self.max_width, w)
1735 && at_least(self.min_height, h)
1736 && at_most(self.max_height, h)
1737 && exactly(self.width, w)
1738 && exactly(self.height, h)
1739 && ratio
1740 }
1741}
1742
1743const PWNED_RANGE: &str = "https://api.pwnedpasswords.com/range/";
1745
1746async fn breached(state: &crate::AppState, password: &str) -> bool {
1749 use sha1::{Digest, Sha1};
1750 let hash: String = Sha1::digest(password.as_bytes())
1751 .iter()
1752 .map(|b| format!("{b:02X}"))
1753 .collect();
1754 let (prefix, suffix) = hash.split_at(5);
1755 let response = state
1756 .http
1757 .get(format!("{PWNED_RANGE}{prefix}"))
1758 .header("Add-Padding", "true")
1759 .timeout(std::time::Duration::from_secs(5))
1760 .send()
1761 .await;
1762 match response {
1763 Ok(response) if response.status().is_success() => response.text().lines().any(|line| {
1764 line.split_once(':').is_some_and(|(candidate, count)| {
1765 candidate.trim().eq_ignore_ascii_case(suffix)
1766 && count.trim().parse::<u64>().is_ok_and(|n| n > 0)
1767 })
1768 }),
1769 Ok(response) => {
1770 tracing::warn!(status = %response.status(), "the password breach check answered with an error; allowing the password");
1771 false
1772 }
1773 Err(err) => {
1774 tracing::warn!(error = ?err, "the password breach check failed; allowing the password");
1775 false
1776 }
1777 }
1778}
1779
1780fn decimal_places(text: &str) -> Option<usize> {
1782 let digits = text.strip_prefix(['-', '+']).unwrap_or(text);
1783 let (whole, fraction) = digits.split_once('.').unwrap_or((digits, ""));
1784 let all_digits = |s: &str| s.chars().all(|c| c.is_ascii_digit());
1785 (!whole.is_empty()
1786 && all_digits(whole)
1787 && all_digits(fraction)
1788 && !(digits.contains('.') && fraction.is_empty()))
1789 .then_some(fraction.len())
1790}
1791
1792fn limit_date(limit: &impl FieldValue) -> Option<NaiveDateTime> {
1795 match limit.inspect() {
1796 Inspected::Date(date) => Some(date),
1797 Inspected::Text(text) => parse_date(text.trim()),
1798 _ => None,
1799 }
1800}
1801
1802fn numeric_value(value: &Inspected) -> Option<f64> {
1804 match value {
1805 Inspected::Number(n) => Some(*n),
1806 Inspected::Text(text) => {
1807 let text = text.trim();
1808 decimal_places(text)?;
1809 text.parse::<f64>().ok().filter(|n| n.is_finite())
1810 }
1811 _ => None,
1812 }
1813}
1814
1815fn integer_digits(value: &Inspected) -> Option<usize> {
1817 let n = numeric_value(value)?;
1818 (n.fract() == 0.0).then(|| format!("{}", n.abs() as u64).len())
1819}
1820
1821fn is_mac_address(text: &str) -> bool {
1822 let text = text.trim();
1823 let hex = |s: &str, n: usize| s.len() == n && s.chars().all(|c| c.is_ascii_hexdigit());
1824 for separator in [':', '-'] {
1825 let parts: Vec<&str> = text.split(separator).collect();
1826 if parts.len() == 6 && parts.iter().all(|p| hex(p, 2)) {
1827 return true;
1828 }
1829 }
1830 let parts: Vec<&str> = text.split('.').collect();
1831 parts.len() == 3 && parts.iter().all(|p| hex(p, 4))
1832}
1833
1834fn is_uuid(text: &str) -> bool {
1835 let groups: Vec<&str> = text.trim().split('-').collect();
1836 groups.len() == 5
1837 && groups
1838 .iter()
1839 .zip([8, 4, 4, 4, 12])
1840 .all(|(g, n)| g.len() == n && g.chars().all(|c| c.is_ascii_hexdigit()))
1841}
1842
1843fn cached_regex(pattern: &str) -> std::result::Result<regex::Regex, regex::Error> {
1845 static CACHE: std::sync::LazyLock<
1846 std::sync::Mutex<std::collections::HashMap<String, regex::Regex>>,
1847 > = std::sync::LazyLock::new(Default::default);
1848 let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
1849 if let Some(regex) = cache.get(pattern) {
1850 return Ok(regex.clone());
1851 }
1852 let regex = regex::Regex::new(pattern)?;
1853 if cache.len() < 1000 {
1854 cache.insert(pattern.to_owned(), regex.clone());
1855 }
1856 Ok(regex)
1857}
1858
1859fn digit_count(value: &Inspected) -> Option<usize> {
1861 let text = match value {
1862 Inspected::Text(text) => text.trim().to_owned(),
1863 Inspected::Number(n) if n.fract() == 0.0 && *n >= 0.0 => format!("{}", *n as u64),
1864 _ => return None,
1865 };
1866 (!text.is_empty() && text.chars().all(|c| c.is_ascii_digit())).then_some(text.len())
1867}
1868
1869fn parse_date(text: &str) -> Option<NaiveDateTime> {
1871 if let Ok(date) = chrono::NaiveDate::parse_from_str(text, "%Y-%m-%d") {
1872 return Some(date.and_time(chrono::NaiveTime::MIN));
1873 }
1874 for format in [
1875 "%Y-%m-%dT%H:%M",
1876 "%Y-%m-%dT%H:%M:%S",
1877 "%Y-%m-%d %H:%M",
1878 "%Y-%m-%d %H:%M:%S",
1879 ] {
1880 if let Ok(date) = NaiveDateTime::parse_from_str(text, format) {
1881 return Some(date);
1882 }
1883 }
1884 chrono::DateTime::parse_from_rfc3339(text)
1885 .ok()
1886 .map(|d| d.naive_utc())
1887}
1888
1889fn is_email(text: &str) -> bool {
1890 let Some((local, domain)) = text.split_once('@') else {
1891 return false;
1892 };
1893 !local.is_empty()
1894 && !text.chars().any(char::is_whitespace)
1895 && !domain.contains('@')
1896 && domain.contains('.')
1897 && domain.split('.').all(|part| !part.is_empty())
1898}
1899
1900fn is_url(text: &str) -> bool {
1901 let rest = text
1902 .strip_prefix("https://")
1903 .or_else(|| text.strip_prefix("http://"));
1904 matches!(rest, Some(rest) if !rest.is_empty()
1905 && !rest.starts_with('/')
1906 && !text.chars().any(char::is_whitespace))
1907}
1908
1909const DONT_FLASH: &[&str] = &[
1911 "password",
1912 "password_confirmation",
1913 "current_password",
1914 "_token",
1915];
1916
1917#[derive(Debug, Clone)]
1934pub struct ValidationError {
1935 pub errors: Errors,
1937 pub input: Map<String, Value>,
1939 bag: Option<String>,
1941}
1942
1943impl ValidationError {
1944 pub fn new(errors: Errors) -> Self {
1946 Self {
1947 errors,
1948 input: Map::new(),
1949 bag: None,
1950 }
1951 }
1952
1953 pub fn in_bag(mut self, bag: impl Into<String>) -> Self {
1959 self.bag = Some(bag.into());
1960 self
1961 }
1962
1963 pub fn bag(&self) -> Option<&str> {
1965 self.bag.as_deref()
1966 }
1967
1968 pub fn with_input(mut self, input: &impl Serialize) -> Self {
1970 if let Ok(Value::Object(map)) = serde_json::to_value(input) {
1971 self.input = map;
1972 }
1973 self.input
1974 .retain(|key, _| !DONT_FLASH.contains(&key.as_str()));
1975 self
1976 }
1977
1978 pub(crate) fn with_input_map(mut self, input: Map<String, Value>) -> Self {
1979 self.input = input;
1980 self.input
1981 .retain(|key, _| !DONT_FLASH.contains(&key.as_str()));
1982 self
1983 }
1984}
1985
1986impl From<Errors> for ValidationError {
1987 fn from(errors: Errors) -> Self {
1988 Self::new(errors)
1989 }
1990}
1991
1992impl IntoResponse for ValidationError {
1993 fn into_response(self) -> Response {
1994 let message = self
1995 .errors
1996 .iter()
1997 .next()
1998 .and_then(|(_, m)| m.first().cloned())
1999 .unwrap_or_default();
2000 let body = json!({ "message": message, "errors": self.errors });
2001 let mut res = (StatusCode::UNPROCESSABLE_ENTITY, axum::Json(body)).into_response();
2002 res.extensions_mut().insert(self);
2003 res
2004 }
2005}
2006
2007#[cfg(test)]
2008mod tests {
2009 use super::*;
2010
2011 #[test]
2012 fn checks_emails_and_urls() {
2013 assert!(is_email("alex@example.com"));
2014 assert!(!is_email("alex@localhost"));
2015 assert!(!is_email("alex example@x.com"));
2016 assert!(!is_email("@x.com"));
2017 assert!(is_url("https://renox.dev/docs"));
2018 assert!(!is_url("ftp://renox.dev"));
2019 assert!(!is_url("https://"));
2020 }
2021
2022 #[test]
2023 fn formats_numbers_without_trailing_zeros() {
2024 assert_eq!(number(3.0), "3");
2025 assert_eq!(number(2.5), "2.5");
2026 }
2027 struct As(Inspected);
2029
2030 impl FieldValue for As {
2031 fn inspect(&self) -> Inspected {
2032 self.0.clone()
2033 }
2034 fn db_value(&self) -> DbValue {
2035 DbValue::Null
2036 }
2037 }
2038
2039 struct Never;
2041
2042 impl Rule for Never {
2043 fn check(&self, _: &Inspected) -> std::result::Result<(), String> {
2044 Err("never".into())
2045 }
2046 }
2047
2048 fn file(kilobytes: f64, dimensions: Option<(u32, u32)>) -> As {
2049 As(Inspected::File {
2050 kilobytes,
2051 extension: "png".into(),
2052 image: true,
2053 dimensions,
2054 })
2055 }
2056
2057 #[test]
2060 fn rules_pass_or_step_aside_for_values_they_dont_judge() {
2061 let mut v = Validator::default();
2062 let photo = file(120.0, Some((800, 600)));
2063 v.field("photo", &photo)
2064 .mimes(&["PNG", "jpeg"])
2065 .dimensions(&Dimensions::new().min_width(400).max_height(600));
2066 v.field("code", &"AB1234")
2067 .matches(r"^[A-Z]{2}\d{4}$")
2068 .not_matches(r"^\d+$");
2069 v.field("secret", &"long enough password")
2070 .password(&Password::min(8));
2071 let missing: Option<String> = None;
2073 v.field("note", &missing)
2074 .decimal(2, 2)
2075 .apply(&Never)
2076 .gt("other", &"5");
2077 v.field("price", &"12").gt("budget", &missing);
2078 v.field("day", &"2026-01-01")
2080 .before_or_equal(5)
2081 .after(true)
2082 .after_or_equal(2.5);
2083 assert!(v.errors.is_empty(), "{:?}", v.errors);
2084 }
2085
2086 #[test]
2087 fn rules_fail_values_of_the_wrong_kind() {
2088 let mut v = Validator::default();
2089 v.field("day", &7).before("2026-01-01");
2091 v.field("agree", &true).digits(3);
2092 v.field("flag", &true).decimal(2, 2);
2093 v.field("on", &true).numeric();
2094 v.field("photo", &file(10.0, None))
2096 .gt("thumb", &file(20.0, None));
2097 v.field("yes", &true).lt("no", &false);
2098 assert_eq!(v.errors.first("day"), Some("The day is not a valid date."));
2099 assert_eq!(v.errors.first("agree"), Some("The agree must be 3 digits."));
2100 assert_eq!(
2101 v.errors.first("flag"),
2102 Some("The flag must have 2 decimal places.")
2103 );
2104 assert_eq!(v.errors.first("on"), Some("The on must be a number."));
2105 assert_eq!(
2106 v.errors.first("photo"),
2107 Some("The photo must be larger than thumb.")
2108 );
2109 assert!(v.errors.has("yes"), "{:?}", v.errors);
2110 let mut v = Validator::new();
2112 v.field("photo", &file(1.0, Some((2000, 10))))
2113 .dimensions(&Dimensions::new().max_width(100));
2114 assert!(v.errors.has("photo"));
2115 }
2116
2117 #[test]
2118 fn nested_rows_carry_their_async_checks_under_the_row_name() {
2119 struct Row {
2120 password: String,
2121 }
2122 impl Validate for Row {
2123 fn rules(&self, v: &mut Validator) {
2124 v.field("password", &self.password).current_password();
2125 }
2126 }
2127 let mut v = Validator::new();
2128 v.nested(
2129 "rows",
2130 &[
2131 Row {
2132 password: "a".into(),
2133 },
2134 Row {
2135 password: "b".into(),
2136 },
2137 ],
2138 );
2139 let fields: Vec<_> = v.checks.iter().map(|c| c.field.as_str()).collect();
2140 assert_eq!(fields, ["rows.0.password", "rows.1.password"]);
2141 }
2142
2143 #[tokio::test]
2144 async fn checks_on_a_field_that_already_failed_are_skipped() {
2145 let db = crate::db::connect(&crate::Config::default()).await.unwrap();
2146 let mut v = Validator::new();
2147 v.field("email", &"not an email").email();
2150 v.field("email", &"not an email")
2151 .unique("no_such_table", "email")
2152 .current_password();
2153 v.field("password", &"guess")
2155 .current_password()
2156 .message("That isn't your password.");
2157 let errors = v.finish(&db).await.unwrap();
2158 assert_eq!(
2159 errors.first("email"),
2160 Some("The email must be a valid email address.")
2161 );
2162 assert_eq!(
2163 errors.iter().find(|(f, _)| *f == "email").unwrap().1.len(),
2164 1
2165 );
2166 assert_eq!(errors.first("password"), Some("That isn't your password."));
2167 }
2168}