Skip to main content

jpx_core/extensions/
validation.rs

1//! Data validation functions.
2
3use std::collections::HashSet;
4
5use regex::Regex;
6use serde_json::Value;
7
8use crate::functions::Function;
9use crate::interpreter::SearchResult;
10use crate::registry::register_if_enabled;
11use crate::{Context, Runtime, arg, defn};
12
13/// Register validation functions filtered by the enabled set.
14pub fn register_filtered(runtime: &mut Runtime, enabled: &HashSet<&str>) {
15    register_if_enabled(runtime, "is_email", enabled, Box::new(IsEmailFn::new()));
16    register_if_enabled(runtime, "is_url", enabled, Box::new(IsUrlFn::new()));
17    register_if_enabled(runtime, "is_uuid", enabled, Box::new(IsUuidFn::new()));
18    register_if_enabled(runtime, "is_phone", enabled, Box::new(IsPhoneFn::new()));
19    register_if_enabled(runtime, "is_ipv4", enabled, Box::new(IsIpv4Fn::new()));
20    register_if_enabled(runtime, "is_ipv6", enabled, Box::new(IsIpv6Fn::new()));
21    register_if_enabled(runtime, "luhn_check", enabled, Box::new(LuhnCheckFn::new()));
22    register_if_enabled(
23        runtime,
24        "is_credit_card",
25        enabled,
26        Box::new(IsCreditCardFn::new()),
27    );
28    register_if_enabled(runtime, "is_jwt", enabled, Box::new(IsJwtFn::new()));
29    register_if_enabled(
30        runtime,
31        "is_iso_date",
32        enabled,
33        Box::new(IsIsoDateFn::new()),
34    );
35    register_if_enabled(runtime, "is_json", enabled, Box::new(IsJsonFn::new()));
36    register_if_enabled(runtime, "is_base64", enabled, Box::new(IsBase64Fn::new()));
37    register_if_enabled(runtime, "is_hex", enabled, Box::new(IsHexFn::new()));
38}
39
40// =============================================================================
41// is_email(string) -> boolean
42// =============================================================================
43
44defn!(IsEmailFn, vec![arg!(string)], None);
45
46impl Function for IsEmailFn {
47    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
48        self.signature.validate(args, ctx)?;
49
50        let s = args[0].as_str().unwrap();
51
52        let email_re = Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap();
53        Ok(Value::Bool(email_re.is_match(s)))
54    }
55}
56
57// =============================================================================
58// is_url(string) -> boolean
59// =============================================================================
60
61defn!(IsUrlFn, vec![arg!(string)], None);
62
63impl Function for IsUrlFn {
64    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
65        self.signature.validate(args, ctx)?;
66
67        let s = args[0].as_str().unwrap();
68
69        let url_re = Regex::new(r"^https?://[^\s/$.?#].[^\s]*$").unwrap();
70        Ok(Value::Bool(url_re.is_match(s)))
71    }
72}
73
74// =============================================================================
75// is_uuid(string) -> boolean
76// =============================================================================
77
78defn!(IsUuidFn, vec![arg!(string)], None);
79
80impl Function for IsUuidFn {
81    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
82        self.signature.validate(args, ctx)?;
83
84        let s = args[0].as_str().unwrap();
85
86        let uuid_re = Regex::new(
87            r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$",
88        )
89        .unwrap();
90        Ok(Value::Bool(uuid_re.is_match(s)))
91    }
92}
93
94// =============================================================================
95// is_ipv4(string) -> boolean
96// =============================================================================
97
98defn!(IsIpv4Fn, vec![arg!(string)], None);
99
100impl Function for IsIpv4Fn {
101    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
102        self.signature.validate(args, ctx)?;
103
104        let s = args[0].as_str().unwrap();
105
106        let is_valid = s.parse::<std::net::Ipv4Addr>().is_ok();
107        Ok(Value::Bool(is_valid))
108    }
109}
110
111// =============================================================================
112// is_ipv6(string) -> boolean
113// =============================================================================
114
115defn!(IsIpv6Fn, vec![arg!(string)], None);
116
117impl Function for IsIpv6Fn {
118    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
119        self.signature.validate(args, ctx)?;
120
121        let s = args[0].as_str().unwrap();
122
123        let is_valid = s.parse::<std::net::Ipv6Addr>().is_ok();
124        Ok(Value::Bool(is_valid))
125    }
126}
127
128// =============================================================================
129// luhn_check(string) -> boolean
130// =============================================================================
131
132defn!(LuhnCheckFn, vec![arg!(string)], None);
133
134impl Function for LuhnCheckFn {
135    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
136        self.signature.validate(args, ctx)?;
137
138        let s = args[0].as_str().unwrap();
139
140        Ok(Value::Bool(luhn_validate(s)))
141    }
142}
143
144fn luhn_validate(s: &str) -> bool {
145    // Remove spaces and dashes
146    let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
147
148    if digits.is_empty() {
149        return false;
150    }
151
152    let mut sum = 0;
153    let mut double = false;
154
155    for c in digits.chars().rev() {
156        if let Some(digit) = c.to_digit(10) {
157            let mut d = digit;
158            if double {
159                d *= 2;
160                if d > 9 {
161                    d -= 9;
162                }
163            }
164            sum += d;
165            double = !double;
166        } else {
167            return false;
168        }
169    }
170
171    sum % 10 == 0
172}
173
174// =============================================================================
175// is_credit_card(string) -> boolean
176// =============================================================================
177
178defn!(IsCreditCardFn, vec![arg!(string)], None);
179
180impl Function for IsCreditCardFn {
181    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
182        self.signature.validate(args, ctx)?;
183
184        let s = args[0].as_str().unwrap();
185
186        // Remove spaces and dashes
187        let digits: String = s.chars().filter(|c| c.is_ascii_digit()).collect();
188
189        // Credit cards are typically 13-19 digits
190        if digits.len() < 13 || digits.len() > 19 {
191            return Ok(Value::Bool(false));
192        }
193
194        // Must pass Luhn check
195        Ok(Value::Bool(luhn_validate(&digits)))
196    }
197}
198
199// =============================================================================
200// is_phone(string) -> boolean
201// =============================================================================
202
203defn!(IsPhoneFn, vec![arg!(string)], None);
204
205impl Function for IsPhoneFn {
206    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
207        self.signature.validate(args, ctx)?;
208
209        let s = args[0].as_str().unwrap();
210
211        // Basic phone pattern: optional + followed by digits, spaces, dashes, parens
212        // Minimum 7 digits for a valid phone number
213        let phone_re = Regex::new(r"^\+?[\d\s\-\(\)\.]{7,}$").unwrap();
214        if !phone_re.is_match(s) {
215            return Ok(Value::Bool(false));
216        }
217
218        // Count actual digits - need at least 7
219        let digit_count = s.chars().filter(|c| c.is_ascii_digit()).count();
220        Ok(Value::Bool((7..=15).contains(&digit_count)))
221    }
222}
223
224// =============================================================================
225// is_jwt(string) -> boolean
226// =============================================================================
227
228defn!(IsJwtFn, vec![arg!(string)], None);
229
230impl Function for IsJwtFn {
231    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
232        self.signature.validate(args, ctx)?;
233
234        let s = args[0].as_str().unwrap();
235
236        // JWT has 3 base64url-encoded parts separated by dots
237        let parts: Vec<&str> = s.split('.').collect();
238        if parts.len() != 3 {
239            return Ok(Value::Bool(false));
240        }
241
242        // Check each part is valid base64url (alphanumeric, -, _, no padding required)
243        let is_valid = parts.iter().all(|part| {
244            !part.is_empty()
245                && part
246                    .chars()
247                    .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '=')
248        });
249
250        Ok(Value::Bool(is_valid))
251    }
252}
253
254// =============================================================================
255// is_iso_date(string) -> boolean
256// =============================================================================
257
258defn!(IsIsoDateFn, vec![arg!(string)], None);
259
260impl Function for IsIsoDateFn {
261    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
262        self.signature.validate(args, ctx)?;
263
264        let s = args[0].as_str().unwrap();
265
266        // Try parsing as RFC3339 (subset of ISO 8601)
267        if chrono::DateTime::parse_from_rfc3339(s).is_ok() {
268            return Ok(Value::Bool(true));
269        }
270
271        // Try parsing as date only (YYYY-MM-DD)
272        if chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").is_ok() {
273            return Ok(Value::Bool(true));
274        }
275
276        // Try parsing as datetime without timezone
277        if chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S").is_ok() {
278            return Ok(Value::Bool(true));
279        }
280
281        Ok(Value::Bool(false))
282    }
283}
284
285// =============================================================================
286// is_json(string) -> boolean
287// =============================================================================
288
289defn!(IsJsonFn, vec![arg!(string)], None);
290
291impl Function for IsJsonFn {
292    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
293        self.signature.validate(args, ctx)?;
294
295        let s = args[0].as_str().unwrap();
296
297        let is_valid = serde_json::from_str::<serde_json::Value>(s).is_ok();
298        Ok(Value::Bool(is_valid))
299    }
300}
301
302// =============================================================================
303// is_base64(string) -> boolean
304// =============================================================================
305
306defn!(IsBase64Fn, vec![arg!(string)], None);
307
308impl Function for IsBase64Fn {
309    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
310        self.signature.validate(args, ctx)?;
311
312        let s = args[0].as_str().unwrap();
313
314        use base64::{Engine, engine::general_purpose::STANDARD};
315        let is_valid = STANDARD.decode(s).is_ok();
316        Ok(Value::Bool(is_valid))
317    }
318}
319
320// =============================================================================
321// is_hex(string) -> boolean
322// =============================================================================
323
324defn!(IsHexFn, vec![arg!(string)], None);
325
326impl Function for IsHexFn {
327    fn evaluate(&self, args: &[Value], ctx: &mut Context<'_>) -> SearchResult {
328        self.signature.validate(args, ctx)?;
329
330        let s = args[0].as_str().unwrap();
331
332        // Must be non-empty and all hex chars
333        let is_valid = !s.is_empty() && s.chars().all(|c| c.is_ascii_hexdigit());
334        Ok(Value::Bool(is_valid))
335    }
336}