use anyhow::{bail, Result}; use regex::Regex; use scraper::{Html, Selector}; use serde_json::Value; pub struct Response { pub status: u16, pub body: String, pub headers: std::collections::HashMap, pub latency_ms: Option, pub cert_expiry_days: Option, pub cert_issuer: Option, } pub fn evaluate(query: &Value, response: &Response) -> Result { match query { Value::Object(map) => { if let Some(consider) = map.get("$consider") { let is_down = consider.as_str() == Some("down"); let rest: serde_json::Map = map.iter() .filter(|(k, _)| k.as_str() != "$consider") .map(|(k, v)| (k.clone(), v.clone())) .collect(); let matches = evaluate(&Value::Object(rest), response).unwrap_or(false); return Ok(if is_down { !matches } else { matches }); } if let Some(and) = map.get("$and") { let Value::Array(clauses) = and else { bail!("$and expects array") }; return Ok(clauses.iter().all(|c| evaluate(c, response).unwrap_or(false))); } if let Some(or) = map.get("$or") { let Value::Array(clauses) = or else { bail!("$or expects array") }; return Ok(clauses.iter().any(|c| evaluate(c, response).unwrap_or(false))); } if let Some(not) = map.get("$not") { return Ok(!evaluate(not, response)?); } if let Some(cond) = map.get("size") { let val = Value::Number(serde_json::Number::from(response.body.len())); return eval_condition(cond, &val, response); } if let Some(cond) = map.get("$time") { let val = Value::Number(serde_json::Number::from(response.latency_ms.unwrap_or(0))); return eval_condition(cond, &val, response); } if let Some(cond) = map.get("$certExpiry") { let val = Value::Number(serde_json::Number::from(response.cert_expiry_days.unwrap_or(0))); return eval_condition(cond, &val, response); } if let Some(cond) = map.get("$certIssuer") { let val = response.cert_issuer.as_ref() .map(|s| Value::String(s.clone())) .unwrap_or(Value::Null); return eval_condition(cond, &val, response); } if let Some(json_path_map) = map.get("$json") { let path_map = match json_path_map { Value::Object(m) => m, _ => bail!("$json expects an object {{ path: condition }}"), }; for (path, condition) in path_map { let resolved = resolve_json_path(&response.body, path); if !eval_condition(condition, &resolved, response)? { return Ok(false); } } return Ok(true); } if let Some(sel_map) = map.get("$html") { let sel_obj = match sel_map { Value::Object(m) => m, _ => bail!("$html expects an object {{ selector: condition }}"), }; let doc = Html::parse_document(&response.body); for (selector, condition) in sel_obj { let sel = Selector::parse(selector) .map_err(|_| anyhow::anyhow!("Invalid CSS selector: {selector}"))?; let selected = doc.select(&sel).next() .map(|el| Value::String(el.text().collect::().trim().to_string())) .unwrap_or(Value::Null); if !eval_condition(condition, &selected, response)? { return Ok(false); } } return Ok(true); } for (field, condition) in map { let field_val = resolve_field(field, response); if !eval_condition(condition, &field_val, response)? { return Ok(false); } } Ok(true) } _ => bail!("Query must be an object"), } } fn resolve_field(field: &str, r: &Response) -> Value { match field { "status" | "status_code" => Value::Number(r.status.into()), "body" => Value::String(r.body.clone()), f if f.starts_with("headers.") => { let key = f.trim_start_matches("headers.").to_lowercase(); r.headers.get(&key) .map(|v| Value::String(v.clone())) .unwrap_or(Value::Null) } _ => Value::Null, } } fn resolve_json_path(body: &str, path: &str) -> Value { let obj: Value = match serde_json::from_str(body) { Ok(v) => v, Err(_) => return Value::Null, }; let path = path.trim_start_matches("$").trim_start_matches("."); if path.is_empty() { return obj; } let mut current = &obj; for part in path.split('.') { if let Some(idx_start) = part.find('[') { let key = &part[..idx_start]; if !key.is_empty() { current = match current.get(key) { Some(v) => v, None => return Value::Null, }; } let idx_str = part[idx_start + 1..].trim_end_matches(']'); if let Ok(idx) = idx_str.parse::() { current = match current.get(idx) { Some(v) => v, None => return Value::Null, }; } else { return Value::Null; } } else { current = match current.get(part) { Some(v) => v, None => return Value::Null, }; } } current.clone() } fn eval_condition(condition: &Value, field_val: &Value, response: &Response) -> Result { match condition { Value::Number(n) => Ok(field_val.as_f64() == n.as_f64()), Value::String(s) => Ok(field_val.as_str() == Some(s.as_str())), Value::Bool(b) => Ok(field_val.as_bool() == Some(*b)), Value::Object(ops) => { for (op, val) in ops { if !eval_op(op, field_val, val, response)? { return Ok(false); } } Ok(true) } _ => Ok(true), } } fn eval_op(op: &str, field_val: &Value, val: &Value, _response: &Response) -> Result { let ok = match op { "$eq" => field_val == val, "$ne" => field_val != val, "$gt" => cmp_num(field_val, val, |a,b| a > b), "$ge" => cmp_num(field_val, val, |a,b| a >= b), "$lt" => cmp_num(field_val, val, |a,b| a < b), "$le" => cmp_num(field_val, val, |a,b| a <= b), "$co" => { let needle = val.as_str().unwrap_or(""); field_val.as_str().map(|s| s.contains(needle)).unwrap_or(false) } "$sw" => { let needle = val.as_str().unwrap_or(""); field_val.as_str().map(|s| s.starts_with(needle)).unwrap_or(false) } "$ew" => { let needle = val.as_str().unwrap_or(""); field_val.as_str().map(|s| s.ends_with(needle)).unwrap_or(false) } "$re" => { let pattern = val.as_str().unwrap_or(""); if pattern.len() > 200 { return Ok(false); } let re = match Regex::new(pattern) { Ok(r) => r, Err(_) => return Ok(false), }; field_val.as_str().map(|s| re.is_match(s)).unwrap_or(false) } "$exists" => { let should_exist = val.as_bool().unwrap_or(true); let exists = !field_val.is_null(); exists == should_exist } _ => { tracing::warn!("Unknown query operator: {op}"); false } }; Ok(ok) } fn cmp_num(a: &Value, b: &Value, f: impl Fn(f64, f64) -> bool) -> bool { match (a.as_f64(), b.as_f64()) { (Some(x), Some(y)) => f(x, y), _ => false, } } fn css_select(html: &str, selector: &str) -> Option { let doc = Html::parse_document(html); let sel = Selector::parse(selector).ok()?; doc.select(&sel).next().map(|el| el.text().collect::().trim().to_string()) }