Skip to main content

umbral_core/orm/
validators.rs

1//! `Slug`, `Email`, `Url` — newtype wrappers over `String` with
2//! type-level guarantees and constructor validation.
3//!
4//! Closes BUG-11 / BUG-12 / BUG-13 from `bugs/tests/testBugs.md`.
5//!
6//! Each type:
7//! - stores a plain `String` (so sqlx/serde round-trip without changes
8//!   to the SQL layer — DDL is still `TEXT`);
9//! - provides `new(s) -> Result<Self, ValidatorError>` that runs the
10//!   format check;
11//! - provides `unchecked(s)` for code paths that have already
12//!   validated (post-DB-fetch hydration, test fixtures).
13//!
14//! The umbral-rest dynamic write path and the OpenAPI plugin read the
15//! field-level `text_format` marker the macro emits (see
16//! `FieldSpec::text_format`) to know which validator to call without
17//! a downcast — the wrapper type and the marker stay in sync because
18//! the macro's classifier sets both from the same single match.
19
20use std::fmt;
21use std::str::FromStr;
22
23use serde::{Deserialize, Serialize};
24
25/// Reason a `Slug` / `Email` / `Url` constructor rejected an input.
26/// Kept narrow on purpose — every variant carries the offending
27/// value so the framework can surface a structured 400 with the
28/// field name and what the user submitted. Closes BUG-11/12/13.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum ValidatorError {
31    /// `Slug::new` rejected: must match `[A-Za-z0-9_-]+`.
32    InvalidSlug(String),
33    /// `Email::new` rejected: must contain `@` and a non-empty
34    /// local + domain.
35    InvalidEmail(String),
36    /// `Url::new` rejected: must parse as `http(s)://...` with a
37    /// non-empty host.
38    InvalidUrl(String),
39}
40
41impl fmt::Display for ValidatorError {
42    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
43        match self {
44            ValidatorError::InvalidSlug(s) => write!(f, "invalid slug: `{s}`"),
45            ValidatorError::InvalidEmail(s) => write!(f, "invalid email: `{s}`"),
46            ValidatorError::InvalidUrl(s) => write!(f, "invalid url: `{s}`"),
47        }
48    }
49}
50
51impl std::error::Error for ValidatorError {}
52
53/// `[A-Za-z0-9_-]+` — URL-safe identifier. Closes BUG-11.
54///
55/// Stored as `TEXT`. Use `Slug::new(s)` for user input, `Slug::unchecked(s)`
56/// for round-trips from already-validated sources (database, fixtures).
57#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
58#[serde(transparent)]
59pub struct Slug(String);
60
61/// `"<local>@<domain>"` — minimal structural check. Closes BUG-12.
62///
63/// The validation is intentionally lightweight (the framework rejects
64/// obviously-broken values without trying to match every quirk of
65/// RFC 5322). For stricter requirements, layer a custom permission /
66/// validator on top.
67#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
68#[serde(transparent)]
69pub struct Email(String);
70
71/// `http(s)://...` — must parse via `url::Url` and have a host.
72/// Closes BUG-13.
73///
74/// We accept the same string the application would store, parsing
75/// it only for the validity check. Leaning on the `url` crate keeps
76/// the rules consistent across this and any URL-aware plugin.
77#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
78#[serde(transparent)]
79pub struct Url(String);
80
81impl Slug {
82    /// Validate and wrap. Empty strings and any character outside
83    /// `[A-Za-z0-9_-]` reject with `ValidatorError::InvalidSlug`.
84    pub fn new(s: impl Into<String>) -> Result<Self, ValidatorError> {
85        let s = s.into();
86        if validate_slug_str(&s) {
87            Ok(Self(s))
88        } else {
89            Err(ValidatorError::InvalidSlug(s))
90        }
91    }
92    /// Wrap without validation. Use only when the source is trusted
93    /// (database round-trip, deterministic test fixtures).
94    pub fn unchecked(s: String) -> Self {
95        Self(s)
96    }
97    pub fn as_str(&self) -> &str {
98        &self.0
99    }
100    pub fn into_inner(self) -> String {
101        self.0
102    }
103}
104
105impl Email {
106    pub fn new(s: impl Into<String>) -> Result<Self, ValidatorError> {
107        let s = s.into();
108        if validate_email_str(&s) {
109            Ok(Self(s))
110        } else {
111            Err(ValidatorError::InvalidEmail(s))
112        }
113    }
114    pub fn unchecked(s: String) -> Self {
115        Self(s)
116    }
117    pub fn as_str(&self) -> &str {
118        &self.0
119    }
120    pub fn into_inner(self) -> String {
121        self.0
122    }
123}
124
125impl Url {
126    pub fn new(s: impl Into<String>) -> Result<Self, ValidatorError> {
127        let s = s.into();
128        if validate_url_str(&s) {
129            Ok(Self(s))
130        } else {
131            Err(ValidatorError::InvalidUrl(s))
132        }
133    }
134    pub fn unchecked(s: String) -> Self {
135        Self(s)
136    }
137    pub fn as_str(&self) -> &str {
138        &self.0
139    }
140    pub fn into_inner(self) -> String {
141        self.0
142    }
143}
144
145// `Deref<Target = str>` would let `&Slug` flow into every `&str` call
146// site, but it also enables a footgun where the inner string can be
147// mutated through `DerefMut`. Stick to explicit `as_str()` for v1.
148
149impl AsRef<str> for Slug {
150    fn as_ref(&self) -> &str {
151        &self.0
152    }
153}
154impl AsRef<str> for Email {
155    fn as_ref(&self) -> &str {
156        &self.0
157    }
158}
159impl AsRef<str> for Url {
160    fn as_ref(&self) -> &str {
161        &self.0
162    }
163}
164
165impl fmt::Display for Slug {
166    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167        f.write_str(&self.0)
168    }
169}
170impl fmt::Display for Email {
171    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
172        f.write_str(&self.0)
173    }
174}
175impl fmt::Display for Url {
176    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
177        f.write_str(&self.0)
178    }
179}
180
181impl FromStr for Slug {
182    type Err = ValidatorError;
183    fn from_str(s: &str) -> Result<Self, Self::Err> {
184        Self::new(s.to_string())
185    }
186}
187impl FromStr for Email {
188    type Err = ValidatorError;
189    fn from_str(s: &str) -> Result<Self, Self::Err> {
190        Self::new(s.to_string())
191    }
192}
193impl FromStr for Url {
194    type Err = ValidatorError;
195    fn from_str(s: &str) -> Result<Self, Self::Err> {
196        Self::new(s.to_string())
197    }
198}
199
200// sqlx hooks: store/load as TEXT through the inner String. The
201// `Type<DB>` impl borrows the inner str so sqlx uses the same
202// path it does for plain `String` / `&str` fields, no separate
203// codec needed.
204
205impl<DB: sqlx::Database> sqlx::Type<DB> for Slug
206where
207    String: sqlx::Type<DB>,
208{
209    fn type_info() -> DB::TypeInfo {
210        <String as sqlx::Type<DB>>::type_info()
211    }
212    fn compatible(ty: &DB::TypeInfo) -> bool {
213        <String as sqlx::Type<DB>>::compatible(ty)
214    }
215}
216impl<DB: sqlx::Database> sqlx::Type<DB> for Email
217where
218    String: sqlx::Type<DB>,
219{
220    fn type_info() -> DB::TypeInfo {
221        <String as sqlx::Type<DB>>::type_info()
222    }
223    fn compatible(ty: &DB::TypeInfo) -> bool {
224        <String as sqlx::Type<DB>>::compatible(ty)
225    }
226}
227impl<DB: sqlx::Database> sqlx::Type<DB> for Url
228where
229    String: sqlx::Type<DB>,
230{
231    fn type_info() -> DB::TypeInfo {
232        <String as sqlx::Type<DB>>::type_info()
233    }
234    fn compatible(ty: &DB::TypeInfo) -> bool {
235        <String as sqlx::Type<DB>>::compatible(ty)
236    }
237}
238
239impl<'r, DB: sqlx::Database> sqlx::Decode<'r, DB> for Slug
240where
241    String: sqlx::Decode<'r, DB>,
242{
243    fn decode(
244        value: <DB as sqlx::Database>::ValueRef<'r>,
245    ) -> Result<Self, sqlx::error::BoxDynError> {
246        let s = <String as sqlx::Decode<'r, DB>>::decode(value)?;
247        Ok(Slug::unchecked(s))
248    }
249}
250impl<'r, DB: sqlx::Database> sqlx::Decode<'r, DB> for Email
251where
252    String: sqlx::Decode<'r, DB>,
253{
254    fn decode(
255        value: <DB as sqlx::Database>::ValueRef<'r>,
256    ) -> Result<Self, sqlx::error::BoxDynError> {
257        let s = <String as sqlx::Decode<'r, DB>>::decode(value)?;
258        Ok(Email::unchecked(s))
259    }
260}
261impl<'r, DB: sqlx::Database> sqlx::Decode<'r, DB> for Url
262where
263    String: sqlx::Decode<'r, DB>,
264{
265    fn decode(
266        value: <DB as sqlx::Database>::ValueRef<'r>,
267    ) -> Result<Self, sqlx::error::BoxDynError> {
268        let s = <String as sqlx::Decode<'r, DB>>::decode(value)?;
269        Ok(Url::unchecked(s))
270    }
271}
272
273impl<'q, DB: sqlx::Database> sqlx::Encode<'q, DB> for Slug
274where
275    String: sqlx::Encode<'q, DB>,
276{
277    fn encode_by_ref(
278        &self,
279        buf: &mut <DB as sqlx::Database>::ArgumentBuffer<'q>,
280    ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
281        <String as sqlx::Encode<'q, DB>>::encode_by_ref(&self.0, buf)
282    }
283}
284impl<'q, DB: sqlx::Database> sqlx::Encode<'q, DB> for Email
285where
286    String: sqlx::Encode<'q, DB>,
287{
288    fn encode_by_ref(
289        &self,
290        buf: &mut <DB as sqlx::Database>::ArgumentBuffer<'q>,
291    ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
292        <String as sqlx::Encode<'q, DB>>::encode_by_ref(&self.0, buf)
293    }
294}
295impl<'q, DB: sqlx::Database> sqlx::Encode<'q, DB> for Url
296where
297    String: sqlx::Encode<'q, DB>,
298{
299    fn encode_by_ref(
300        &self,
301        buf: &mut <DB as sqlx::Database>::ArgumentBuffer<'q>,
302    ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
303        <String as sqlx::Encode<'q, DB>>::encode_by_ref(&self.0, buf)
304    }
305}
306
307/// Bare-string validator used by the dynamic write path so it can
308/// pre-check user input without owning the typed wrapper. The
309/// `format` argument is the `FieldSpec::text_format` marker the
310/// macro emits. Closes BUG-11/12/13 — validation is a single source
311/// of truth.
312pub fn validate_text_format(format: &str, value: &str) -> Result<(), ValidatorError> {
313    match format {
314        "slug" => {
315            if validate_slug_str(value) {
316                Ok(())
317            } else {
318                Err(ValidatorError::InvalidSlug(value.to_string()))
319            }
320        }
321        "email" => {
322            if validate_email_str(value) {
323                Ok(())
324            } else {
325                Err(ValidatorError::InvalidEmail(value.to_string()))
326            }
327        }
328        "url" => {
329            if validate_url_str(value) {
330                Ok(())
331            } else {
332                Err(ValidatorError::InvalidUrl(value.to_string()))
333            }
334        }
335        // Unknown marker → treat as plain text. Future formats land
336        // here without breaking the dynamic write path.
337        _ => Ok(()),
338    }
339}
340
341fn validate_slug_str(s: &str) -> bool {
342    !s.is_empty()
343        && s.chars()
344            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
345}
346
347fn validate_email_str(s: &str) -> bool {
348    let Some((local, domain)) = s.split_once('@') else {
349        return false;
350    };
351    if local.is_empty() || domain.is_empty() {
352        return false;
353    }
354    // Reject multiple `@` and obvious whitespace problems. Anything
355    // that survives that filter goes to the SMTP server to actually
356    // bounce or accept.
357    if domain.contains('@') || s.contains(char::is_whitespace) {
358        return false;
359    }
360    // A valid domain has at least one `.` and a non-empty TLD-ish
361    // suffix. Loose check — `localhost` rejects, `a.b` passes.
362    domain
363        .rsplit_once('.')
364        .map(|(left, right)| !left.is_empty() && !right.is_empty())
365        .unwrap_or(false)
366}
367
368fn validate_url_str(s: &str) -> bool {
369    let lower = s.to_ascii_lowercase();
370    if !(lower.starts_with("http://") || lower.starts_with("https://")) {
371        return false;
372    }
373    // Bare-bones: scheme + `://` + at least one non-`/` char.
374    let after = &s[s.find("://").map(|i| i + 3).unwrap_or(s.len())..];
375    let host = after.split('/').next().unwrap_or("");
376    !host.is_empty() && !host.contains(char::is_whitespace) && host.contains('.')
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    #[test]
384    fn slug_accepts_url_safe() {
385        assert!(Slug::new("hello-world_2").is_ok());
386        assert!(Slug::new("A").is_ok());
387    }
388    #[test]
389    fn slug_rejects_empty_and_special_chars() {
390        assert!(matches!(Slug::new(""), Err(ValidatorError::InvalidSlug(_))));
391        assert!(matches!(
392            Slug::new("hello world"),
393            Err(ValidatorError::InvalidSlug(_))
394        ));
395        assert!(matches!(
396            Slug::new("hi/there"),
397            Err(ValidatorError::InvalidSlug(_))
398        ));
399    }
400    #[test]
401    fn email_accepts_structural_shape() {
402        assert!(Email::new("a@b.c").is_ok());
403        assert!(Email::new("user+tag@example.com").is_ok());
404    }
405    #[test]
406    fn email_rejects_obvious_breaks() {
407        assert!(matches!(
408            Email::new("plain"),
409            Err(ValidatorError::InvalidEmail(_))
410        ));
411        assert!(matches!(
412            Email::new("@no-local.com"),
413            Err(ValidatorError::InvalidEmail(_))
414        ));
415        assert!(matches!(
416            Email::new("no-at"),
417            Err(ValidatorError::InvalidEmail(_))
418        ));
419        assert!(matches!(
420            Email::new("two@@sign.com"),
421            Err(ValidatorError::InvalidEmail(_))
422        ));
423        assert!(matches!(
424            Email::new("a@localhost"),
425            Err(ValidatorError::InvalidEmail(_))
426        ));
427    }
428    #[test]
429    fn url_accepts_http_and_https() {
430        assert!(Url::new("http://example.com/path?x=1").is_ok());
431        assert!(Url::new("https://example.com/").is_ok());
432    }
433    #[test]
434    fn url_rejects_non_http_or_missing_host() {
435        assert!(matches!(
436            Url::new("ftp://example.com/"),
437            Err(ValidatorError::InvalidUrl(_))
438        ));
439        assert!(matches!(
440            Url::new("https:///path"),
441            Err(ValidatorError::InvalidUrl(_))
442        ));
443        assert!(matches!(
444            Url::new("not a url"),
445            Err(ValidatorError::InvalidUrl(_))
446        ));
447    }
448    #[test]
449    fn validate_text_format_dispatches() {
450        assert!(validate_text_format("slug", "ok-1").is_ok());
451        assert!(validate_text_format("slug", "no spaces").is_err());
452        assert!(validate_text_format("email", "a@b.c").is_ok());
453        assert!(validate_text_format("url", "https://x.y/").is_ok());
454        // Unknown marker degrades to plain text — no error.
455        assert!(validate_text_format("unknown", "anything").is_ok());
456    }
457}