mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-03 01:40:21 +08:00
1786 lines
61 KiB
Rust
1786 lines
61 KiB
Rust
use std::{
|
|
collections::{BTreeMap, HashSet},
|
|
sync::OnceLock,
|
|
};
|
|
|
|
use regex::{Regex, RegexBuilder};
|
|
use serde_json::{Map, Value};
|
|
|
|
const ORIGINAL_PLACEHOLDER: &str = "{{$original}}";
|
|
const ITEM_PREFIX: &str = "$item.";
|
|
const ITEM_EXACT: &str = "$item";
|
|
const CONDITION_SOURCES: &[&str] = &["body", "request_headers", "headers", "original", "current"];
|
|
const CONDITION_TYPE_VALUES: &[&str] = &["string", "number", "boolean", "array", "object", "null"];
|
|
|
|
static RANGE_RE: OnceLock<Regex> = OnceLock::new();
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum ConditionHeaders<'a> {
|
|
Request(&'a http::HeaderMap),
|
|
Map(&'a BTreeMap<String, String>),
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
enum BodyPathSegment {
|
|
Key(String),
|
|
Index(isize),
|
|
WildcardSlice(Option<usize>, Option<usize>),
|
|
}
|
|
|
|
pub fn header_rules_are_locally_supported(rules: Option<&Value>) -> bool {
|
|
let Some(rules) = rules else {
|
|
return true;
|
|
};
|
|
rules.is_array()
|
|
}
|
|
|
|
pub fn header_rules_have_enabled_rules(rules: Option<&Value>) -> bool {
|
|
let Some(rules) = rules.and_then(Value::as_array) else {
|
|
return false;
|
|
};
|
|
|
|
rules
|
|
.iter()
|
|
.any(|rule| rule.as_object().is_some_and(header_rule_is_enabled))
|
|
}
|
|
|
|
pub fn apply_local_header_rules(
|
|
headers: &mut BTreeMap<String, String>,
|
|
rules: Option<&Value>,
|
|
protected_keys: &[&str],
|
|
body: &Value,
|
|
original_body: Option<&Value>,
|
|
) -> bool {
|
|
apply_local_header_rules_inner(headers, rules, protected_keys, body, original_body, None)
|
|
}
|
|
|
|
pub fn apply_local_header_rules_with_request_headers(
|
|
headers: &mut BTreeMap<String, String>,
|
|
rules: Option<&Value>,
|
|
protected_keys: &[&str],
|
|
body: &Value,
|
|
original_body: Option<&Value>,
|
|
request_headers: Option<&http::HeaderMap>,
|
|
) -> bool {
|
|
apply_local_header_rules_inner(
|
|
headers,
|
|
rules,
|
|
protected_keys,
|
|
body,
|
|
original_body,
|
|
request_headers.map(ConditionHeaders::Request),
|
|
)
|
|
}
|
|
|
|
fn apply_local_header_rules_inner(
|
|
headers: &mut BTreeMap<String, String>,
|
|
rules: Option<&Value>,
|
|
protected_keys: &[&str],
|
|
body: &Value,
|
|
original_body: Option<&Value>,
|
|
request_headers: Option<ConditionHeaders<'_>>,
|
|
) -> bool {
|
|
let Some(rules) = rules else {
|
|
return true;
|
|
};
|
|
let Some(rules) = rules.as_array() else {
|
|
return false;
|
|
};
|
|
let protected_keys: HashSet<String> = protected_keys
|
|
.iter()
|
|
.map(|value| value.trim().to_ascii_lowercase())
|
|
.collect();
|
|
|
|
for rule in rules {
|
|
let Some(rule) = rule.as_object() else {
|
|
continue;
|
|
};
|
|
if !header_rule_is_enabled(rule) {
|
|
continue;
|
|
}
|
|
if let Some(condition) = rule.get("condition").filter(|value| !value.is_null()) {
|
|
if !condition_is_locally_supported(condition) {
|
|
continue;
|
|
}
|
|
let condition_headers = request_headers.or(Some(ConditionHeaders::Map(&*headers)));
|
|
if !evaluate_local_condition(body, condition, original_body, condition_headers) {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
match rule
|
|
.get("action")
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.map(str::to_ascii_lowercase)
|
|
.as_deref()
|
|
{
|
|
Some("set") => {
|
|
let Some(key) = rule.get("key").and_then(Value::as_str).map(str::trim) else {
|
|
continue;
|
|
};
|
|
let key = key.to_ascii_lowercase();
|
|
if key.is_empty() || protected_keys.contains(&key) {
|
|
continue;
|
|
}
|
|
let value = rule
|
|
.get("value")
|
|
.map(header_rule_value_to_string)
|
|
.unwrap_or_default();
|
|
headers.insert(key, value);
|
|
}
|
|
Some("drop") => {
|
|
let Some(key) = rule.get("key").and_then(Value::as_str).map(str::trim) else {
|
|
continue;
|
|
};
|
|
let key = key.to_ascii_lowercase();
|
|
if !key.is_empty() && !protected_keys.contains(&key) {
|
|
headers.remove(&key);
|
|
}
|
|
}
|
|
Some("rename") => {
|
|
let Some(from) = rule.get("from").and_then(Value::as_str).map(str::trim) else {
|
|
continue;
|
|
};
|
|
let Some(to) = rule.get("to").and_then(Value::as_str).map(str::trim) else {
|
|
continue;
|
|
};
|
|
let from = from.to_ascii_lowercase();
|
|
let to = to.to_ascii_lowercase();
|
|
if from.is_empty()
|
|
|| to.is_empty()
|
|
|| protected_keys.contains(&from)
|
|
|| protected_keys.contains(&to)
|
|
{
|
|
continue;
|
|
}
|
|
if let Some(value) = headers.remove(&from) {
|
|
headers.insert(to, value);
|
|
}
|
|
}
|
|
_ => continue,
|
|
}
|
|
}
|
|
|
|
true
|
|
}
|
|
|
|
fn header_rule_value_to_string(value: &Value) -> String {
|
|
value
|
|
.as_str()
|
|
.map(str::to_string)
|
|
.unwrap_or_else(|| value.to_string())
|
|
}
|
|
|
|
fn header_rule_is_enabled(rule: &Map<String, Value>) -> bool {
|
|
rule.get("enabled").and_then(Value::as_bool) != Some(false)
|
|
}
|
|
|
|
pub fn body_rules_are_locally_supported(rules: Option<&Value>) -> bool {
|
|
let Some(rules) = rules else {
|
|
return true;
|
|
};
|
|
rules.is_array()
|
|
}
|
|
|
|
pub fn body_rules_have_enabled_rules(rules: Option<&Value>) -> bool {
|
|
let Some(rules) = rules.and_then(Value::as_array) else {
|
|
return false;
|
|
};
|
|
|
|
rules
|
|
.iter()
|
|
.any(|rule| rule.as_object().is_some_and(body_rule_is_enabled))
|
|
}
|
|
|
|
pub fn body_rules_handle_path(rules: Option<&Value>, path: &str) -> bool {
|
|
let Some(target_path) = parse_body_path(path) else {
|
|
return false;
|
|
};
|
|
let Some(rules) = rules.and_then(Value::as_array) else {
|
|
return false;
|
|
};
|
|
|
|
rules.iter().any(|rule| {
|
|
let Some(rule) = rule.as_object() else {
|
|
return false;
|
|
};
|
|
if !body_rule_is_enabled(rule) {
|
|
return false;
|
|
}
|
|
match rule
|
|
.get("action")
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.map(str::to_ascii_lowercase)
|
|
.as_deref()
|
|
{
|
|
Some("set")
|
|
| Some("drop")
|
|
| Some("append")
|
|
| Some("insert")
|
|
| Some("regex_replace") => rule
|
|
.get("path")
|
|
.and_then(Value::as_str)
|
|
.and_then(parse_body_path)
|
|
.is_some_and(|candidate| path_matches(&candidate, &target_path)),
|
|
Some("rename") => {
|
|
rule.get("from")
|
|
.and_then(Value::as_str)
|
|
.and_then(parse_body_path)
|
|
.is_some_and(|candidate| path_matches(&candidate, &target_path))
|
|
|| rule
|
|
.get("to")
|
|
.and_then(Value::as_str)
|
|
.and_then(parse_body_path)
|
|
.is_some_and(|candidate| path_matches(&candidate, &target_path))
|
|
}
|
|
_ => false,
|
|
}
|
|
})
|
|
}
|
|
|
|
pub fn apply_local_body_rules(
|
|
body: &mut Value,
|
|
rules: Option<&Value>,
|
|
original_body: Option<&Value>,
|
|
) -> bool {
|
|
apply_local_body_rules_inner(body, rules, original_body, None)
|
|
}
|
|
|
|
pub fn apply_local_body_rules_with_request_headers(
|
|
body: &mut Value,
|
|
rules: Option<&Value>,
|
|
original_body: Option<&Value>,
|
|
request_headers: Option<&http::HeaderMap>,
|
|
) -> bool {
|
|
apply_local_body_rules_inner(
|
|
body,
|
|
rules,
|
|
original_body,
|
|
request_headers.map(ConditionHeaders::Request),
|
|
)
|
|
}
|
|
|
|
fn apply_local_body_rules_inner(
|
|
body: &mut Value,
|
|
rules: Option<&Value>,
|
|
original_body: Option<&Value>,
|
|
request_headers: Option<ConditionHeaders<'_>>,
|
|
) -> bool {
|
|
let Some(rules) = rules else {
|
|
return true;
|
|
};
|
|
let Some(rules) = rules.as_array() else {
|
|
return false;
|
|
};
|
|
|
|
for rule in rules {
|
|
let Some(rule) = rule.as_object() else {
|
|
continue;
|
|
};
|
|
if !body_rule_is_enabled(rule) {
|
|
continue;
|
|
}
|
|
|
|
let condition = rule.get("condition").filter(|value| !value.is_null());
|
|
let item_condition = condition.is_some_and(condition_has_item_ref);
|
|
if let Some(condition) = condition {
|
|
if !condition_is_locally_supported(condition) {
|
|
continue;
|
|
}
|
|
if !item_condition
|
|
&& !evaluate_local_condition(body, condition, original_body, request_headers)
|
|
{
|
|
continue;
|
|
}
|
|
}
|
|
|
|
match rule
|
|
.get("action")
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.map(str::to_ascii_lowercase)
|
|
.as_deref()
|
|
{
|
|
Some("set") => {
|
|
let Some(path) = rule
|
|
.get("path")
|
|
.and_then(Value::as_str)
|
|
.and_then(parse_body_path)
|
|
else {
|
|
continue;
|
|
};
|
|
let targets = iter_wildcard_targets(
|
|
body,
|
|
&path,
|
|
WildcardTargetOptions {
|
|
condition,
|
|
item_condition,
|
|
original_body,
|
|
request_headers,
|
|
require_leaf: false,
|
|
reverse: false,
|
|
},
|
|
);
|
|
let value_template = rule.get("value").cloned().unwrap_or(Value::Null);
|
|
for target_path in targets {
|
|
let value = if contains_original_placeholder(&value_template) {
|
|
let original = get_nested_value(body, &target_path);
|
|
resolve_original_placeholder(&value_template, original.as_ref())
|
|
} else {
|
|
value_template.clone()
|
|
};
|
|
let _ = set_nested_value(body, &target_path, value);
|
|
}
|
|
}
|
|
Some("drop") => {
|
|
let Some(path) = rule
|
|
.get("path")
|
|
.and_then(Value::as_str)
|
|
.and_then(parse_body_path)
|
|
else {
|
|
continue;
|
|
};
|
|
for target_path in iter_wildcard_targets(
|
|
body,
|
|
&path,
|
|
WildcardTargetOptions {
|
|
condition,
|
|
item_condition,
|
|
original_body,
|
|
request_headers,
|
|
require_leaf: true,
|
|
reverse: true,
|
|
},
|
|
) {
|
|
let _ = delete_nested_value(body, &target_path);
|
|
}
|
|
}
|
|
Some("rename") => {
|
|
let Some(from) = rule
|
|
.get("from")
|
|
.and_then(Value::as_str)
|
|
.and_then(parse_body_path)
|
|
else {
|
|
continue;
|
|
};
|
|
let Some(to) = rule
|
|
.get("to")
|
|
.and_then(Value::as_str)
|
|
.and_then(parse_body_path)
|
|
else {
|
|
continue;
|
|
};
|
|
if has_wildcard(&from) || has_wildcard(&to) {
|
|
continue;
|
|
}
|
|
let _ = rename_nested_value(body, &from, &to);
|
|
}
|
|
Some("append") => {
|
|
let Some(path) = rule
|
|
.get("path")
|
|
.and_then(Value::as_str)
|
|
.and_then(parse_body_path)
|
|
else {
|
|
continue;
|
|
};
|
|
let value = rule.get("value").cloned().unwrap_or(Value::Null);
|
|
for target_path in iter_wildcard_targets(
|
|
body,
|
|
&path,
|
|
WildcardTargetOptions {
|
|
condition,
|
|
item_condition,
|
|
original_body,
|
|
request_headers,
|
|
require_leaf: true,
|
|
reverse: false,
|
|
},
|
|
) {
|
|
if let Some(target) = get_nested_value_mut(body, &target_path) {
|
|
if let Some(values) = target.as_array_mut() {
|
|
values.push(value.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Some("insert") => {
|
|
let Some(path) = rule
|
|
.get("path")
|
|
.and_then(Value::as_str)
|
|
.and_then(parse_body_path)
|
|
else {
|
|
continue;
|
|
};
|
|
let Some(index) = rule.get("index").and_then(parse_insert_index) else {
|
|
continue;
|
|
};
|
|
if has_wildcard(&path) {
|
|
continue;
|
|
}
|
|
let value = rule.get("value").cloned().unwrap_or(Value::Null);
|
|
if let Some(target) = get_nested_value_mut(body, &path) {
|
|
if let Some(values) = target.as_array_mut() {
|
|
let insert_index = normalize_insert_index(values.len(), index);
|
|
values.insert(insert_index, value);
|
|
}
|
|
}
|
|
}
|
|
Some("regex_replace") => {
|
|
let Some(path) = rule
|
|
.get("path")
|
|
.and_then(Value::as_str)
|
|
.and_then(parse_body_path)
|
|
else {
|
|
continue;
|
|
};
|
|
let Some(pattern) = rule.get("pattern").and_then(Value::as_str) else {
|
|
continue;
|
|
};
|
|
let Some(replacement) = rule.get("replacement").and_then(Value::as_str) else {
|
|
continue;
|
|
};
|
|
let flags = rule.get("flags").and_then(Value::as_str).unwrap_or("");
|
|
let count = rule
|
|
.get("count")
|
|
.and_then(parse_non_negative_count)
|
|
.unwrap_or(0);
|
|
if pattern.is_empty() {
|
|
continue;
|
|
}
|
|
let Some(pattern) = compile_regex(pattern, flags) else {
|
|
continue;
|
|
};
|
|
for target_path in iter_wildcard_targets(
|
|
body,
|
|
&path,
|
|
WildcardTargetOptions {
|
|
condition,
|
|
item_condition,
|
|
original_body,
|
|
request_headers,
|
|
require_leaf: true,
|
|
reverse: false,
|
|
},
|
|
) {
|
|
if let Some(target) = get_nested_value_mut(body, &target_path) {
|
|
let Some(current) = target.as_str().map(str::to_owned) else {
|
|
continue;
|
|
};
|
|
let replaced = if count == 0 {
|
|
pattern.replace_all(¤t, replacement).to_string()
|
|
} else {
|
|
pattern.replacen(¤t, count, replacement).to_string()
|
|
};
|
|
*target = Value::String(replaced);
|
|
}
|
|
}
|
|
}
|
|
_ => continue,
|
|
}
|
|
}
|
|
|
|
true
|
|
}
|
|
|
|
fn body_rule_is_enabled(rule: &Map<String, Value>) -> bool {
|
|
rule.get("enabled").and_then(Value::as_bool) != Some(false)
|
|
}
|
|
|
|
fn condition_is_locally_supported(condition: &Value) -> bool {
|
|
let Some(condition) = condition.as_object() else {
|
|
return false;
|
|
};
|
|
|
|
if let Some(children) = condition.get("all").and_then(Value::as_array) {
|
|
return !children.is_empty() && children.iter().all(condition_is_locally_supported);
|
|
}
|
|
if let Some(children) = condition.get("any").and_then(Value::as_array) {
|
|
return !children.is_empty() && children.iter().all(condition_is_locally_supported);
|
|
}
|
|
|
|
let source = condition
|
|
.get("source")
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.unwrap_or("body");
|
|
if !CONDITION_SOURCES.contains(&source) {
|
|
return false;
|
|
}
|
|
|
|
let Some(op) = condition.get("op").and_then(Value::as_str).map(str::trim) else {
|
|
return false;
|
|
};
|
|
let Some(path) = condition.get("path").and_then(Value::as_str) else {
|
|
return false;
|
|
};
|
|
if path.trim().is_empty() {
|
|
return false;
|
|
}
|
|
if !condition_source_is_headers(source) && parse_body_path(path).is_none() {
|
|
return false;
|
|
}
|
|
|
|
match op {
|
|
"exists" | "not_exists" | "eq" | "neq" => true,
|
|
"gt" | "lt" | "gte" | "lte" => condition
|
|
.get("value")
|
|
.is_some_and(|value| value.as_f64().is_some() && !value.is_boolean()),
|
|
"starts_with" | "ends_with" | "matches" => condition
|
|
.get("value")
|
|
.and_then(Value::as_str)
|
|
.is_some_and(|value| {
|
|
if op == "matches" {
|
|
Regex::new(value).is_ok()
|
|
} else {
|
|
true
|
|
}
|
|
}),
|
|
"contains" => condition.get("value").is_some(),
|
|
"in" => condition.get("value").is_some_and(Value::is_array),
|
|
"type_is" => condition
|
|
.get("value")
|
|
.and_then(Value::as_str)
|
|
.is_some_and(|value| CONDITION_TYPE_VALUES.contains(&value)),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn evaluate_local_condition(
|
|
body: &Value,
|
|
condition: &Value,
|
|
original_body: Option<&Value>,
|
|
request_headers: Option<ConditionHeaders<'_>>,
|
|
) -> bool {
|
|
let Some(condition) = condition.as_object() else {
|
|
return false;
|
|
};
|
|
|
|
if let Some(children) = condition.get("all").and_then(Value::as_array) {
|
|
return !children.is_empty()
|
|
&& children.iter().all(|child| {
|
|
evaluate_local_condition(body, child, original_body, request_headers)
|
|
});
|
|
}
|
|
if let Some(children) = condition.get("any").and_then(Value::as_array) {
|
|
return !children.is_empty()
|
|
&& children.iter().any(|child| {
|
|
evaluate_local_condition(body, child, original_body, request_headers)
|
|
});
|
|
}
|
|
|
|
let source = condition
|
|
.get("source")
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.unwrap_or("body");
|
|
|
|
let Some(op) = condition.get("op").and_then(Value::as_str).map(str::trim) else {
|
|
return false;
|
|
};
|
|
let Some(path) = condition.get("path").and_then(Value::as_str).map(str::trim) else {
|
|
return false;
|
|
};
|
|
|
|
let current_value = if condition_source_is_headers(source) {
|
|
request_headers.and_then(|headers| get_header_condition_value(headers, path))
|
|
} else {
|
|
let target = if source.eq_ignore_ascii_case("original") {
|
|
original_body.unwrap_or(body)
|
|
} else {
|
|
body
|
|
};
|
|
parse_body_path(path).and_then(|path| get_nested_value(target, &path))
|
|
};
|
|
if op == "exists" {
|
|
return current_value.is_some();
|
|
}
|
|
if op == "not_exists" {
|
|
return current_value.is_none();
|
|
}
|
|
|
|
let Some(current_value) = current_value else {
|
|
return false;
|
|
};
|
|
let expected = condition.get("value");
|
|
|
|
match op {
|
|
"eq" => expected == Some(¤t_value),
|
|
"neq" => expected != Some(¤t_value),
|
|
"gt" | "lt" | "gte" | "lte" => {
|
|
let Some(current) = json_number(¤t_value) else {
|
|
return false;
|
|
};
|
|
let Some(expected) = expected.and_then(json_number) else {
|
|
return false;
|
|
};
|
|
match op {
|
|
"gt" => current > expected,
|
|
"lt" => current < expected,
|
|
"gte" => current >= expected,
|
|
"lte" => current <= expected,
|
|
_ => false,
|
|
}
|
|
}
|
|
"starts_with" => current_value
|
|
.as_str()
|
|
.zip(expected.and_then(Value::as_str))
|
|
.is_some_and(|(current, expected)| current.starts_with(expected)),
|
|
"ends_with" => current_value
|
|
.as_str()
|
|
.zip(expected.and_then(Value::as_str))
|
|
.is_some_and(|(current, expected)| current.ends_with(expected)),
|
|
"contains" => match (current_value, expected) {
|
|
(Value::String(current), Some(Value::String(expected))) => current.contains(expected),
|
|
(Value::Array(current), Some(expected)) => {
|
|
current.iter().any(|value| value == expected)
|
|
}
|
|
_ => false,
|
|
},
|
|
"matches" => current_value
|
|
.as_str()
|
|
.zip(expected.and_then(Value::as_str))
|
|
.is_some_and(|(current, expected)| {
|
|
Regex::new(expected)
|
|
.map(|pattern| pattern.is_match(current))
|
|
.unwrap_or(false)
|
|
}),
|
|
"in" => expected
|
|
.and_then(Value::as_array)
|
|
.is_some_and(|values| values.iter().any(|value| value == ¤t_value)),
|
|
"type_is" => expected
|
|
.and_then(Value::as_str)
|
|
.is_some_and(|expected| match expected {
|
|
"string" => current_value.is_string(),
|
|
"number" => current_value.as_f64().is_some() && !current_value.is_boolean(),
|
|
"boolean" => current_value.is_boolean(),
|
|
"array" => current_value.is_array(),
|
|
"object" => current_value.is_object(),
|
|
"null" => current_value.is_null(),
|
|
_ => false,
|
|
}),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn condition_source_is_headers(source: &str) -> bool {
|
|
source.eq_ignore_ascii_case("request_headers") || source.eq_ignore_ascii_case("headers")
|
|
}
|
|
|
|
fn get_header_condition_value(headers: ConditionHeaders<'_>, path: &str) -> Option<Value> {
|
|
let key = path.trim().to_ascii_lowercase();
|
|
if key.is_empty() {
|
|
return None;
|
|
}
|
|
match headers {
|
|
ConditionHeaders::Request(headers) => headers
|
|
.get(key.as_str())
|
|
.and_then(|value| value.to_str().ok())
|
|
.map(|value| Value::String(value.trim().to_string())),
|
|
ConditionHeaders::Map(headers) => headers.get(&key).cloned().map(Value::String),
|
|
}
|
|
}
|
|
|
|
fn json_number(value: &Value) -> Option<f64> {
|
|
value.as_f64().filter(|_| !value.is_boolean())
|
|
}
|
|
|
|
fn parse_body_path(path: &str) -> Option<Vec<BodyPathSegment>> {
|
|
let raw = path.trim();
|
|
if raw.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let chars: Vec<char> = raw.chars().collect();
|
|
let mut parts = Vec::new();
|
|
let mut current = String::new();
|
|
let mut expect_key = true;
|
|
let mut index = 0usize;
|
|
|
|
while index < chars.len() {
|
|
let ch = chars[index];
|
|
if ch == '\\' && chars.get(index + 1).copied() == Some('.') {
|
|
current.push('.');
|
|
expect_key = false;
|
|
index += 2;
|
|
continue;
|
|
}
|
|
|
|
if ch == '.' {
|
|
if !current.is_empty() {
|
|
parts.push(BodyPathSegment::Key(std::mem::take(&mut current)));
|
|
} else if expect_key {
|
|
return None;
|
|
}
|
|
expect_key = true;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if ch == '[' {
|
|
if !current.is_empty() {
|
|
parts.push(BodyPathSegment::Key(std::mem::take(&mut current)));
|
|
}
|
|
|
|
let mut close_index = index + 1;
|
|
while close_index < chars.len() && chars[close_index] != ']' {
|
|
close_index += 1;
|
|
}
|
|
if close_index >= chars.len() {
|
|
return None;
|
|
}
|
|
|
|
let inner = chars[index + 1..close_index]
|
|
.iter()
|
|
.collect::<String>()
|
|
.trim()
|
|
.to_string();
|
|
if inner.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
if inner == "*" {
|
|
parts.push(BodyPathSegment::WildcardSlice(None, None));
|
|
} else if let Some((start, end)) = parse_path_range(&inner) {
|
|
parts.push(BodyPathSegment::WildcardSlice(Some(start), Some(end)));
|
|
} else {
|
|
let Ok(index_value) = inner.parse::<isize>() else {
|
|
return None;
|
|
};
|
|
parts.push(BodyPathSegment::Index(index_value));
|
|
}
|
|
|
|
expect_key = false;
|
|
index = close_index + 1;
|
|
continue;
|
|
}
|
|
|
|
current.push(ch);
|
|
expect_key = false;
|
|
index += 1;
|
|
}
|
|
|
|
if !current.is_empty() {
|
|
parts.push(BodyPathSegment::Key(current));
|
|
} else if expect_key {
|
|
return None;
|
|
}
|
|
|
|
(!parts.is_empty()).then_some(parts)
|
|
}
|
|
|
|
fn parse_path_range(raw: &str) -> Option<(usize, usize)> {
|
|
let captures = range_regex().captures(raw)?;
|
|
let start = captures.get(1)?.as_str().parse::<usize>().ok()?;
|
|
let end = captures.get(2)?.as_str().parse::<usize>().ok()?;
|
|
Some((start, end))
|
|
}
|
|
|
|
fn range_regex() -> &'static Regex {
|
|
RANGE_RE.get_or_init(|| Regex::new(r"^(\d+)\s*-\s*(\d+)$").expect("valid range regex"))
|
|
}
|
|
|
|
fn has_wildcard(path: &[BodyPathSegment]) -> bool {
|
|
path.iter()
|
|
.any(|segment| matches!(segment, BodyPathSegment::WildcardSlice(..)))
|
|
}
|
|
|
|
fn wildcard_indices(
|
|
len: usize,
|
|
start: Option<usize>,
|
|
end: Option<usize>,
|
|
) -> Box<dyn Iterator<Item = usize>> {
|
|
if len == 0 {
|
|
return Box::new(std::iter::empty());
|
|
}
|
|
match (start, end) {
|
|
(None, None) => Box::new(0..len),
|
|
(Some(start), Some(end)) => {
|
|
let start = start.min(len);
|
|
let end = end.min(len.saturating_sub(1));
|
|
if start > end {
|
|
Box::new(std::iter::empty())
|
|
} else {
|
|
Box::new(start..(end + 1))
|
|
}
|
|
}
|
|
_ => Box::new(std::iter::empty()),
|
|
}
|
|
}
|
|
|
|
fn wildcard_matches_index(start: Option<usize>, end: Option<usize>, index: isize) -> bool {
|
|
match (start, end) {
|
|
(None, None) => true,
|
|
(Some(start), Some(end)) => usize::try_from(index)
|
|
.ok()
|
|
.is_some_and(|index| index >= start && index <= end),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn path_matches(candidate: &[BodyPathSegment], target: &[BodyPathSegment]) -> bool {
|
|
candidate.len() == target.len()
|
|
&& candidate
|
|
.iter()
|
|
.zip(target)
|
|
.all(|(candidate, target)| match (candidate, target) {
|
|
(BodyPathSegment::Key(lhs), BodyPathSegment::Key(rhs)) => lhs == rhs,
|
|
(BodyPathSegment::Index(lhs), BodyPathSegment::Index(rhs)) => lhs == rhs,
|
|
(BodyPathSegment::WildcardSlice(start, end), BodyPathSegment::Index(index))
|
|
| (BodyPathSegment::Index(index), BodyPathSegment::WildcardSlice(start, end)) => {
|
|
wildcard_matches_index(*start, *end, *index)
|
|
}
|
|
(
|
|
BodyPathSegment::WildcardSlice(lhs_start, lhs_end),
|
|
BodyPathSegment::WildcardSlice(rhs_start, rhs_end),
|
|
) => lhs_start == rhs_start && lhs_end == rhs_end,
|
|
_ => false,
|
|
})
|
|
}
|
|
|
|
fn segments_to_path(path: &[BodyPathSegment]) -> String {
|
|
let mut parts = Vec::new();
|
|
for segment in path {
|
|
match segment {
|
|
BodyPathSegment::Index(index) => parts.push(format!("[{index}]")),
|
|
BodyPathSegment::WildcardSlice(None, None) => parts.push("[*]".to_string()),
|
|
BodyPathSegment::WildcardSlice(Some(start), Some(end)) => {
|
|
parts.push(format!("[{start}-{end}]"));
|
|
}
|
|
BodyPathSegment::WildcardSlice(..) => return String::new(),
|
|
BodyPathSegment::Key(key) => {
|
|
let escaped = key.replace('.', "\\.");
|
|
if parts
|
|
.last()
|
|
.is_some_and(|part: &String| !part.ends_with(']'))
|
|
{
|
|
parts.push(format!(".{escaped}"));
|
|
} else {
|
|
parts.push(escaped);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
parts.join("")
|
|
}
|
|
|
|
fn contains_original_placeholder(value: &Value) -> bool {
|
|
match value {
|
|
Value::String(value) => value.contains(ORIGINAL_PLACEHOLDER),
|
|
Value::Array(items) => items.iter().any(contains_original_placeholder),
|
|
Value::Object(items) => items.values().any(contains_original_placeholder),
|
|
_ => false,
|
|
}
|
|
}
|
|
|
|
fn resolve_original_placeholder(template: &Value, original: Option<&Value>) -> Value {
|
|
match template {
|
|
Value::String(value) => {
|
|
if value == ORIGINAL_PLACEHOLDER {
|
|
return original.cloned().unwrap_or(Value::Null);
|
|
}
|
|
if value.contains(ORIGINAL_PLACEHOLDER) {
|
|
let replacement = original.map_or_else(|| "null".to_string(), placeholder_string);
|
|
return Value::String(value.replace(ORIGINAL_PLACEHOLDER, &replacement));
|
|
}
|
|
Value::String(value.clone())
|
|
}
|
|
Value::Array(items) => Value::Array(
|
|
items
|
|
.iter()
|
|
.map(|item| resolve_original_placeholder(item, original))
|
|
.collect(),
|
|
),
|
|
Value::Object(items) => Value::Object(
|
|
items
|
|
.iter()
|
|
.map(|(key, value)| (key.clone(), resolve_original_placeholder(value, original)))
|
|
.collect(),
|
|
),
|
|
_ => template.clone(),
|
|
}
|
|
}
|
|
|
|
fn placeholder_string(value: &Value) -> String {
|
|
match value {
|
|
Value::String(value) => value.clone(),
|
|
_ => value.to_string(),
|
|
}
|
|
}
|
|
|
|
fn resolve_index(len: usize, index: isize) -> Option<usize> {
|
|
if index >= 0 {
|
|
((index as usize) < len).then_some(index as usize)
|
|
} else {
|
|
let resolved = len as isize + index;
|
|
(resolved >= 0).then_some(resolved as usize)
|
|
}
|
|
}
|
|
|
|
fn normalize_insert_index(len: usize, index: isize) -> usize {
|
|
if index >= 0 {
|
|
(index as usize).min(len)
|
|
} else {
|
|
let resolved = len as isize + index;
|
|
resolved.max(0) as usize
|
|
}
|
|
}
|
|
|
|
fn expand_wildcard_paths(
|
|
value: &Value,
|
|
path: &[BodyPathSegment],
|
|
require_leaf: bool,
|
|
) -> Vec<Vec<BodyPathSegment>> {
|
|
let mut result = Vec::new();
|
|
let mut prefix = Vec::new();
|
|
expand_wildcard_paths_recursive(value, path, require_leaf, 0, &mut prefix, &mut result);
|
|
result
|
|
}
|
|
|
|
fn expand_wildcard_paths_recursive(
|
|
current: &Value,
|
|
path: &[BodyPathSegment],
|
|
require_leaf: bool,
|
|
offset: usize,
|
|
prefix: &mut Vec<BodyPathSegment>,
|
|
result: &mut Vec<Vec<BodyPathSegment>>,
|
|
) {
|
|
if offset == path.len() {
|
|
result.push(prefix.clone());
|
|
return;
|
|
}
|
|
|
|
let segment = &path[offset];
|
|
let is_last = offset == path.len() - 1;
|
|
match segment {
|
|
BodyPathSegment::WildcardSlice(start, end) => {
|
|
let Some(values) = current.as_array() else {
|
|
return;
|
|
};
|
|
for index in wildcard_indices(values.len(), *start, *end) {
|
|
prefix.push(BodyPathSegment::Index(index as isize));
|
|
expand_wildcard_paths_recursive(
|
|
&values[index],
|
|
path,
|
|
require_leaf,
|
|
offset + 1,
|
|
prefix,
|
|
result,
|
|
);
|
|
prefix.pop();
|
|
}
|
|
}
|
|
BodyPathSegment::Index(index) => {
|
|
let Some(values) = current.as_array() else {
|
|
return;
|
|
};
|
|
let Some(resolved) = resolve_index(values.len(), *index) else {
|
|
return;
|
|
};
|
|
prefix.push(BodyPathSegment::Index(*index));
|
|
expand_wildcard_paths_recursive(
|
|
&values[resolved],
|
|
path,
|
|
require_leaf,
|
|
offset + 1,
|
|
prefix,
|
|
result,
|
|
);
|
|
prefix.pop();
|
|
}
|
|
BodyPathSegment::Key(key) => {
|
|
let Some(object) = current.as_object() else {
|
|
return;
|
|
};
|
|
if let Some(next) = object.get(key) {
|
|
prefix.push(BodyPathSegment::Key(key.clone()));
|
|
expand_wildcard_paths_recursive(
|
|
next,
|
|
path,
|
|
require_leaf,
|
|
offset + 1,
|
|
prefix,
|
|
result,
|
|
);
|
|
prefix.pop();
|
|
} else if is_last && !require_leaf {
|
|
prefix.push(BodyPathSegment::Key(key.clone()));
|
|
result.push(prefix.clone());
|
|
prefix.pop();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct WildcardTargetOptions<'a> {
|
|
condition: Option<&'a Value>,
|
|
item_condition: bool,
|
|
original_body: Option<&'a Value>,
|
|
request_headers: Option<ConditionHeaders<'a>>,
|
|
require_leaf: bool,
|
|
reverse: bool,
|
|
}
|
|
|
|
fn iter_wildcard_targets(
|
|
body: &Value,
|
|
path: &[BodyPathSegment],
|
|
options: WildcardTargetOptions<'_>,
|
|
) -> Vec<Vec<BodyPathSegment>> {
|
|
if !has_wildcard(path) {
|
|
return vec![path.to_vec()];
|
|
}
|
|
|
|
let mut expanded = expand_wildcard_paths(body, path, options.require_leaf);
|
|
if options.reverse {
|
|
expanded.reverse();
|
|
}
|
|
|
|
if !options.item_condition {
|
|
return expanded;
|
|
}
|
|
|
|
let Some(condition) = options.condition else {
|
|
return expanded;
|
|
};
|
|
|
|
expanded
|
|
.into_iter()
|
|
.filter(|concrete_path| {
|
|
let prefix = get_item_prefix_from_concrete(concrete_path, path);
|
|
let resolved = resolve_item_condition(condition, &prefix);
|
|
evaluate_local_condition(
|
|
body,
|
|
&resolved,
|
|
options.original_body,
|
|
options.request_headers,
|
|
)
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn get_item_prefix_from_concrete(
|
|
concrete_path: &[BodyPathSegment],
|
|
wildcard_path: &[BodyPathSegment],
|
|
) -> String {
|
|
let last_wildcard_index = wildcard_path
|
|
.iter()
|
|
.enumerate()
|
|
.rev()
|
|
.find(|(_, segment)| matches!(segment, BodyPathSegment::WildcardSlice(..)))
|
|
.map(|(index, _)| index)
|
|
.unwrap_or(0);
|
|
segments_to_path(&concrete_path[..=last_wildcard_index])
|
|
}
|
|
|
|
fn condition_has_item_ref(condition: &Value) -> bool {
|
|
let Some(condition) = condition.as_object() else {
|
|
return false;
|
|
};
|
|
if let Some(children) = condition.get("all").and_then(Value::as_array) {
|
|
return children.iter().any(condition_has_item_ref);
|
|
}
|
|
if let Some(children) = condition.get("any").and_then(Value::as_array) {
|
|
return children.iter().any(condition_has_item_ref);
|
|
}
|
|
condition
|
|
.get("path")
|
|
.and_then(Value::as_str)
|
|
.map(str::trim)
|
|
.is_some_and(|path| path == ITEM_EXACT || path.starts_with(ITEM_PREFIX))
|
|
}
|
|
|
|
fn resolve_item_condition(condition: &Value, item_path_prefix: &str) -> Value {
|
|
let Some(condition) = condition.as_object() else {
|
|
return condition.clone();
|
|
};
|
|
|
|
if let Some(children) = condition.get("all").and_then(Value::as_array) {
|
|
return serde_json::json!({
|
|
"all": children
|
|
.iter()
|
|
.map(|child| resolve_item_condition(child, item_path_prefix))
|
|
.collect::<Vec<_>>()
|
|
});
|
|
}
|
|
if let Some(children) = condition.get("any").and_then(Value::as_array) {
|
|
return serde_json::json!({
|
|
"any": children
|
|
.iter()
|
|
.map(|child| resolve_item_condition(child, item_path_prefix))
|
|
.collect::<Vec<_>>()
|
|
});
|
|
}
|
|
|
|
let mut resolved = condition.clone();
|
|
if let Some(Value::String(path)) = resolved.get_mut("path") {
|
|
let trimmed = path.trim();
|
|
if trimmed == ITEM_EXACT {
|
|
*path = item_path_prefix.to_string();
|
|
} else if let Some(suffix) = trimmed.strip_prefix(ITEM_PREFIX) {
|
|
*path = format!("{item_path_prefix}.{suffix}");
|
|
}
|
|
}
|
|
Value::Object(resolved)
|
|
}
|
|
|
|
fn compile_regex(pattern: &str, flags: &str) -> Option<Regex> {
|
|
let mut builder = RegexBuilder::new(pattern);
|
|
for flag in flags.chars() {
|
|
match flag {
|
|
'i' => {
|
|
builder.case_insensitive(true);
|
|
}
|
|
'm' => {
|
|
builder.multi_line(true);
|
|
}
|
|
's' => {
|
|
builder.dot_matches_new_line(true);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
builder.build().ok()
|
|
}
|
|
|
|
fn parse_insert_index(value: &Value) -> Option<isize> {
|
|
value
|
|
.as_i64()
|
|
.and_then(|value| isize::try_from(value).ok())
|
|
.or_else(|| value.as_u64().and_then(|value| isize::try_from(value).ok()))
|
|
}
|
|
|
|
fn parse_non_negative_count(value: &Value) -> Option<usize> {
|
|
value
|
|
.as_u64()
|
|
.and_then(|value| usize::try_from(value).ok())
|
|
.or_else(|| {
|
|
value.as_i64().and_then(|value| {
|
|
if value < 0 {
|
|
None
|
|
} else {
|
|
usize::try_from(value).ok()
|
|
}
|
|
})
|
|
})
|
|
}
|
|
|
|
fn get_nested_value(value: &Value, path: &[BodyPathSegment]) -> Option<Value> {
|
|
let mut current = value;
|
|
for segment in path {
|
|
match segment {
|
|
BodyPathSegment::Key(key) => {
|
|
current = current.as_object()?.get(key)?;
|
|
}
|
|
BodyPathSegment::Index(index) => {
|
|
let values = current.as_array()?;
|
|
let resolved = resolve_index(values.len(), *index)?;
|
|
current = values.get(resolved)?;
|
|
}
|
|
BodyPathSegment::WildcardSlice(..) => return None,
|
|
}
|
|
}
|
|
Some(current.clone())
|
|
}
|
|
|
|
fn get_nested_value_mut<'a>(
|
|
mut current: &'a mut Value,
|
|
path: &[BodyPathSegment],
|
|
) -> Option<&'a mut Value> {
|
|
for segment in path {
|
|
current = get_existing_child_mut(current, segment)?;
|
|
}
|
|
Some(current)
|
|
}
|
|
|
|
fn get_existing_child_mut<'a>(
|
|
current: &'a mut Value,
|
|
segment: &BodyPathSegment,
|
|
) -> Option<&'a mut Value> {
|
|
match segment {
|
|
BodyPathSegment::Key(key) => current.as_object_mut()?.get_mut(key),
|
|
BodyPathSegment::Index(index) => {
|
|
let values = current.as_array_mut()?;
|
|
let resolved = resolve_index(values.len(), *index)?;
|
|
values.get_mut(resolved)
|
|
}
|
|
BodyPathSegment::WildcardSlice(..) => None,
|
|
}
|
|
}
|
|
|
|
fn set_nested_value(current: &mut Value, path: &[BodyPathSegment], value: Value) -> bool {
|
|
let Some((last, parents)) = path.split_last() else {
|
|
return false;
|
|
};
|
|
let mut current = current;
|
|
|
|
for (offset, segment) in parents.iter().enumerate() {
|
|
let next = &path[offset + 1];
|
|
match segment {
|
|
BodyPathSegment::Key(key) => {
|
|
let Some(object) = current.as_object_mut() else {
|
|
return false;
|
|
};
|
|
match next {
|
|
BodyPathSegment::Key(_) => {
|
|
let child = object
|
|
.entry(key.clone())
|
|
.or_insert_with(|| Value::Object(Map::new()));
|
|
if !child.is_object() {
|
|
*child = Value::Object(Map::new());
|
|
}
|
|
current = child;
|
|
}
|
|
BodyPathSegment::Index(_) => {
|
|
let Some(child) = object.get_mut(key) else {
|
|
return false;
|
|
};
|
|
if !child.is_array() {
|
|
return false;
|
|
}
|
|
current = child;
|
|
}
|
|
BodyPathSegment::WildcardSlice(..) => return false,
|
|
}
|
|
}
|
|
BodyPathSegment::Index(index) => {
|
|
let Some(values) = current.as_array_mut() else {
|
|
return false;
|
|
};
|
|
let Some(resolved) = resolve_index(values.len(), *index) else {
|
|
return false;
|
|
};
|
|
current = &mut values[resolved];
|
|
}
|
|
BodyPathSegment::WildcardSlice(..) => return false,
|
|
}
|
|
}
|
|
|
|
match last {
|
|
BodyPathSegment::Key(key) => {
|
|
let Some(object) = current.as_object_mut() else {
|
|
return false;
|
|
};
|
|
object.insert(key.clone(), value);
|
|
true
|
|
}
|
|
BodyPathSegment::Index(index) => {
|
|
let Some(values) = current.as_array_mut() else {
|
|
return false;
|
|
};
|
|
let Some(resolved) = resolve_index(values.len(), *index) else {
|
|
return false;
|
|
};
|
|
values[resolved] = value;
|
|
true
|
|
}
|
|
BodyPathSegment::WildcardSlice(..) => false,
|
|
}
|
|
}
|
|
|
|
fn delete_nested_value(current: &mut Value, path: &[BodyPathSegment]) -> bool {
|
|
let Some((last, parents)) = path.split_last() else {
|
|
return false;
|
|
};
|
|
let mut current = current;
|
|
for segment in parents {
|
|
let Some(child) = get_existing_child_mut(current, segment) else {
|
|
return false;
|
|
};
|
|
current = child;
|
|
}
|
|
|
|
match last {
|
|
BodyPathSegment::Key(key) => current
|
|
.as_object_mut()
|
|
.and_then(|object| object.remove(key))
|
|
.is_some(),
|
|
BodyPathSegment::Index(index) => {
|
|
let Some(values) = current.as_array_mut() else {
|
|
return false;
|
|
};
|
|
let Some(resolved) = resolve_index(values.len(), *index) else {
|
|
return false;
|
|
};
|
|
values.remove(resolved);
|
|
true
|
|
}
|
|
BodyPathSegment::WildcardSlice(..) => false,
|
|
}
|
|
}
|
|
|
|
fn rename_nested_value(
|
|
current: &mut Value,
|
|
from: &[BodyPathSegment],
|
|
to: &[BodyPathSegment],
|
|
) -> bool {
|
|
if from == to {
|
|
return get_nested_value(current, from).is_some();
|
|
}
|
|
|
|
let Some(value) = get_nested_value(current, from) else {
|
|
return false;
|
|
};
|
|
if !set_nested_value(current, to, value) {
|
|
return false;
|
|
}
|
|
delete_nested_value(current, from)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::{
|
|
apply_local_body_rules, apply_local_body_rules_with_request_headers,
|
|
apply_local_header_rules, apply_local_header_rules_with_request_headers,
|
|
body_rules_are_locally_supported, body_rules_handle_path, body_rules_have_enabled_rules,
|
|
header_rules_are_locally_supported, header_rules_have_enabled_rules,
|
|
};
|
|
|
|
#[test]
|
|
fn header_rules_allow_simple_set_drop_and_rename() {
|
|
let rules = serde_json::json!([
|
|
{"action":"set","key":"x-added","value":"1"},
|
|
{"action":"drop","key":"x-drop"},
|
|
{"action":"rename","from":"x-old","to":"x-new"}
|
|
]);
|
|
assert!(header_rules_are_locally_supported(Some(&rules)));
|
|
|
|
let mut headers = std::collections::BTreeMap::from([
|
|
("x-drop".to_string(), "drop-me".to_string()),
|
|
("x-old".to_string(), "old-value".to_string()),
|
|
("authorization".to_string(), "Bearer keep".to_string()),
|
|
]);
|
|
assert!(apply_local_header_rules(
|
|
&mut headers,
|
|
Some(&rules),
|
|
&["authorization", "content-type"],
|
|
&serde_json::json!({}),
|
|
None,
|
|
));
|
|
assert_eq!(headers.get("x-added").map(String::as_str), Some("1"));
|
|
assert!(!headers.contains_key("x-drop"));
|
|
assert_eq!(headers.get("x-new").map(String::as_str), Some("old-value"));
|
|
assert_eq!(
|
|
headers.get("authorization").map(String::as_str),
|
|
Some("Bearer keep")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn header_rules_allow_simple_conditions() {
|
|
let rules = serde_json::json!([
|
|
{"action":"set","key":"x-added","value":"1","condition":{"path":"metadata.mode","op":"eq","value":"safe"}},
|
|
{"action":"set","key":"x-from-original","value":"1","condition":{"path":"metadata.client","op":"exists","source":"original"}}
|
|
]);
|
|
assert!(header_rules_are_locally_supported(Some(&rules)));
|
|
|
|
let mut headers = std::collections::BTreeMap::new();
|
|
assert!(apply_local_header_rules(
|
|
&mut headers,
|
|
Some(&rules),
|
|
&[],
|
|
&serde_json::json!({"metadata":{"mode":"safe"}}),
|
|
Some(&serde_json::json!({"metadata":{"mode":"safe","client":"desktop"}})),
|
|
));
|
|
assert_eq!(headers.get("x-added").map(String::as_str), Some("1"));
|
|
assert_eq!(
|
|
headers.get("x-from-original").map(String::as_str),
|
|
Some("1")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn header_rules_can_read_original_request_header_conditions() {
|
|
let rules = serde_json::json!([
|
|
{"action":"set","key":"x-applied","value":"yes","condition":{"source":"request_headers","path":"X-Mode","op":"eq","value":"debug"}},
|
|
{"action":"set","key":"x-skipped","value":"yes","condition":{"source":"request_headers","path":"x-missing","op":"exists"}}
|
|
]);
|
|
let mut request_headers = http::HeaderMap::new();
|
|
request_headers.insert("x-mode", "debug".parse().unwrap());
|
|
|
|
let mut headers = std::collections::BTreeMap::from([(
|
|
"x-mode".to_string(),
|
|
"provider-value".to_string(),
|
|
)]);
|
|
assert!(apply_local_header_rules_with_request_headers(
|
|
&mut headers,
|
|
Some(&rules),
|
|
&[],
|
|
&serde_json::json!({}),
|
|
None,
|
|
Some(&request_headers),
|
|
));
|
|
|
|
assert_eq!(headers.get("x-applied").map(String::as_str), Some("yes"));
|
|
assert!(!headers.contains_key("x-skipped"));
|
|
}
|
|
|
|
#[test]
|
|
fn body_rules_support_all_runtime_actions() {
|
|
let rules = serde_json::json!([
|
|
{"action":"set","path":"model","value":"wrapped-{{$original}}"},
|
|
{"action":"set","path":"metadata.snapshot","value":"{{$original}}","condition":{"path":"metadata.snapshot","op":"exists"}},
|
|
{"action":"rename","from":"messages[0].content","to":"messages[0].text"},
|
|
{"action":"append","path":"messages","value":{"role":"assistant","content":"done"}},
|
|
{"action":"insert","path":"messages","index":-1,"value":{"role":"system","content":"before-last"}},
|
|
{"action":"regex_replace","path":"tools[*].name","pattern":"tool","replacement":"utility","flags":"i","count":1},
|
|
{"action":"drop","path":"tools[1-2].deprecated"}
|
|
]);
|
|
assert!(body_rules_are_locally_supported(Some(&rules)));
|
|
|
|
let mut body = serde_json::json!({
|
|
"model": "gpt-5",
|
|
"metadata": {
|
|
"snapshot": {
|
|
"keep": true
|
|
}
|
|
},
|
|
"messages": [
|
|
{"content":"hello"},
|
|
{"content":"world"}
|
|
],
|
|
"tools": [
|
|
{"name":"WriterTool","kind":"snake_case_name","deprecated":true},
|
|
{"name":"ReadTool","kind":"Pascal Case","deprecated":true},
|
|
{"name":"OtherTool","kind":"kebab-case-value","deprecated":true}
|
|
]
|
|
});
|
|
|
|
assert!(apply_local_body_rules(&mut body, Some(&rules), None));
|
|
|
|
assert_eq!(body["model"], "wrapped-gpt-5");
|
|
assert_eq!(
|
|
body["metadata"]["snapshot"],
|
|
serde_json::json!({"keep": true})
|
|
);
|
|
assert_eq!(body["messages"][0]["text"], "hello");
|
|
assert!(body["messages"][0].get("content").is_none());
|
|
assert_eq!(body["messages"][1]["content"], "world");
|
|
assert_eq!(
|
|
body["messages"][2],
|
|
serde_json::json!({"role":"system","content":"before-last"})
|
|
);
|
|
assert_eq!(
|
|
body["messages"][3],
|
|
serde_json::json!({"role":"assistant","content":"done"})
|
|
);
|
|
assert_eq!(body["tools"][0]["name"], "Writerutility");
|
|
assert_eq!(body["tools"][1]["name"], "Readutility");
|
|
assert_eq!(body["tools"][2]["name"], "Otherutility");
|
|
assert_eq!(body["tools"][0]["deprecated"], true);
|
|
assert!(body["tools"][1].get("deprecated").is_none());
|
|
assert!(body["tools"][2].get("deprecated").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn body_rules_conditions_cover_all_supported_operators() {
|
|
let rules = serde_json::json!([
|
|
{"action":"set","path":"results.eq","value":true,"condition":{"path":"text","op":"eq","value":"alpha-beta"}},
|
|
{"action":"set","path":"results.neq","value":true,"condition":{"path":"text","op":"neq","value":"other"}},
|
|
{"action":"set","path":"results.gt","value":true,"condition":{"path":"num","op":"gt","value":9}},
|
|
{"action":"set","path":"results.lt","value":true,"condition":{"path":"num","op":"lt","value":11}},
|
|
{"action":"set","path":"results.gte","value":true,"condition":{"path":"num","op":"gte","value":10}},
|
|
{"action":"set","path":"results.lte","value":true,"condition":{"path":"num","op":"lte","value":10}},
|
|
{"action":"set","path":"results.starts_with","value":true,"condition":{"path":"text","op":"starts_with","value":"alpha"}},
|
|
{"action":"set","path":"results.ends_with","value":true,"condition":{"path":"text","op":"ends_with","value":"beta"}},
|
|
{"action":"set","path":"results.contains_string","value":true,"condition":{"path":"text","op":"contains","value":"ha-be"}},
|
|
{"action":"set","path":"results.contains_array","value":true,"condition":{"path":"tags","op":"contains","value":"green"}},
|
|
{"action":"set","path":"results.matches","value":true,"condition":{"path":"text","op":"matches","value":"alpha.*beta"}},
|
|
{"action":"set","path":"results.exists","value":true,"condition":{"path":"profile.name","op":"exists"}},
|
|
{"action":"set","path":"results.not_exists","value":true,"condition":{"path":"profile.age","op":"not_exists"}},
|
|
{"action":"set","path":"results.in","value":true,"condition":{"path":"choice","op":"in","value":["a","b","c"]}},
|
|
{"action":"set","path":"results.type_is","value":true,"condition":{"path":"flag","op":"type_is","value":"boolean"}},
|
|
{"action":"set","path":"results.original","value":true,"condition":{"path":"legacy.present","op":"exists","source":"original"}},
|
|
{"action":"set","path":"results.all","value":true,"condition":{"all":[{"path":"num","op":"gt","value":5},{"path":"text","op":"contains","value":"beta"}]}},
|
|
{"action":"set","path":"results.any","value":true,"condition":{"any":[{"path":"missing","op":"exists"},{"path":"maybe_null","op":"type_is","value":"null"}]}}
|
|
]);
|
|
assert!(body_rules_are_locally_supported(Some(&rules)));
|
|
|
|
let original = serde_json::json!({
|
|
"num": 10,
|
|
"text": "alpha-beta",
|
|
"tags": ["red", "green"],
|
|
"choice": "b",
|
|
"flag": true,
|
|
"maybe_null": null,
|
|
"profile": {
|
|
"name": "Ada"
|
|
},
|
|
"legacy": {
|
|
"present": 1
|
|
}
|
|
});
|
|
let mut body = serde_json::json!({
|
|
"num": 10,
|
|
"text": "alpha-beta",
|
|
"tags": ["red", "green"],
|
|
"choice": "b",
|
|
"flag": true,
|
|
"maybe_null": null,
|
|
"profile": {
|
|
"name": "Ada"
|
|
}
|
|
});
|
|
|
|
assert!(apply_local_body_rules(
|
|
&mut body,
|
|
Some(&rules),
|
|
Some(&original)
|
|
));
|
|
|
|
let expected = serde_json::json!({
|
|
"eq": true,
|
|
"neq": true,
|
|
"gt": true,
|
|
"lt": true,
|
|
"gte": true,
|
|
"lte": true,
|
|
"starts_with": true,
|
|
"ends_with": true,
|
|
"contains_string": true,
|
|
"contains_array": true,
|
|
"matches": true,
|
|
"exists": true,
|
|
"not_exists": true,
|
|
"in": true,
|
|
"type_is": true,
|
|
"original": true,
|
|
"all": true,
|
|
"any": true
|
|
});
|
|
assert_eq!(body["results"], expected);
|
|
}
|
|
|
|
#[test]
|
|
fn body_conditions_default_to_current_request_body() {
|
|
let rules = serde_json::json!([
|
|
{"action":"set","path":"model","value":"provider-model"},
|
|
{"action":"set","path":"metadata.original_hit","value":true,"condition":{"path":"model","op":"eq","value":"client-model"}},
|
|
{"action":"set","path":"metadata.current_hit","value":true,"condition":{"path":"model","op":"eq","value":"provider-model"}}
|
|
]);
|
|
let original = serde_json::json!({
|
|
"model": "client-model"
|
|
});
|
|
let mut body = original.clone();
|
|
|
|
assert!(apply_local_body_rules(
|
|
&mut body,
|
|
Some(&rules),
|
|
Some(&original)
|
|
));
|
|
|
|
assert_eq!(body["model"], "provider-model");
|
|
assert!(body["metadata"].get("original_hit").is_none());
|
|
assert_eq!(body["metadata"]["current_hit"], true);
|
|
}
|
|
|
|
#[test]
|
|
fn body_conditions_default_to_current_request_body_for_wildcard_drop() {
|
|
let rules = serde_json::json!([
|
|
{
|
|
"action": "drop",
|
|
"path": "tools[*]",
|
|
"condition": {"path": "$item.name", "op": "eq", "value": "Agent"}
|
|
}
|
|
]);
|
|
let original = serde_json::json!({
|
|
"model": "client-model"
|
|
});
|
|
let mut body = serde_json::json!({
|
|
"tools": [
|
|
{"name": "Agent"},
|
|
{"name": "Bash"}
|
|
]
|
|
});
|
|
|
|
assert!(apply_local_body_rules(
|
|
&mut body,
|
|
Some(&rules),
|
|
Some(&original)
|
|
));
|
|
|
|
assert_eq!(body["tools"], serde_json::json!([{"name": "Bash"}]));
|
|
}
|
|
|
|
#[test]
|
|
fn body_rules_can_read_original_request_header_conditions() {
|
|
let rules = serde_json::json!([
|
|
{"action":"set","path":"metadata.from_header","value":true,"condition":{"source":"request_headers","path":"X-Mode","op":"eq","value":"debug"}},
|
|
{"action":"set","path":"metadata.contains","value":true,"condition":{"source":"headers","path":"x-feature","op":"contains","value":"beta"}},
|
|
{"action":"set","path":"metadata.skipped","value":true,"condition":{"source":"request_headers","path":"x-mode","op":"eq","value":"prod"}}
|
|
]);
|
|
let mut request_headers = http::HeaderMap::new();
|
|
request_headers.insert("x-mode", "debug".parse().unwrap());
|
|
request_headers.insert("x-feature", "alpha,beta".parse().unwrap());
|
|
let mut body = serde_json::json!({});
|
|
|
|
assert!(apply_local_body_rules_with_request_headers(
|
|
&mut body,
|
|
Some(&rules),
|
|
None,
|
|
Some(&request_headers),
|
|
));
|
|
|
|
assert_eq!(body["metadata"]["from_header"], true);
|
|
assert_eq!(body["metadata"]["contains"], true);
|
|
assert!(body["metadata"].get("skipped").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn body_rules_tolerate_invalid_regex_flags_and_negative_count() {
|
|
let invalid_flags = serde_json::json!([
|
|
{"action":"regex_replace","path":"text","pattern":"foo","replacement":"bar","flags":"ix"}
|
|
]);
|
|
let invalid_count = serde_json::json!([
|
|
{"action":"regex_replace","path":"text","pattern":"foo","replacement":"bar","count":-1}
|
|
]);
|
|
let mut flags_body = serde_json::json!({"text":"foo"});
|
|
let mut count_body = serde_json::json!({"text":"foo foo"});
|
|
|
|
assert!(body_rules_are_locally_supported(Some(&invalid_flags)));
|
|
assert!(body_rules_are_locally_supported(Some(&invalid_count)));
|
|
assert!(apply_local_body_rules(
|
|
&mut flags_body,
|
|
Some(&invalid_flags),
|
|
None
|
|
));
|
|
assert!(apply_local_body_rules(
|
|
&mut count_body,
|
|
Some(&invalid_count),
|
|
None
|
|
));
|
|
assert_eq!(flags_body["text"], "bar");
|
|
assert_eq!(count_body["text"], "bar bar");
|
|
}
|
|
|
|
#[test]
|
|
fn body_rules_skip_invalid_entries_without_rejecting_whole_body() {
|
|
let rules = serde_json::json!([
|
|
{"action":"set","path":".bad","value":1},
|
|
{"action":"drop","path":"missing."},
|
|
{"action":"regex_replace","path":"text","pattern":"(","replacement":"x"},
|
|
{"op":"remove","path":"/legacy"},
|
|
{"action":"set","path":"ok","value":true}
|
|
]);
|
|
let mut body = serde_json::json!({
|
|
"text": "keep",
|
|
"legacy": true
|
|
});
|
|
|
|
assert!(apply_local_body_rules(&mut body, Some(&rules), None));
|
|
|
|
assert_eq!(body["text"], "keep");
|
|
assert_eq!(body["legacy"], true);
|
|
assert_eq!(body["ok"], true);
|
|
}
|
|
|
|
#[test]
|
|
fn body_rules_skip_disabled_entries() {
|
|
let rules = serde_json::json!([
|
|
{"enabled":false,"action":"set","path":"metadata.disabled","value":true},
|
|
{"enabled":false,"action":"drop","path":"keep"},
|
|
{"action":"set","path":"metadata.enabled","value":true}
|
|
]);
|
|
let mut body = serde_json::json!({
|
|
"keep": true,
|
|
"metadata": {}
|
|
});
|
|
|
|
assert!(body_rules_have_enabled_rules(Some(&rules)));
|
|
assert!(apply_local_body_rules(&mut body, Some(&rules), None));
|
|
|
|
assert_eq!(body["keep"], true);
|
|
assert!(body["metadata"].get("disabled").is_none());
|
|
assert_eq!(body["metadata"]["enabled"], true);
|
|
assert!(!body_rules_handle_path(Some(&rules), "metadata.disabled"));
|
|
assert!(body_rules_handle_path(Some(&rules), "metadata.enabled"));
|
|
|
|
let disabled_only = serde_json::json!([
|
|
{"enabled":false,"action":"set","path":"metadata.disabled","value":true}
|
|
]);
|
|
assert!(!body_rules_have_enabled_rules(Some(&disabled_only)));
|
|
}
|
|
|
|
#[test]
|
|
fn header_rules_skip_invalid_entries_without_rejecting_whole_headers() {
|
|
let rules = serde_json::json!([
|
|
{"action":"set","key":"","value":"bad"},
|
|
{"action":"set","key":"x-json","value":{"nested":true}},
|
|
{"action":"drop","key":null},
|
|
{"action":"rename","from":"x-missing","to":""},
|
|
{"op":"remove","key":"x-legacy"},
|
|
{"action":"set","key":"x-ok","value":"yes"}
|
|
]);
|
|
let mut headers = std::collections::BTreeMap::from([(
|
|
"authorization".to_string(),
|
|
"Bearer keep".to_string(),
|
|
)]);
|
|
|
|
assert!(header_rules_are_locally_supported(Some(&rules)));
|
|
assert!(apply_local_header_rules(
|
|
&mut headers,
|
|
Some(&rules),
|
|
&["authorization"],
|
|
&serde_json::json!({}),
|
|
None,
|
|
));
|
|
|
|
assert_eq!(
|
|
headers.get("authorization").map(String::as_str),
|
|
Some("Bearer keep")
|
|
);
|
|
assert_eq!(
|
|
headers.get("x-json").map(String::as_str),
|
|
Some("{\"nested\":true}")
|
|
);
|
|
assert_eq!(headers.get("x-ok").map(String::as_str), Some("yes"));
|
|
assert!(!headers.contains_key("x-legacy"));
|
|
}
|
|
|
|
#[test]
|
|
fn header_rules_skip_disabled_entries() {
|
|
let rules = serde_json::json!([
|
|
{"enabled":false,"action":"set","key":"x-disabled","value":"bad"},
|
|
{"enabled":false,"action":"drop","key":"x-keep"},
|
|
{"action":"set","key":"x-enabled","value":"ok"}
|
|
]);
|
|
assert!(header_rules_have_enabled_rules(Some(&rules)));
|
|
|
|
let mut headers =
|
|
std::collections::BTreeMap::from([("x-keep".to_string(), "yes".to_string())]);
|
|
assert!(apply_local_header_rules(
|
|
&mut headers,
|
|
Some(&rules),
|
|
&[],
|
|
&serde_json::json!({}),
|
|
None,
|
|
));
|
|
|
|
assert_eq!(headers.get("x-keep").map(String::as_str), Some("yes"));
|
|
assert!(!headers.contains_key("x-disabled"));
|
|
assert_eq!(headers.get("x-enabled").map(String::as_str), Some("ok"));
|
|
|
|
let disabled_only = serde_json::json!([
|
|
{"enabled":false,"action":"set","key":"x-disabled","value":"bad"}
|
|
]);
|
|
assert!(!header_rules_have_enabled_rules(Some(&disabled_only)));
|
|
}
|
|
|
|
#[test]
|
|
fn body_rules_handle_exact_and_wildcard_paths() {
|
|
let rules = serde_json::json!([
|
|
{"action":"append","path":"messages","value":{}},
|
|
{"action":"regex_replace","path":"tools[*].name","pattern":"foo","replacement":"bar"},
|
|
{"action":"rename","from":"metadata.old","to":"metadata.new"}
|
|
]);
|
|
|
|
assert!(body_rules_handle_path(Some(&rules), "messages"));
|
|
assert!(body_rules_handle_path(Some(&rules), "tools[1].name"));
|
|
assert!(body_rules_handle_path(Some(&rules), "metadata.old"));
|
|
assert!(body_rules_handle_path(Some(&rules), "metadata.new"));
|
|
assert!(!body_rules_handle_path(Some(&rules), "tools[2].kind"));
|
|
assert!(!body_rules_handle_path(Some(&rules), "tools[3].kind"));
|
|
assert!(!body_rules_handle_path(Some(&rules), "instructions"));
|
|
}
|
|
}
|