Skip to main content

icydb_model/base/validator/
web.rs

1//! Module: base::validator::web
2//!
3//! Responsibility: base validator definitions.
4//! Does not own: normalization policy, persistence, or schema mutation semantics.
5//! Boundary: reports typed visitor issues for facade schema values.
6
7use crate::{prelude::*, visitor::Validator};
8
9fn is_mime_token_part(part: &str) -> bool {
10    let Some(first) = part.chars().next() else {
11        return false;
12    };
13    let Some(last) = part.chars().next_back() else {
14        return false;
15    };
16
17    !part.is_empty()
18        && first.is_ascii_alphanumeric()
19        && last.is_ascii_alphanumeric()
20        && part
21            .chars()
22            .all(|c| c.is_ascii_alphanumeric() || "+.-".contains(c))
23}
24
25///
26/// MimeType
27///
28/// Validates a basic MIME type token pair in the form `type/subtype`.
29/// Each token must start and end with ASCII alphanumeric characters and may
30/// contain `+`, `-`, or `.` internally.
31///
32
33#[validator]
34pub struct MimeType;
35
36impl Validator<str> for MimeType {
37    fn validate(&self, s: &str, ctx: &mut dyn VisitorContext) {
38        // Split into at most three slash segments so we can enforce exactly one '/'.
39        let mut slash_segments = s.split('/');
40        let type_part = slash_segments.next();
41        let subtype_part = slash_segments.next();
42        let extra_part = slash_segments.next();
43
44        // Must contain exactly one '/'
45        if type_part.is_none() || subtype_part.is_none() || extra_part.is_some() {
46            ctx.issue("MIME type must contain exactly one slash");
47            return;
48        }
49
50        let (Some(type_part), Some(subtype_part)) = (type_part, subtype_part) else {
51            return;
52        };
53
54        if !is_mime_token_part(type_part) || !is_mime_token_part(subtype_part) {
55            ctx.issue("MIME type contains invalid token characters");
56        }
57    }
58}
59
60fn url_has_forbidden_chars(s: &str) -> bool {
61    s.chars()
62        .any(|ch| ch.is_ascii_control() || ch.is_ascii_whitespace())
63}
64
65fn split_http_url_rest(s: &str) -> Option<&str> {
66    if let Some(rest) = s.strip_prefix("http://") {
67        Some(rest)
68    } else {
69        s.strip_prefix("https://")
70    }
71}
72
73fn url_host_end(rest: &str) -> usize {
74    rest.find(['/', '?', '#']).unwrap_or(rest.len())
75}
76
77fn url_host_and_port_are_valid(host: &str) -> bool {
78    if host.is_empty() || host.contains('@') {
79        return false;
80    }
81
82    if let Some(bracketed) = host.strip_prefix('[') {
83        let Some(end) = bracketed.find(']') else {
84            return false;
85        };
86        let address = &bracketed[..end];
87        let suffix = &bracketed[end + 1..];
88
89        return !address.is_empty()
90            && address
91                .chars()
92                .all(|ch| ch.is_ascii_hexdigit() || ch == ':' || ch == '.')
93            && url_port_suffix_is_valid(suffix);
94    }
95
96    let hostname = match host.rsplit_once(':') {
97        Some((hostname, port)) => {
98            if hostname.contains(':')
99                || port.is_empty()
100                || !port.chars().all(|ch| ch.is_ascii_digit())
101            {
102                return false;
103            }
104            hostname
105        }
106        None => host,
107    };
108
109    url_hostname_is_valid(hostname)
110}
111
112fn url_port_suffix_is_valid(suffix: &str) -> bool {
113    if suffix.is_empty() {
114        return true;
115    }
116    let Some(port) = suffix.strip_prefix(':') else {
117        return false;
118    };
119
120    !port.is_empty() && port.chars().all(|ch| ch.is_ascii_digit())
121}
122
123fn url_hostname_is_valid(hostname: &str) -> bool {
124    if hostname.is_empty() || hostname == "." || hostname == ".." {
125        return false;
126    }
127
128    hostname.split('.').all(|label| {
129        let Some(first) = label.chars().next() else {
130            return false;
131        };
132        let Some(last) = label.chars().next_back() else {
133            return false;
134        };
135
136        !label.is_empty()
137            && first.is_ascii_alphanumeric()
138            && last.is_ascii_alphanumeric()
139            && label
140                .chars()
141                .all(|ch| ch.is_ascii_alphanumeric() || ch == '-')
142    })
143}
144
145///
146/// Url
147///
148/// Validates that the value uses `http://` or `https://` and has a non-empty
149/// host without whitespace, control characters, userinfo, or malformed ports.
150///
151
152#[validator]
153pub struct Url;
154
155impl Validator<str> for Url {
156    fn validate(&self, s: &str, ctx: &mut dyn VisitorContext) {
157        let Some(rest) = split_http_url_rest(s) else {
158            ctx.issue("URL must start with http:// or https://");
159            return;
160        };
161
162        if url_has_forbidden_chars(s) {
163            ctx.issue("URL must not contain whitespace or control characters");
164            return;
165        }
166
167        let host_end = url_host_end(rest);
168        let host = &rest[..host_end];
169        if !url_host_and_port_are_valid(host) {
170            ctx.issue("URL host is malformed");
171        }
172    }
173}
174
175///
176/// TESTS
177///
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    struct TestCtx {
184        issues: crate::visitor::VisitorIssues,
185    }
186
187    impl TestCtx {
188        fn new() -> Self {
189            Self {
190                issues: crate::visitor::VisitorIssues::new(),
191            }
192        }
193
194        fn has_issues(&self) -> bool {
195            !self.issues.is_empty()
196        }
197    }
198
199    impl crate::visitor::VisitorContext for TestCtx {
200        fn add_issue(&mut self, issue: crate::visitor::Issue) {
201            self.issues.push(String::new(), issue);
202        }
203
204        fn add_issue_at(&mut self, _: crate::visitor::PathSegment, issue: crate::visitor::Issue) {
205            self.add_issue(issue);
206        }
207    }
208
209    #[test]
210    fn mime_type_rejects_dot_only_tokens() {
211        let validator = MimeType;
212        let mut ctx = TestCtx::new();
213
214        validator.validate("./.", &mut ctx);
215
216        assert!(ctx.has_issues());
217    }
218
219    #[test]
220    fn mime_type_accepts_common_structured_suffix() {
221        let validator = MimeType;
222        let mut ctx = TestCtx::new();
223
224        validator.validate("application/vnd.api+json", &mut ctx);
225
226        assert!(!ctx.has_issues());
227    }
228
229    #[test]
230    fn url_rejects_unsupported_or_malformed_scheme_inputs() {
231        for url in [
232            "javascript:alert(1)",
233            "https://javascript:alert(1)",
234            "ftp://example.com",
235            "https://",
236            "https://example.com:abc",
237            "https://exa mple.com",
238        ] {
239            let validator = Url;
240            let mut ctx = TestCtx::new();
241
242            validator.validate(url, &mut ctx);
243
244            assert!(ctx.has_issues(), "{url} should be rejected");
245        }
246    }
247
248    #[test]
249    fn url_accepts_http_hosts_and_numeric_ports() {
250        for url in [
251            "https://example.com",
252            "http://localhost:8080/path?q=1",
253            "https://127.0.0.1:4943/",
254            "https://[::1]:4943/",
255        ] {
256            let validator = Url;
257            let mut ctx = TestCtx::new();
258
259            validator.validate(url, &mut ctx);
260
261            assert!(!ctx.has_issues(), "{url} should be accepted");
262        }
263    }
264}