icydb_model/base/validator/
web.rs1use 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#[validator]
34pub struct MimeType;
35
36impl Validator<str> for MimeType {
37 fn validate(&self, s: &str, ctx: &mut dyn VisitorContext) {
38 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 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#[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#[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}