220 lines
8.3 KiB
Rust
220 lines
8.3 KiB
Rust
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<String, String>,
|
|
pub latency_ms: Option<u64>,
|
|
pub cert_expiry_days: Option<i64>,
|
|
pub cert_issuer: Option<String>,
|
|
}
|
|
|
|
pub fn evaluate(query: &Value, response: &Response) -> Result<bool> {
|
|
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<String, Value> = 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::<String>().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::<usize>() {
|
|
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<bool> {
|
|
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<bool> {
|
|
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<String> {
|
|
let doc = Html::parse_document(html);
|
|
let sel = Selector::parse(selector).ok()?;
|
|
doc.select(&sel).next().map(|el| el.text().collect::<String>().trim().to_string())
|
|
}
|