From bd662b5343821b4acba690045b9392827f1235d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABlle=20Huisman?= Date: Fri, 5 Dec 2025 13:23:39 +0100 Subject: [PATCH] feat: add url validation --- Cargo.lock | 279 ++++++++++++++++++ example/src/main.rs | 4 + .../fortifier-macros/src/validate/field.rs | 13 +- packages/fortifier-macros/src/validations.rs | 2 + .../fortifier-macros/src/validations/url.rs | 18 ++ packages/fortifier/Cargo.toml | 4 +- packages/fortifier/src/validations.rs | 4 + packages/fortifier/src/validations/email.rs | 6 + packages/fortifier/src/validations/url.rs | 85 ++++++ 9 files changed, 412 insertions(+), 3 deletions(-) create mode 100644 packages/fortifier-macros/src/validations/url.rs create mode 100644 packages/fortifier/src/validations/url.rs diff --git a/Cargo.lock b/Cargo.lock index 35698a9..fe49d19 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11,18 +11,39 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "equivalent" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fortifier" version = "0.0.1" dependencies = [ "fortifier-macros", "indexmap", + "url", ] [[package]] @@ -56,6 +77,108 @@ version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e93fcd3157766c0c8da2f8cff6ce651a31f0810eaa1c51ec363ef790bbb5fb99" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02845b3647bb045f1100ecd6480ff52f34c35f82d9880e029d329c21d1054899" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "2.12.0" @@ -72,12 +195,33 @@ version = "1.0.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + [[package]] name = "memchr" version = "2.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + [[package]] name = "proc-macro2" version = "1.0.103" @@ -109,6 +253,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -153,6 +298,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "syn" version = "2.0.110" @@ -164,6 +321,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "target-triple" version = "1.0.0" @@ -179,6 +347,16 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "toml" version = "0.9.8" @@ -245,6 +423,24 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "winapi-util" version = "0.1.11" @@ -274,3 +470,86 @@ name = "winnow" version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21a0236b59786fed61e2a80582dd500fe61f18b5dca67a4a067d0bc9039339cf" + +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] diff --git a/example/src/main.rs b/example/src/main.rs index abe2f6d..c61972c 100644 --- a/example/src/main.rs +++ b/example/src/main.rs @@ -9,12 +9,16 @@ struct CreateUser { #[validate(length(min = 1, max = 256))] name: String, + + #[validate(url)] + url: String, } fn main() -> Result<(), Box> { let data = CreateUser { email: "john@doe.com".to_owned(), name: "John Doe".to_owned(), + url: "https://john.doe.com".to_owned(), }; data.validate_sync()?; diff --git a/packages/fortifier-macros/src/validate/field.rs b/packages/fortifier-macros/src/validate/field.rs index 970c160..c6a794b 100644 --- a/packages/fortifier-macros/src/validate/field.rs +++ b/packages/fortifier-macros/src/validate/field.rs @@ -2,13 +2,14 @@ use proc_macro2::TokenStream; use quote::quote; use syn::{Field, Result}; -use crate::validations::{Email, Length}; +use crate::validations::{Email, Length, Url}; pub struct ValidateField { expr: TokenStream, // TODO: Consider using a trait for validations. email: Option, length: Option, + url: Option, } impl ValidateField { @@ -17,6 +18,7 @@ impl ValidateField { expr, email: None, length: None, + url: None, }; for attr in &field.attrs { @@ -29,6 +31,10 @@ impl ValidateField { } else if meta.path.is_ident("length") { result.length = Some(Length::parse(&meta)?); + Ok(()) + } else if meta.path.is_ident("url") { + result.url = Some(Url::parse(&meta)?); + Ok(()) } else { Err(meta.error("unknown validate parameter")) @@ -47,6 +53,8 @@ impl ValidateField { quote!(EmailError) } else if self.length.is_some() { quote!(LengthError) + } else if self.url.is_some() { + quote!(UrlError) } else { quote!(()) } @@ -55,8 +63,9 @@ impl ValidateField { pub fn sync_validations(&self) -> Vec { let email = self.email.as_ref().map(|email| email.tokens(&self.expr)); let length = self.length.as_ref().map(|length| length.tokens(&self.expr)); + let url = self.url.as_ref().map(|url| url.tokens(&self.expr)); - [email, length].into_iter().flatten().collect() + [email, length, url].into_iter().flatten().collect() } pub fn async_validations(&self) -> Vec { diff --git a/packages/fortifier-macros/src/validations.rs b/packages/fortifier-macros/src/validations.rs index 0b1b292..2909a91 100644 --- a/packages/fortifier-macros/src/validations.rs +++ b/packages/fortifier-macros/src/validations.rs @@ -1,5 +1,7 @@ mod email; mod length; +mod url; pub use email::*; pub use length::*; +pub use url::*; diff --git a/packages/fortifier-macros/src/validations/url.rs b/packages/fortifier-macros/src/validations/url.rs new file mode 100644 index 0000000..290b84e --- /dev/null +++ b/packages/fortifier-macros/src/validations/url.rs @@ -0,0 +1,18 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::{Result, meta::ParseNestedMeta}; + +#[derive(Default)] +pub struct Url {} + +impl Url { + pub fn parse(_meta: &ParseNestedMeta<'_>) -> Result { + Ok(Url::default()) + } + + pub fn tokens(&self, expr: &TokenStream) -> TokenStream { + quote! { + #expr.validate_url() + } + } +} diff --git a/packages/fortifier/Cargo.toml b/packages/fortifier/Cargo.toml index 7eeb995..3b0372d 100644 --- a/packages/fortifier/Cargo.toml +++ b/packages/fortifier/Cargo.toml @@ -9,13 +9,15 @@ repository.workspace = true version.workspace = true [features] -default = ["macros"] +default = ["macros", "url"] indexmap = ["dep:indexmap"] macros = ["dep:fortifier-macros"] +url = ["dep:url"] [dependencies] fortifier-macros = { workspace = true, optional = true } indexmap = { version = "2.12.0", optional = true } +url = { version = "2.5.7", optional = true } [lints] workspace = true diff --git a/packages/fortifier/src/validations.rs b/packages/fortifier/src/validations.rs index 0b1b292..4ff3dcb 100644 --- a/packages/fortifier/src/validations.rs +++ b/packages/fortifier/src/validations.rs @@ -1,5 +1,9 @@ mod email; mod length; +#[cfg(feature = "url")] +mod url; pub use email::*; pub use length::*; +#[cfg(feature = "url")] +pub use url::*; diff --git a/packages/fortifier/src/validations/email.rs b/packages/fortifier/src/validations/email.rs index 079860f..14988c2 100644 --- a/packages/fortifier/src/validations/email.rs +++ b/packages/fortifier/src/validations/email.rs @@ -53,6 +53,12 @@ validate_type_with_deref!(Rc); validate_type_with_deref!(Ref<'_, T>); validate_type_with_deref!(RefMut<'_, T>); +impl ValidateEmail for str { + fn email(&self) -> Option> { + Some(self.into()) + } +} + impl ValidateEmail for &str { fn email(&self) -> Option> { Some((*self).into()) diff --git a/packages/fortifier/src/validations/url.rs b/packages/fortifier/src/validations/url.rs new file mode 100644 index 0000000..05db33c --- /dev/null +++ b/packages/fortifier/src/validations/url.rs @@ -0,0 +1,85 @@ +use std::{ + borrow::Cow, + cell::{Ref, RefMut}, + rc::Rc, + sync::Arc, +}; + +use url::{ParseError, Url}; + +/// URL validation error. +#[derive(Debug)] +pub enum UrlError { + /// Invalid URL. + Parse(ParseError), +} + +/// Validate a URL. +pub trait ValidateUrl { + /// The URL. + fn url(&self) -> Option>; + + /// Validate URL. + fn validate_url(&self) -> Result<(), UrlError> { + let Some(url) = self.url() else { + return Ok(()); + }; + + Url::parse(&url).map_err(UrlError::Parse)?; + + Ok(()) + } +} + +macro_rules! validate_type_with_deref { + ($type:ty) => { + impl ValidateUrl for $type + where + T: ValidateUrl, + { + fn url(&self) -> Option> { + T::url(self) + } + } + }; +} + +validate_type_with_deref!(&T); +validate_type_with_deref!(Arc); +validate_type_with_deref!(Box); +validate_type_with_deref!(Rc); +validate_type_with_deref!(Ref<'_, T>); +validate_type_with_deref!(RefMut<'_, T>); + +impl ValidateUrl for str { + fn url(&self) -> Option> { + Some(self.into()) + } +} + +impl ValidateUrl for &str { + fn url(&self) -> Option> { + Some((*self).into()) + } +} + +impl ValidateUrl for String { + fn url(&self) -> Option> { + Some(self.into()) + } +} + +impl ValidateUrl for Cow<'_, str> { + fn url(&self) -> Option> { + Some(self.clone()) + } +} + +impl ValidateUrl for Option +where + T: ValidateUrl, +{ + fn url(&self) -> Option> { + if let Some(s) = self { T::url(s) } else { None } + } +}