use once_cell::sync::Lazy;
use regex::Regex;
use serde_json::Value;
use crate::error::{DbError, DbResult};
static EMAIL_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$").unwrap());
static URL_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"^https?://[^\s/$.?#].[^\s]*$").unwrap());
static UUID_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(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}$")
.unwrap()
});
pub fn evaluate(name: &str, args: &[Value]) -> DbResult<Option<Value>> {
match name {
"HIGHLIGHT" => highlight(args),
"SLUGIFY" => {
if args.len() != 1 {
return Err(DbError::ExecutionError(
"SLUGIFY requires exactly 1 argument".to_string(),
));
}
match &args[0] {
Value::String(s) => Ok(Some(Value::String(slug::slugify(s)))),
Value::Null => Ok(Some(Value::Null)),
_ => Err(DbError::ExecutionError(
"SLUGIFY requires a string argument".to_string(),
)),
}
}
"SANITIZE" => sanitize(args),
"IS_EMAIL" => Ok(Some(Value::Bool(
args.first()
.and_then(Value::as_str)
.is_some_and(|s| EMAIL_RE.is_match(s)),
))),
"IS_URL" => Ok(Some(Value::Bool(
args.first()
.and_then(Value::as_str)
.is_some_and(|s| URL_RE.is_match(s)),
))),
"IS_UUID" => Ok(Some(Value::Bool(
args.first()
.and_then(Value::as_str)
.is_some_and(|s| UUID_RE.is_match(s)),
))),
"IS_BLANK" => Ok(Some(Value::Bool(match args.first() {
Some(Value::String(s)) => s.trim().is_empty(),
Some(Value::Null) | None => true,
_ => false,
}))),
_ => Ok(None),
}
}
fn push_html_escaped(out: &mut String, c: char) {
match c {
'&' => out.push_str("&"),
'<' => out.push_str("<"),
'>' => out.push_str(">"),
'"' => out.push_str("""),
'\'' => out.push_str("'"),
_ => out.push(c),
}
}
fn fold(c: char) -> char {
c.to_lowercase().next().unwrap_or(c)
}
fn highlight(args: &[Value]) -> DbResult<Option<Value>> {
let Some(Value::String(text)) = args.first() else {
return Ok(Some(Value::Null));
};
let mut terms: Vec<Vec<char>> = match args.get(1) {
Some(Value::String(s)) => vec![s.chars().map(fold).collect()],
Some(Value::Array(arr)) => arr
.iter()
.filter_map(Value::as_str)
.map(|s| s.chars().map(fold).collect())
.collect(),
_ => Vec::new(),
};
terms.retain(|t| !t.is_empty());
terms.sort_by_key(|t| std::cmp::Reverse(t.len()));
let text_chars: Vec<char> = text.chars().collect();
let folded: Vec<char> = text_chars.iter().map(|&c| fold(c)).collect();
let mut result = String::with_capacity(text.len() + 16);
let mut i = 0;
while i < text_chars.len() {
let hit = terms
.iter()
.find(|t| folded.get(i..i + t.len()) == Some(t.as_slice()))
.map(Vec::len);
match hit {
Some(len) => {
result.push_str("<b>");
for &c in &text_chars[i..i + len] {
push_html_escaped(&mut result, c);
}
result.push_str("</b>");
i += len;
}
None => {
push_html_escaped(&mut result, text_chars[i]);
i += 1;
}
}
}
Ok(Some(Value::String(result)))
}
fn strip_html(input: &str) -> String {
fn is_tag_start(c: Option<char>) -> bool {
c.is_some_and(|c| c.is_ascii_alphabetic() || matches!(c, '/' | '!' | '?'))
}
fn one_pass(input: &str) -> (String, bool) {
let mut out = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
let mut removed = false;
while let Some(c) = chars.next() {
if c == '<' && is_tag_start(chars.peek().copied()) {
removed = true;
for inner in chars.by_ref() {
if inner == '>' {
break;
}
}
} else {
out.push(c);
}
}
(out, removed)
}
fn has_tag(s: &str) -> bool {
let mut chars = s.chars().peekable();
while let Some(c) = chars.next() {
if c == '<' && is_tag_start(chars.peek().copied()) {
return true;
}
}
false
}
let mut current = input.to_string();
for _ in 0..4 {
let (next, removed) = one_pass(¤t);
current = next;
if !removed || !has_tag(¤t) {
return current;
}
}
current.replace('<', "")
}
fn sanitize(args: &[Value]) -> DbResult<Option<Value>> {
if args.is_empty() || args.len() > 2 {
return Err(DbError::ExecutionError(
"SANITIZE requires 1 or 2 arguments (text, options?)".to_string(),
));
}
match &args[0] {
Value::String(s) => {
let mut result = s.clone();
let options: Vec<String> = if args.len() == 2 {
match &args[1] {
Value::String(opt) => vec![opt.to_lowercase()],
Value::Array(arr) => arr
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_lowercase()))
.collect(),
_ => vec!["trim".to_string()],
}
} else {
vec!["trim".to_string()]
};
for opt in &options {
match opt.as_str() {
"trim" => result = result.trim().to_string(),
"lowercase" | "lower" => result = result.to_lowercase(),
"uppercase" | "upper" => result = result.to_uppercase(),
"alphanumeric" | "alnum" => {
result = result
.chars()
.filter(|c| c.is_alphanumeric() || c.is_whitespace())
.collect();
}
"alpha" => {
result = result
.chars()
.filter(|c| c.is_alphabetic() || c.is_whitespace())
.collect();
}
"numeric" | "digits" => {
result = result
.chars()
.filter(|c| c.is_numeric() || *c == '.' || *c == '-')
.collect();
}
"email" => {
result = result.trim().to_lowercase();
result = result
.chars()
.filter(|c| {
c.is_alphanumeric()
|| *c == '@'
|| *c == '.'
|| *c == '_'
|| *c == '-'
|| *c == '+'
})
.collect();
}
"url" => {
result = result.trim().to_string();
result = result
.chars()
.filter(|c| {
c.is_alphanumeric()
|| matches!(
*c,
'-' | '_'
| '.'
| '~'
| ':'
| '/'
| '?'
| '#'
| '['
| ']'
| '@'
| '!'
| '$'
| '&'
| '\''
| '('
| ')'
| '*'
| '+'
| ','
| ';'
| '='
| '%'
)
})
.collect();
}
"html" => {
result = result
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'");
}
"strip_html" => {
result = strip_html(&result);
}
"normalize_whitespace" | "normalize" => {
let parts: Vec<&str> = result.split_whitespace().collect();
result = parts.join(" ");
}
_ => {}
}
}
Ok(Some(Value::String(result)))
}
Value::Null => Ok(Some(Value::Null)),
_ => Err(DbError::ExecutionError(
"SANITIZE requires a string argument".to_string(),
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn call(name: &str, args: &[Value]) -> Value {
evaluate(name, args).unwrap().unwrap()
}
#[test]
fn highlight_empty_term_terminates() {
assert_eq!(call("HIGHLIGHT", &[json!("abc"), json!("")]), json!("abc"));
assert_eq!(
call("HIGHLIGHT", &[json!("abc"), json!(["", "b"])]),
json!("a<b>b</b>c")
);
}
#[test]
fn highlight_escapes_html() {
assert_eq!(
call("HIGHLIGHT", &[json!("<i>fox</i> & co"), json!("fox")]),
json!("<i><b>fox</b></i> & co")
);
assert_eq!(
call("HIGHLIGHT", &[json!("a<b"), json!("a<")]),
json!("<b>a<</b>b")
);
assert_eq!(
call(
"HIGHLIGHT",
&[json!("The quick brown fox"), json!(["quick", "FOX"])]
),
json!("The <b>quick</b> brown <b>fox</b>")
);
}
#[test]
fn sanitize_strip_html_removes_unclosed_and_rebuilt_tags() {
let strip = |s: &str| call("SANITIZE", &[json!(s), json!("strip_html")]);
assert_eq!(
strip("<script>alert('xss')</script>"),
json!("alert('xss')")
);
assert_eq!(strip("hello <script"), json!("hello "));
assert_eq!(strip("<b>bold</b> text"), json!("bold text"));
assert_eq!(strip("1 < 2 and 3 > 2"), json!("1 < 2 and 3 > 2"));
assert_eq!(strip("<<b>script>alert(1)"), json!("alert(1)"));
assert_eq!(strip("<scr<script>ipt>x"), json!("ipt>x"));
}
}