pub(crate) mod extract;
mod key_values;
mod messages;
pub(crate) mod nested;
mod value;
use std::collections::BTreeMap;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value, json};
pub use extract::Valid;
pub use key_values::KeyValues;
pub(crate) use messages::{render, template_for};
pub use value::{FieldValue, Inspected};
use crate::Result;
use crate::db::{Db, DbValue, ToDbValue, quote};
use chrono::NaiveDateTime;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Errors(BTreeMap<String, Vec<String>>);
impl Errors {
pub fn new() -> Self {
Self::default()
}
pub fn add(&mut self, field: impl Into<String>, message: impl Into<String>) {
self.0.entry(field.into()).or_default().push(message.into());
}
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
pub fn has(&self, field: &str) -> bool {
self.0.contains_key(field)
}
pub fn first(&self, field: &str) -> Option<&str> {
self.0
.get(field)
.and_then(|m| m.first())
.map(String::as_str)
}
pub fn iter(&self) -> impl Iterator<Item = (&str, &[String])> {
self.0.iter().map(|(f, m)| (f.as_str(), m.as_slice()))
}
}
pub trait Validate {
const ERROR_BAG: Option<&'static str> = None;
fn rules(&self, v: &mut Validator);
fn prepare(&mut self) {}
fn authorize(
&self,
form: &FormContext<'_>,
) -> impl std::future::Future<Output = Result<bool>> + Send {
let _ = form;
std::future::ready(Ok(true))
}
fn after(
&self,
form: &FormContext<'_>,
errors: &mut Errors,
) -> impl std::future::Future<Output = Result> + Send {
let _ = (form, errors);
std::future::ready(Ok(()))
}
}
pub trait ValidateHooks {
fn prepare(&mut self) {}
fn authorize(
&self,
form: &FormContext<'_>,
) -> impl std::future::Future<Output = Result<bool>> + Send {
let _ = form;
std::future::ready(Ok(true))
}
fn after(
&self,
form: &FormContext<'_>,
errors: &mut Errors,
) -> impl std::future::Future<Output = Result> + Send {
let _ = (form, errors);
std::future::ready(Ok(()))
}
}
#[non_exhaustive]
pub struct FormContext<'a> {
pub state: &'a crate::AppState,
pub user: Option<&'a crate::auth::User>,
pub method: &'a axum::http::Method,
pub path: &'a str,
}
struct Pending {
field: String,
label: String,
table: String,
column: String,
value: DbValue,
ignore_id: Option<crate::db::DbValue>,
scope: Vec<ScopeCondition>,
unique: bool,
message: Option<String>,
}
struct AsyncCheck {
field: String,
label: String,
kind: AsyncKind,
message: Option<String>,
}
enum AsyncKind {
CurrentPassword(String),
Uncompromised(String),
}
enum ScopeCondition {
Eq(String, DbValue),
Null(String),
NotNull(String),
}
impl ScopeCondition {
fn column(&self) -> &str {
match self {
Self::Eq(column, _) | Self::Null(column) | Self::NotNull(column) => column,
}
}
}
async fn ensure_sqlite_column(db: &Db, table: &str, column: &str) -> Result {
let found: i64 = crate::db::sql("SELECT COUNT(*) FROM pragma_table_info(?) WHERE name = ?")
.bind(table)
.bind(column)
.scalar(db)
.await?;
if found == 0 {
return Err(anyhow::anyhow!(
"unique/exists rule: table `{table}` has no column `{column}`"
)
.into());
}
Ok(())
}
pub struct Validator {
texts: Option<crate::i18n::Texts>,
errors: Errors,
pending: Vec<Pending>,
checks: Vec<AsyncCheck>,
}
impl Default for Validator {
fn default() -> Self {
Self::new()
}
}
impl Validator {
pub fn new() -> Self {
Self {
texts: None,
errors: Errors::new(),
pending: Vec::new(),
checks: Vec::new(),
}
}
pub fn in_lang(self, lang: &crate::Lang) -> Self {
self.with_texts(lang.texts())
}
pub(crate) fn with_texts(mut self, texts: crate::i18n::Texts) -> Self {
self.texts = Some(texts);
self
}
fn template(&self, key: &str) -> std::borrow::Cow<'static, str> {
messages::template_for(self.texts.as_ref(), key)
}
fn translated_label(&self, name: &str) -> Option<String> {
self.texts.as_ref().and_then(|t| {
t.get(&format!("renox.validation.attributes.{name}"))
.cloned()
})
}
fn label_for(&self, name: &str) -> String {
nested::label(name, |key| self.translated_label(key))
}
pub fn each<T: FieldValue>(
&mut self,
name: &str,
items: &[T],
rules: impl for<'a> Fn(Field<'a>) -> Field<'a>,
) {
let base = self.label_for(name);
for (i, item) in items.iter().enumerate() {
let key = format!("{name}.{i}");
let label = format!("{base} #{}", i + 1);
rules(self.field(&key, item).label(&label));
}
}
pub fn nested<T: Validate>(&mut self, name: &str, items: &[T]) {
for (i, item) in items.iter().enumerate() {
let mut inner = Validator {
texts: self.texts.clone(),
errors: Errors::new(),
pending: Vec::new(),
checks: Vec::new(),
};
item.rules(&mut inner);
for (field, messages) in inner.errors.iter() {
for message in messages {
self.errors
.add(format!("{name}.{i}.{field}"), message.clone());
}
}
for mut pending in inner.pending {
pending.field = format!("{name}.{i}.{}", pending.field);
self.pending.push(pending);
}
for mut check in inner.checks {
check.field = format!("{name}.{i}.{}", check.field);
self.checks.push(check);
}
}
}
pub fn distinct<T: FieldValue>(&mut self, name: &str, items: &[T]) {
let base = self.label_for(name);
let template = self.template("distinct");
let mut seen: Vec<Inspected> = Vec::new();
for (i, item) in items.iter().enumerate() {
let value = match item.inspect() {
Inspected::Text(text) => Inspected::Text(text.trim().to_lowercase()),
other => other,
};
if value == Inspected::Missing {
continue;
}
if seen.contains(&value) {
let label = format!("{base} #{}", i + 1);
self.errors
.add(format!("{name}.{i}"), render(&template, &label, &[]));
} else {
seen.push(value);
}
}
}
pub fn field<'v>(&'v mut self, name: &str, value: &impl FieldValue) -> Field<'v> {
let translated = self.translated_label(name).is_some();
let mut field = Field {
translated,
label: self.label_for(name),
name: name.to_owned(),
value: value.inspect(),
db_value: value.db_value(),
failed: false,
last_pending: None,
last_check: None,
v: self,
};
if let Inspected::Number(n) = field.value
&& !n.is_finite()
{
field.fail("numeric", &[]);
}
field
}
pub fn error(&mut self, field: &str, message: impl Into<String>) {
self.errors.add(field, message);
}
pub async fn finish(self, db: &Db) -> Result<Errors> {
self.finish_with(db, None, None).await
}
pub async fn finish_for(
self,
state: &crate::AppState,
user: Option<&crate::auth::User>,
) -> Result<Errors> {
self.finish_with(&state.db, Some(state), user).await
}
async fn finish_with(
self,
db: &Db,
state: Option<&crate::AppState>,
user: Option<&crate::auth::User>,
) -> Result<Errors> {
let mut errors = self.errors;
for check in self.pending {
if errors.has(&check.field) {
continue;
}
let dialect = db.dialect();
if dialect == crate::db::Dialect::Sqlite {
ensure_sqlite_column(db, &check.table, &check.column).await?;
for condition in &check.scope {
ensure_sqlite_column(db, &check.table, condition.column()).await?;
}
}
let column = match (&check.value, dialect) {
(DbValue::Text(_), crate::db::Dialect::Postgres) => {
format!("CAST({} AS TEXT)", quote(&check.column))
}
_ => quote(&check.column),
};
let mut sql = format!(
"SELECT EXISTS(SELECT 1 FROM {} WHERE {column} = ?",
quote(&check.table),
);
if check.ignore_id.is_some() {
sql.push_str(" AND \"id\" != ?");
}
let mut scope_values = Vec::new();
for condition in check.scope {
match condition {
ScopeCondition::Eq(column, value) => {
sql.push_str(&format!(" AND {} = ?", quote(&column)));
scope_values.push(value);
}
ScopeCondition::Null(column) => {
sql.push_str(&format!(" AND {} IS NULL", quote(&column)));
}
ScopeCondition::NotNull(column) => {
sql.push_str(&format!(" AND {} IS NOT NULL", quote(&column)));
}
}
}
sql.push(')');
let mut query = crate::db::sql(sql).bind(check.value);
if let Some(id) = check.ignore_id {
query = query.bind(id);
}
let query = query.bind_all(scope_values);
let found: bool = query.scalar(db).await?;
if found == check.unique {
let key = if check.unique { "unique" } else { "exists" };
let message = check.message.unwrap_or_else(|| {
render(
&messages::template_for(self.texts.as_ref(), key),
&check.label,
&[],
)
});
errors.add(check.field, message);
}
}
for check in self.checks {
if errors.has(&check.field) {
continue;
}
let key = match &check.kind {
AsyncKind::CurrentPassword(password) => match user {
Some(user) if user.check_password(password).await => continue,
_ => "current_password",
},
AsyncKind::Uncompromised(password) => match state {
Some(state) if breached(state, password).await => "password.uncompromised",
_ => continue,
},
};
let message = check.message.unwrap_or_else(|| {
render(
&messages::template_for(self.texts.as_ref(), key),
&check.label,
&[],
)
});
errors.add(check.field, message);
}
Ok(errors)
}
pub(crate) fn rules_with_texts(data: &impl Validate, texts: crate::i18n::Texts) -> Self {
let mut validator = Self::new().with_texts(texts);
data.rules(&mut validator);
validator
}
pub fn rules_of(data: &impl Validate) -> Self {
let mut validator = Self::new();
data.rules(&mut validator);
validator
}
}
pub struct Field<'v> {
v: &'v mut Validator,
name: String,
label: String,
translated: bool,
value: Inspected,
db_value: DbValue,
failed: bool,
last_pending: Option<usize>,
last_check: Option<usize>,
}
fn number(n: f64) -> String {
if n.fract() == 0.0 && n.abs() < 1e15 {
format!("{}", n as i64)
} else {
n.to_string()
}
}
impl Field<'_> {
pub fn label(mut self, label: &str) -> Self {
self.label = label.to_owned();
self
}
pub(crate) fn fallback_label(mut self, label: &str) -> Self {
if !self.translated {
self.label = label.to_owned();
}
self
}
fn fail(&mut self, key: &str, params: &[(&str, String)]) {
if !self.failed {
let message = render(&self.v.template(key), &self.label, params);
self.v.errors.add(&self.name, message);
self.failed = true;
self.last_pending = None;
self.last_check = None;
}
}
fn check(&mut self, kind: AsyncKind) {
self.v.checks.push(AsyncCheck {
field: self.name.clone(),
label: self.label.clone(),
kind,
message: None,
});
self.last_pending = None;
self.last_check = Some(self.v.checks.len() - 1);
}
fn present(&self) -> bool {
!self.failed && self.value != Inspected::Missing
}
pub fn message(self, message: impl Into<String>) -> Self {
let message = message.into();
if let Some(i) = self.last_pending {
self.v.pending[i].message = Some(message);
} else if let Some(i) = self.last_check {
self.v.checks[i].message = Some(message);
} else if self.failed
&& let Some(last) = self
.v
.errors
.0
.get_mut(&self.name)
.and_then(|messages| messages.last_mut())
{
*last = message;
}
self
}
pub fn required(mut self) -> Self {
if !self.failed && self.value == Inspected::Missing {
self.fail("required", &[]);
}
self
}
fn size_rule(
mut self,
kind: &str,
ok: impl Fn(f64) -> bool,
params: &[(&str, String)],
) -> Self {
if !self.present() {
return self;
}
let (size, suffix) = match &self.value {
Inspected::Text(text) => (text.chars().count() as f64, "string"),
Inspected::Number(n) => (*n, "numeric"),
Inspected::Items(n) => (*n as f64, "array"),
Inspected::File { kilobytes, .. } => (*kilobytes, "file"),
_ => return self,
};
if !ok(size) {
self.fail(&format!("{kind}.{suffix}"), params);
}
self
}
pub fn min(self, min: impl Into<f64>) -> Self {
let min = min.into();
self.size_rule("min", |s| s >= min, &[("min", number(min))])
}
pub fn max(self, max: impl Into<f64>) -> Self {
let max = max.into();
self.size_rule("max", |s| s <= max, &[("max", number(max))])
}
pub fn between(self, min: impl Into<f64>, max: impl Into<f64>) -> Self {
let (min, max) = (min.into(), max.into());
self.size_rule(
"between",
|s| s >= min && s <= max,
&[("min", number(min)), ("max", number(max))],
)
}
pub fn email(mut self) -> Self {
if let (true, Inspected::Text(text)) = (self.present(), &self.value)
&& !is_email(text)
{
self.fail("email", &[]);
}
self
}
pub fn url(mut self) -> Self {
if let (true, Inspected::Text(text)) = (self.present(), &self.value)
&& !is_url(text)
{
self.fail("url", &[]);
}
self
}
pub fn image(mut self) -> Self {
if let (true, Inspected::File { image, .. }) = (self.present(), &self.value)
&& !*image
{
self.fail("image", &[]);
}
self
}
pub fn mimes(mut self, extensions: &[&str]) -> Self {
if let (true, Inspected::File { extension, .. }) = (self.present(), &self.value) {
let normalise = |e: &str| match e.to_ascii_lowercase().as_str() {
"jpeg" => "jpg".to_owned(),
other => other.to_owned(),
};
let ok = extensions
.iter()
.any(|e| normalise(e) == normalise(extension));
if !ok {
self.fail("mimes", &[("values", extensions.join(", "))]);
}
}
self
}
pub fn one_of<V: FieldValue>(mut self, allowed: &[V]) -> Self {
if self.present() && !allowed.iter().any(|a| a.inspect() == self.value) {
self.fail("in", &[]);
}
self
}
pub fn confirmed(mut self, confirmation: &impl FieldValue) -> Self {
if self.present() && confirmation.inspect() != self.value {
self.fail("confirmed", &[]);
}
self
}
pub fn accepted(mut self) -> Self {
if !self.failed && self.value != Inspected::Bool(true) {
self.fail("accepted", &[]);
}
self
}
pub fn rule(mut self, valid: bool, message: impl Into<String>) -> Self {
if !self.failed && !valid {
self.v.errors.add(&self.name, message);
self.failed = true;
self.last_pending = None;
self.last_check = None;
}
self
}
pub fn matches(mut self, pattern: &str) -> Self {
if let (true, Inspected::Text(text)) = (self.present(), &self.value) {
let ok = match cached_regex(pattern) {
Ok(regex) => regex.is_match(text),
Err(err) => {
tracing::error!(pattern, error = %err, "invalid pattern in a `matches` rule");
false
}
};
if !ok {
self.fail("regex", &[]);
}
}
self
}
pub fn digits(mut self, n: usize) -> Self {
if self.present() && digit_count(&self.value) != Some(n) {
self.fail("digits", &[("digits", n.to_string())]);
}
self
}
pub fn digits_between(mut self, min: usize, max: usize) -> Self {
if self.present() && !digit_count(&self.value).is_some_and(|n| n >= min && n <= max) {
self.fail(
"digits_between",
&[("min", min.to_string()), ("max", max.to_string())],
);
}
self
}
pub fn date(mut self) -> Self {
if self.present() && self.as_date().is_none() {
self.fail("date", &[]);
}
self
}
fn date_rule(
mut self,
key: &str,
limit: NaiveDateTime,
ok: impl Fn(NaiveDateTime, NaiveDateTime) -> bool,
) -> Self {
if !self.present() {
return self;
}
match self.as_date() {
None => self.fail("date", &[]),
Some(date) if !ok(date, limit) => {
let shown = if limit.time() == chrono::NaiveTime::MIN {
limit.date().to_string()
} else {
limit.format("%Y-%m-%d %H:%M").to_string()
};
self.fail(key, &[("date", shown)]);
}
Some(_) => {}
}
self
}
pub fn before(self, limit: impl FieldValue) -> Self {
match limit_date(&limit) {
Some(limit) => self.date_rule("before", limit, |d, l| d < l),
None => self,
}
}
pub fn before_or_equal(self, limit: impl FieldValue) -> Self {
match limit_date(&limit) {
Some(limit) => self.date_rule("before_or_equal", limit, |d, l| d <= l),
None => self,
}
}
pub fn after(self, limit: impl FieldValue) -> Self {
match limit_date(&limit) {
Some(limit) => self.date_rule("after", limit, |d, l| d > l),
None => self,
}
}
pub fn after_or_equal(self, limit: impl FieldValue) -> Self {
match limit_date(&limit) {
Some(limit) => self.date_rule("after_or_equal", limit, |d, l| d >= l),
None => self,
}
}
fn as_date(&self) -> Option<NaiveDateTime> {
match &self.value {
Inspected::Date(date) => Some(*date),
Inspected::Text(text) => parse_date(text.trim()),
_ => None,
}
}
pub fn none_of<V: FieldValue>(mut self, refused: &[V]) -> Self {
if self.present() && refused.iter().any(|r| r.inspect() == self.value) {
self.fail("not_in", &[]);
}
self
}
fn text_rule(
mut self,
key: &str,
ok: impl Fn(&str) -> bool,
params: &[(&str, String)],
) -> Self {
if let (true, Inspected::Text(text)) = (self.present(), &self.value)
&& !ok(text)
{
self.fail(key, params);
}
self
}
pub fn alpha(self) -> Self {
self.text_rule("alpha", |t| t.chars().all(char::is_alphabetic), &[])
}
pub fn alpha_num(self) -> Self {
self.text_rule("alpha_num", |t| t.chars().all(char::is_alphanumeric), &[])
}
pub fn alpha_dash(self) -> Self {
self.text_rule(
"alpha_dash",
|t| {
t.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
},
&[],
)
}
pub fn lowercase(self) -> Self {
self.text_rule("lowercase", |t| !t.chars().any(char::is_uppercase), &[])
}
pub fn uppercase(self) -> Self {
self.text_rule("uppercase", |t| !t.chars().any(char::is_lowercase), &[])
}
pub fn starts_with(self, prefixes: &[&str]) -> Self {
let values = prefixes.join(", ");
self.text_rule(
"starts_with",
|t| prefixes.iter().any(|p| t.starts_with(p)),
&[("values", values)],
)
}
pub fn ends_with(self, suffixes: &[&str]) -> Self {
let values = suffixes.join(", ");
self.text_rule(
"ends_with",
|t| suffixes.iter().any(|s| t.ends_with(s)),
&[("values", values)],
)
}
pub fn uuid(self) -> Self {
self.text_rule("uuid", is_uuid, &[])
}
pub fn ip(self) -> Self {
self.text_rule("ip", |t| t.trim().parse::<std::net::IpAddr>().is_ok(), &[])
}
pub fn size(self, size: impl Into<f64>) -> Self {
let size = size.into();
self.size_rule("size", |s| s == size, &[("size", number(size))])
}
pub fn required_without(self, other: &impl FieldValue) -> Self {
let missing = other.inspect() == Inspected::Missing;
self.required_if(missing)
}
pub fn prohibited_if(mut self, condition: bool) -> Self {
if condition && !self.failed && self.value != Inspected::Missing {
self.fail("prohibited", &[]);
}
self
}
pub fn required_if(self, condition: bool) -> Self {
if condition { self.required() } else { self }
}
pub fn required_unless(self, condition: bool) -> Self {
self.required_if(!condition)
}
pub fn required_with(self, other: &impl FieldValue) -> Self {
let given = other.inspect() != Inspected::Missing;
self.required_if(given)
}
pub fn same(mut self, other: &str, value: &impl FieldValue) -> Self {
if self.present() && value.inspect() != self.value {
let other = self.v.label_for(other);
self.fail("same", &[("other", other)]);
}
self
}
pub fn different(mut self, other: &str, value: &impl FieldValue) -> Self {
if self.present() && value.inspect() == self.value {
let other = self.v.label_for(other);
self.fail("different", &[("other", other)]);
}
self
}
fn compare_rule(
mut self,
key: &str,
other: &str,
value: &impl FieldValue,
ok: fn(std::cmp::Ordering) -> bool,
) -> Self {
if !self.present() {
return self;
}
let other_value = value.inspect();
if other_value == Inspected::Missing {
return self;
}
let both_numeric = matches!(
(&self.value, &other_value),
(Inspected::Text(_), Inspected::Text(_))
) && numeric_value(&self.value).is_some()
&& numeric_value(&other_value).is_some();
let measured = |value: &Inspected| -> Option<(f64, &'static str)> {
match value {
Inspected::Text(_) if both_numeric => Some((numeric_value(value)?, "numeric")),
Inspected::Number(n) => Some((*n, "numeric")),
Inspected::Text(text) => Some((text.chars().count() as f64, "string")),
Inspected::Items(n) => Some((*n as f64, "array")),
Inspected::File { kilobytes, .. } => Some((*kilobytes, "file")),
_ => None,
}
};
let label = self.v.label_for(other);
let as_date = |value: &Inspected| match value {
Inspected::Date(date) => Some(*date),
Inspected::Text(text) => parse_date(text.trim()),
_ => None,
};
let dates = as_date(&self.value).zip(as_date(&other_value));
if let Some((a, b)) = dates {
if !ok(a.cmp(&b)) {
self.fail(&format!("{key}.date"), &[("other", label)]);
}
return self;
}
match (measured(&self.value), measured(&other_value)) {
(Some((a, kind)), Some((b, other_kind))) if kind == other_kind => {
if !a.partial_cmp(&b).is_some_and(ok) {
self.fail(&format!("{key}.{kind}"), &[("other", label)]);
}
}
_ => self.fail(&format!("{key}.numeric"), &[("other", label)]),
}
self
}
pub fn gt(self, other: &str, value: &impl FieldValue) -> Self {
self.compare_rule("gt", other, value, |o| o.is_gt())
}
pub fn gte(self, other: &str, value: &impl FieldValue) -> Self {
self.compare_rule("gte", other, value, |o| o.is_ge())
}
pub fn lt(self, other: &str, value: &impl FieldValue) -> Self {
self.compare_rule("lt", other, value, |o| o.is_lt())
}
pub fn lte(self, other: &str, value: &impl FieldValue) -> Self {
self.compare_rule("lte", other, value, |o| o.is_le())
}
pub fn decimal(mut self, min: usize, max: usize) -> Self {
if !self.present() {
return self;
}
let text = match &self.value {
Inspected::Text(text) => text.trim().to_owned(),
Inspected::Number(n) => n.to_string(),
_ => String::new(),
};
let places = decimal_places(&text);
if !places.is_some_and(|p| p >= min && p <= max) {
let places = if min == max {
min.to_string()
} else {
format!("{min}-{max}")
};
self.fail("decimal", &[("decimal", places)]);
}
self
}
pub fn dimensions(mut self, limits: &Dimensions) -> Self {
if let (true, Inspected::File { dimensions, .. }) = (self.present(), &self.value) {
let ok = dimensions.is_some_and(|(w, h)| limits.allows(w, h));
if !ok {
self.fail("dimensions", &[]);
}
}
self
}
pub fn prohibited(self) -> Self {
self.prohibited_if(true)
}
pub fn prohibited_unless(self, condition: bool) -> Self {
self.prohibited_if(!condition)
}
pub fn prohibits(mut self, other: &str, value: &impl FieldValue) -> Self {
if self.present() && value.inspect() != Inspected::Missing {
let other = self.v.label_for(other);
self.fail("prohibits", &[("other", other)]);
}
self
}
pub fn required_with_all(self, others: &[&dyn FieldValue]) -> Self {
let all = others.iter().all(|o| o.inspect() != Inspected::Missing);
self.required_if(all)
}
pub fn required_without_all(self, others: &[&dyn FieldValue]) -> Self {
let none = others.iter().all(|o| o.inspect() == Inspected::Missing);
self.required_if(none)
}
pub fn min_digits(mut self, min: usize) -> Self {
if self.present() && !integer_digits(&self.value).is_some_and(|n| n >= min) {
self.fail("min_digits", &[("min", min.to_string())]);
}
self
}
pub fn max_digits(mut self, max: usize) -> Self {
if self.present() && !integer_digits(&self.value).is_some_and(|n| n <= max) {
self.fail("max_digits", &[("max", max.to_string())]);
}
self
}
pub fn multiple_of(mut self, step: impl Into<f64>) -> Self {
let step = step.into();
if !self.present() {
return self;
}
let ok = numeric_value(&self.value).is_some_and(|n| {
let ratio = n / step;
step != 0.0 && (ratio - ratio.round()).abs() < 1e-9
});
if !ok {
self.fail("multiple_of", &[("value", number(step))]);
}
self
}
pub fn numeric(mut self) -> Self {
if self.present() && numeric_value(&self.value).is_none() {
self.fail("numeric", &[]);
}
self
}
pub fn integer(mut self) -> Self {
if self.present() && !numeric_value(&self.value).is_some_and(|n| n.fract() == 0.0) {
self.fail("integer", &[]);
}
self
}
pub fn json(self) -> Self {
self.text_rule(
"json",
|t| serde_json::from_str::<serde_json::Value>(t).is_ok(),
&[],
)
}
pub fn ulid(self) -> Self {
self.text_rule("ulid", |t| t.trim().parse::<crate::db::Ulid>().is_ok(), &[])
}
pub fn timezone(self) -> Self {
self.text_rule(
"timezone",
|t| t.trim().parse::<chrono_tz::Tz>().is_ok(),
&[],
)
}
pub fn mac_address(self) -> Self {
self.text_rule("mac_address", is_mac_address, &[])
}
pub fn ascii(self) -> Self {
self.text_rule("ascii", |t| t.is_ascii(), &[])
}
pub fn hex_color(self) -> Self {
self.text_rule(
"hex_color",
|t| {
t.strip_prefix('#').is_some_and(|hex| {
matches!(hex.len(), 3 | 4 | 6 | 8) && hex.chars().all(|c| c.is_ascii_hexdigit())
})
},
&[],
)
}
pub fn doesnt_start_with(self, prefixes: &[&str]) -> Self {
let values = prefixes.join(", ");
self.text_rule(
"doesnt_start_with",
|t| !prefixes.iter().any(|p| t.starts_with(p)),
&[("values", values)],
)
}
pub fn doesnt_end_with(self, suffixes: &[&str]) -> Self {
let values = suffixes.join(", ");
self.text_rule(
"doesnt_end_with",
|t| !suffixes.iter().any(|s| t.ends_with(s)),
&[("values", values)],
)
}
pub fn not_matches(mut self, pattern: &str) -> Self {
if let (true, Inspected::Text(text)) = (self.present(), &self.value) {
let matched = match cached_regex(pattern) {
Ok(regex) => regex.is_match(text),
Err(err) => {
tracing::error!(pattern, error = %err, "invalid pattern in a `not_matches` rule");
true
}
};
if matched {
self.fail("not_regex", &[]);
}
}
self
}
pub fn accepted_if(self, condition: bool) -> Self {
if condition { self.accepted() } else { self }
}
pub fn declined(mut self) -> Self {
let declined = match &self.value {
Inspected::Bool(b) => !b,
Inspected::Number(n) => *n == 0.0,
Inspected::Text(text) => {
matches!(
text.trim().to_ascii_lowercase().as_str(),
"no" | "off" | "0" | "false"
)
}
_ => false,
};
if !self.failed && !declined {
self.fail("declined", &[]);
}
self
}
pub fn declined_if(self, condition: bool) -> Self {
if condition { self.declined() } else { self }
}
pub fn password(mut self, policy: &Password) -> Self {
if let (true, Inspected::Text(text)) = (self.present(), &self.value) {
match policy.broken(text) {
Some((key, params)) => self.fail(key, ¶ms),
None if policy.uncompromised => {
let text = text.clone();
self.check(AsyncKind::Uncompromised(text));
}
None => {}
}
}
self
}
pub fn current_password(mut self) -> Self {
if let (true, Inspected::Text(text)) = (self.present(), &self.value) {
let text = text.clone();
self.check(AsyncKind::CurrentPassword(text));
}
self
}
pub fn apply(mut self, rule: &impl Rule) -> Self {
if !self.present() {
return self;
}
if let Err(message) = rule.check(&self.value) {
let message = render(&message, &self.label, &[]);
self.v.errors.add(&self.name, message);
self.failed = true;
self.last_pending = None;
self.last_check = None;
}
self
}
fn database(mut self, table: &str, column: &str, unique: bool) -> Self {
if self.present() {
self.v.pending.push(Pending {
field: self.name.clone(),
label: self.label.clone(),
table: table.to_owned(),
column: column.to_owned(),
value: self.db_value.clone(),
ignore_id: None,
scope: Vec::new(),
unique,
message: None,
});
self.last_pending = Some(self.v.pending.len() - 1);
self.last_check = None;
}
self
}
pub fn unique(self, table: &str, column: &str) -> Self {
self.database(table, column, true)
}
pub fn ignore(self, id: impl crate::db::ToDbValue) -> Self {
if let Some(i) = self.last_pending {
self.v.pending[i].ignore_id = Some(id.to_db_value());
}
self
}
pub fn exists(self, table: &str, column: &str) -> Self {
self.database(table, column, false)
}
pub fn where_eq(self, column: &str, value: impl ToDbValue) -> Self {
self.scope(ScopeCondition::Eq(column.to_owned(), value.to_db_value()))
}
pub fn where_null(self, column: &str) -> Self {
self.scope(ScopeCondition::Null(column.to_owned()))
}
pub fn where_not_null(self, column: &str) -> Self {
self.scope(ScopeCondition::NotNull(column.to_owned()))
}
fn scope(self, condition: ScopeCondition) -> Self {
if let Some(i) = self.last_pending {
self.v.pending[i].scope.push(condition);
}
self
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Password {
min: usize,
letters: bool,
mixed_case: bool,
numbers: bool,
symbols: bool,
uncompromised: bool,
}
impl Password {
pub fn min(min: usize) -> Self {
Self {
min,
letters: false,
mixed_case: false,
numbers: false,
symbols: false,
uncompromised: false,
}
}
pub fn uncompromised(mut self) -> Self {
self.uncompromised = true;
self
}
pub fn letters(mut self) -> Self {
self.letters = true;
self
}
pub fn mixed_case(mut self) -> Self {
self.mixed_case = true;
self
}
pub fn numbers(mut self) -> Self {
self.numbers = true;
self
}
pub fn symbols(mut self) -> Self {
self.symbols = true;
self
}
fn broken(&self, password: &str) -> Option<(&'static str, Vec<(&'static str, String)>)> {
if password.chars().count() < self.min {
return Some(("min.string", vec![("min", self.min.to_string())]));
}
let has = |test: fn(&char) -> bool| password.chars().any(|c| test(&c));
if self.letters && !has(|c| c.is_alphabetic()) {
return Some(("password.letters", Vec::new()));
}
if self.mixed_case && !(has(|c| c.is_uppercase()) && has(|c| c.is_lowercase())) {
return Some(("password.mixed", Vec::new()));
}
if self.numbers && !has(|c| c.is_numeric()) {
return Some(("password.numbers", Vec::new()));
}
if self.symbols && !has(|c| !c.is_alphanumeric() && !c.is_whitespace()) {
return Some(("password.symbols", Vec::new()));
}
None
}
}
impl Default for Password {
fn default() -> Self {
Self::min(8)
}
}
pub trait Rule {
fn check(&self, value: &Inspected) -> std::result::Result<(), String>;
}
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Dimensions {
min_width: Option<u32>,
max_width: Option<u32>,
min_height: Option<u32>,
max_height: Option<u32>,
width: Option<u32>,
height: Option<u32>,
ratio: Option<(u32, u32)>,
}
impl Dimensions {
pub fn new() -> Self {
Self::default()
}
pub fn min_width(mut self, px: u32) -> Self {
self.min_width = Some(px);
self
}
pub fn max_width(mut self, px: u32) -> Self {
self.max_width = Some(px);
self
}
pub fn min_height(mut self, px: u32) -> Self {
self.min_height = Some(px);
self
}
pub fn max_height(mut self, px: u32) -> Self {
self.max_height = Some(px);
self
}
pub fn width(mut self, px: u32) -> Self {
self.width = Some(px);
self
}
pub fn height(mut self, px: u32) -> Self {
self.height = Some(px);
self
}
pub fn ratio(mut self, width: u32, height: u32) -> Self {
self.ratio = Some((width, height));
self
}
fn allows(&self, w: u32, h: u32) -> bool {
let at_least = |limit: Option<u32>, v: u32| limit.is_none_or(|l| v >= l);
let at_most = |limit: Option<u32>, v: u32| limit.is_none_or(|l| v <= l);
let exactly = |limit: Option<u32>, v: u32| limit.is_none_or(|l| v == l);
let ratio = self.ratio.is_none_or(|(rw, rh)| {
rw > 0 && (h as f64 - w as f64 * rh as f64 / rw as f64).abs() <= 1.0
});
at_least(self.min_width, w)
&& at_most(self.max_width, w)
&& at_least(self.min_height, h)
&& at_most(self.max_height, h)
&& exactly(self.width, w)
&& exactly(self.height, h)
&& ratio
}
}
const PWNED_RANGE: &str = "https://api.pwnedpasswords.com/range/";
async fn breached(state: &crate::AppState, password: &str) -> bool {
use sha1::{Digest, Sha1};
let hash: String = Sha1::digest(password.as_bytes())
.iter()
.map(|b| format!("{b:02X}"))
.collect();
let (prefix, suffix) = hash.split_at(5);
let response = state
.http
.get(format!("{PWNED_RANGE}{prefix}"))
.header("Add-Padding", "true")
.timeout(std::time::Duration::from_secs(5))
.send()
.await;
match response {
Ok(response) if response.status().is_success() => response.text().lines().any(|line| {
line.split_once(':').is_some_and(|(candidate, count)| {
candidate.trim().eq_ignore_ascii_case(suffix)
&& count.trim().parse::<u64>().is_ok_and(|n| n > 0)
})
}),
Ok(response) => {
tracing::warn!(status = %response.status(), "the password breach check answered with an error; allowing the password");
false
}
Err(err) => {
tracing::warn!(error = ?err, "the password breach check failed; allowing the password");
false
}
}
}
fn decimal_places(text: &str) -> Option<usize> {
let digits = text.strip_prefix(['-', '+']).unwrap_or(text);
let (whole, fraction) = digits.split_once('.').unwrap_or((digits, ""));
let all_digits = |s: &str| s.chars().all(|c| c.is_ascii_digit());
(!whole.is_empty()
&& all_digits(whole)
&& all_digits(fraction)
&& !(digits.contains('.') && fraction.is_empty()))
.then_some(fraction.len())
}
fn limit_date(limit: &impl FieldValue) -> Option<NaiveDateTime> {
match limit.inspect() {
Inspected::Date(date) => Some(date),
Inspected::Text(text) => parse_date(text.trim()),
_ => None,
}
}
fn numeric_value(value: &Inspected) -> Option<f64> {
match value {
Inspected::Number(n) => Some(*n),
Inspected::Text(text) => {
let text = text.trim();
decimal_places(text)?;
text.parse::<f64>().ok().filter(|n| n.is_finite())
}
_ => None,
}
}
fn integer_digits(value: &Inspected) -> Option<usize> {
let n = numeric_value(value)?;
(n.fract() == 0.0).then(|| format!("{}", n.abs() as u64).len())
}
fn is_mac_address(text: &str) -> bool {
let text = text.trim();
let hex = |s: &str, n: usize| s.len() == n && s.chars().all(|c| c.is_ascii_hexdigit());
for separator in [':', '-'] {
let parts: Vec<&str> = text.split(separator).collect();
if parts.len() == 6 && parts.iter().all(|p| hex(p, 2)) {
return true;
}
}
let parts: Vec<&str> = text.split('.').collect();
parts.len() == 3 && parts.iter().all(|p| hex(p, 4))
}
fn is_uuid(text: &str) -> bool {
let groups: Vec<&str> = text.trim().split('-').collect();
groups.len() == 5
&& groups
.iter()
.zip([8, 4, 4, 4, 12])
.all(|(g, n)| g.len() == n && g.chars().all(|c| c.is_ascii_hexdigit()))
}
fn cached_regex(pattern: &str) -> std::result::Result<regex::Regex, regex::Error> {
static CACHE: std::sync::LazyLock<
std::sync::Mutex<std::collections::HashMap<String, regex::Regex>>,
> = std::sync::LazyLock::new(Default::default);
let mut cache = CACHE.lock().unwrap_or_else(|e| e.into_inner());
if let Some(regex) = cache.get(pattern) {
return Ok(regex.clone());
}
let regex = regex::Regex::new(pattern)?;
if cache.len() < 1000 {
cache.insert(pattern.to_owned(), regex.clone());
}
Ok(regex)
}
fn digit_count(value: &Inspected) -> Option<usize> {
let text = match value {
Inspected::Text(text) => text.trim().to_owned(),
Inspected::Number(n) if n.fract() == 0.0 && *n >= 0.0 => format!("{}", *n as u64),
_ => return None,
};
(!text.is_empty() && text.chars().all(|c| c.is_ascii_digit())).then_some(text.len())
}
fn parse_date(text: &str) -> Option<NaiveDateTime> {
if let Ok(date) = chrono::NaiveDate::parse_from_str(text, "%Y-%m-%d") {
return Some(date.and_time(chrono::NaiveTime::MIN));
}
for format in [
"%Y-%m-%dT%H:%M",
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%d %H:%M",
"%Y-%m-%d %H:%M:%S",
] {
if let Ok(date) = NaiveDateTime::parse_from_str(text, format) {
return Some(date);
}
}
chrono::DateTime::parse_from_rfc3339(text)
.ok()
.map(|d| d.naive_utc())
}
fn is_email(text: &str) -> bool {
let Some((local, domain)) = text.split_once('@') else {
return false;
};
!local.is_empty()
&& !text.chars().any(char::is_whitespace)
&& !domain.contains('@')
&& domain.contains('.')
&& domain.split('.').all(|part| !part.is_empty())
}
fn is_url(text: &str) -> bool {
let rest = text
.strip_prefix("https://")
.or_else(|| text.strip_prefix("http://"));
matches!(rest, Some(rest) if !rest.is_empty()
&& !rest.starts_with('/')
&& !text.chars().any(char::is_whitespace))
}
const DONT_FLASH: &[&str] = &[
"password",
"password_confirmation",
"current_password",
"_token",
];
#[derive(Debug, Clone)]
pub struct ValidationError {
pub errors: Errors,
pub input: Map<String, Value>,
bag: Option<String>,
}
impl ValidationError {
pub fn new(errors: Errors) -> Self {
Self {
errors,
input: Map::new(),
bag: None,
}
}
pub fn in_bag(mut self, bag: impl Into<String>) -> Self {
self.bag = Some(bag.into());
self
}
pub fn bag(&self) -> Option<&str> {
self.bag.as_deref()
}
pub fn with_input(mut self, input: &impl Serialize) -> Self {
if let Ok(Value::Object(map)) = serde_json::to_value(input) {
self.input = map;
}
self.input
.retain(|key, _| !DONT_FLASH.contains(&key.as_str()));
self
}
pub(crate) fn with_input_map(mut self, input: Map<String, Value>) -> Self {
self.input = input;
self.input
.retain(|key, _| !DONT_FLASH.contains(&key.as_str()));
self
}
}
impl From<Errors> for ValidationError {
fn from(errors: Errors) -> Self {
Self::new(errors)
}
}
impl IntoResponse for ValidationError {
fn into_response(self) -> Response {
let message = self
.errors
.iter()
.next()
.and_then(|(_, m)| m.first().cloned())
.unwrap_or_default();
let body = json!({ "message": message, "errors": self.errors });
let mut res = (StatusCode::UNPROCESSABLE_ENTITY, axum::Json(body)).into_response();
res.extensions_mut().insert(self);
res
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn checks_emails_and_urls() {
assert!(is_email("alex@example.com"));
assert!(!is_email("alex@localhost"));
assert!(!is_email("alex example@x.com"));
assert!(!is_email("@x.com"));
assert!(is_url("https://renox.dev/docs"));
assert!(!is_url("ftp://renox.dev"));
assert!(!is_url("https://"));
}
#[test]
fn formats_numbers_without_trailing_zeros() {
assert_eq!(number(3.0), "3");
assert_eq!(number(2.5), "2.5");
}
struct As(Inspected);
impl FieldValue for As {
fn inspect(&self) -> Inspected {
self.0.clone()
}
fn db_value(&self) -> DbValue {
DbValue::Null
}
}
struct Never;
impl Rule for Never {
fn check(&self, _: &Inspected) -> std::result::Result<(), String> {
Err("never".into())
}
}
fn file(kilobytes: f64, dimensions: Option<(u32, u32)>) -> As {
As(Inspected::File {
kilobytes,
extension: "png".into(),
image: true,
dimensions,
})
}
#[test]
fn rules_pass_or_step_aside_for_values_they_dont_judge() {
let mut v = Validator::default();
let photo = file(120.0, Some((800, 600)));
v.field("photo", &photo)
.mimes(&["PNG", "jpeg"])
.dimensions(&Dimensions::new().min_width(400).max_height(600));
v.field("code", &"AB1234")
.matches(r"^[A-Z]{2}\d{4}$")
.not_matches(r"^\d+$");
v.field("secret", &"long enough password")
.password(&Password::min(8));
let missing: Option<String> = None;
v.field("note", &missing)
.decimal(2, 2)
.apply(&Never)
.gt("other", &"5");
v.field("price", &"12").gt("budget", &missing);
v.field("day", &"2026-01-01")
.before_or_equal(5)
.after(true)
.after_or_equal(2.5);
assert!(v.errors.is_empty(), "{:?}", v.errors);
}
#[test]
fn rules_fail_values_of_the_wrong_kind() {
let mut v = Validator::default();
v.field("day", &7).before("2026-01-01");
v.field("agree", &true).digits(3);
v.field("flag", &true).decimal(2, 2);
v.field("on", &true).numeric();
v.field("photo", &file(10.0, None))
.gt("thumb", &file(20.0, None));
v.field("yes", &true).lt("no", &false);
assert_eq!(v.errors.first("day"), Some("The day is not a valid date."));
assert_eq!(v.errors.first("agree"), Some("The agree must be 3 digits."));
assert_eq!(
v.errors.first("flag"),
Some("The flag must have 2 decimal places.")
);
assert_eq!(v.errors.first("on"), Some("The on must be a number."));
assert_eq!(
v.errors.first("photo"),
Some("The photo must be larger than thumb.")
);
assert!(v.errors.has("yes"), "{:?}", v.errors);
let mut v = Validator::new();
v.field("photo", &file(1.0, Some((2000, 10))))
.dimensions(&Dimensions::new().max_width(100));
assert!(v.errors.has("photo"));
}
#[test]
fn nested_rows_carry_their_async_checks_under_the_row_name() {
struct Row {
password: String,
}
impl Validate for Row {
fn rules(&self, v: &mut Validator) {
v.field("password", &self.password).current_password();
}
}
let mut v = Validator::new();
v.nested(
"rows",
&[
Row {
password: "a".into(),
},
Row {
password: "b".into(),
},
],
);
let fields: Vec<_> = v.checks.iter().map(|c| c.field.as_str()).collect();
assert_eq!(fields, ["rows.0.password", "rows.1.password"]);
}
#[tokio::test]
async fn checks_on_a_field_that_already_failed_are_skipped() {
let db = crate::db::connect(&crate::Config::default()).await.unwrap();
let mut v = Validator::new();
v.field("email", &"not an email").email();
v.field("email", &"not an email")
.unique("no_such_table", "email")
.current_password();
v.field("password", &"guess")
.current_password()
.message("That isn't your password.");
let errors = v.finish(&db).await.unwrap();
assert_eq!(
errors.first("email"),
Some("The email must be a valid email address.")
);
assert_eq!(
errors.iter().find(|(f, _)| *f == "email").unwrap().1.len(),
1
);
assert_eq!(errors.first("password"), Some("That isn't your password."));
}
}