Skip to main content

featherbit/vars/
mod.rs

1//! Request/response variable resolution and condition expressions.
2//!
3//! The featherbit analogue of APISIX's `ctx.var` + `lua-resty-expr`: plugins
4//! resolve named variables against the [`Context`] (`uri`, `arg_<name>`,
5//! `http_<header>`, ...), interpolate `$var` / `${var}` templates, and
6//! evaluate condition expressions written in APISIX's triple-array form:
7//!
8//! ```yaml
9//! vars:
10//!   - ["arg_name", "==", "jack"]
11//!   - ["http_user_agent", "~~", "Mozilla.+"]
12//! ```
13//!
14//! Rules in a top-level list are ANDed. A rule is `[var, op, value]` or the
15//! negated `[var, "!", op, value]`; nested logic uses `["AND", rule...]` /
16//! `["OR", rule...]`. Supported operators: `==`, `~=`, `>`, `>=`, `<`, `<=`,
17//! `~~` (regex), `~*` (case-insensitive regex), `in` (value array), `has`
18//! (var array contains value), `ipmatch` (value is a list of IPs/CIDRs).
19
20use std::borrow::Cow;
21use std::net::IpAddr;
22
23use ipnet::IpNet;
24use regex::Regex;
25
26use crate::context::Context;
27
28/// Resolves a variable name against the context.
29///
30/// Supported names (mirroring APISIX's `ctx.var` where featherbit has an
31/// equivalent):
32/// - `uri` — request path (no query string)
33/// - `request_uri` — path plus `?query` when query params exist
34/// - `method`, `host`, `scheme`, `protocol`
35/// - `remote_addr` — client IP without port; `remote_port` — the port
36/// - `query_string` — rebuilt from `query_params`
37/// - `status` — response status code
38/// - `resp_body` — response body (lossy UTF-8)
39/// - `arg_<name>` — first query parameter value
40/// - `http_<name>` — first request header value (underscores map to dashes)
41/// - `cookie_<name>` — value from the `Cookie` request header
42/// - `post_arg_<name>` — form field, only for
43///   `application/x-www-form-urlencoded` request bodies
44/// - `consumer_name`, `consumer_group_id` — from `message["consumer.name"]` /
45///   `message["consumer.group"]` (set by auth plugins)
46/// - `msg_<key>` — any `context.message` key (stringified)
47///
48/// Returns `None` for unknown names and for known prefixes whose subject is
49/// absent (missing header, missing query param, ...).
50pub fn resolve<'a>(ctx: &'a Context, name: &str) -> Option<Cow<'a, str>> {
51    match name {
52        "uri" => Some(Cow::Borrowed(ctx.request.path.as_str())),
53        "request_uri" => {
54            let qs = query_string(ctx);
55            if qs.is_empty() {
56                Some(Cow::Borrowed(ctx.request.path.as_str()))
57            } else {
58                Some(Cow::Owned(format!("{}?{}", ctx.request.path, qs)))
59            }
60        }
61        "method" | "request_method" => Some(Cow::Borrowed(ctx.request.method.as_str())),
62        "host" => Some(Cow::Borrowed(ctx.request.host.as_str())),
63        "scheme" => Some(Cow::Borrowed(ctx.request.scheme.as_str())),
64        "protocol" => Some(Cow::Owned(
65            format!("{:?}", ctx.request.protocol).to_lowercase(),
66        )),
67        "remote_addr" => Some(Cow::Borrowed(split_remote_addr(&ctx.request.remote_addr).0)),
68        "remote_port" => {
69            let port = split_remote_addr(&ctx.request.remote_addr).1?;
70            Some(Cow::Borrowed(port))
71        }
72        "query_string" => {
73            let qs = query_string(ctx);
74            if qs.is_empty() {
75                None
76            } else {
77                Some(Cow::Owned(qs))
78            }
79        }
80        "status" => Some(Cow::Owned(ctx.response.status_code.to_string())),
81        "resp_body" => Some(Cow::Owned(
82            String::from_utf8_lossy(&ctx.response.body).into_owned(),
83        )),
84        "consumer_name" => message_str(ctx, "consumer.name"),
85        "consumer_group_id" => message_str(ctx, "consumer.group"),
86        _ => {
87            if let Some(arg) = name.strip_prefix("arg_") {
88                ctx.request
89                    .query_params
90                    .get(arg)
91                    .and_then(|v| v.first())
92                    .map(|v| Cow::Borrowed(v.as_str()))
93            } else if let Some(header) = name.strip_prefix("http_") {
94                let header = header.replace('_', "-").to_lowercase();
95                ctx.request
96                    .headers
97                    .get(&header)
98                    .and_then(|v| v.first())
99                    .map(|v| Cow::Borrowed(v.as_str()))
100            } else if let Some(cookie) = name.strip_prefix("cookie_") {
101                cookie_value(ctx, cookie).map(Cow::Owned)
102            } else if let Some(field) = name.strip_prefix("post_arg_") {
103                post_arg(ctx, field).map(Cow::Owned)
104            } else if let Some(key) = name.strip_prefix("msg_") {
105                message_str(ctx, key)
106            } else {
107                None
108            }
109        }
110    }
111}
112
113/// Interpolates `$var` and `${var}` references in a template.
114///
115/// Unknown or absent variables resolve to the empty string (matching
116/// APISIX's `resolve_var`). A literal `$` not followed by `[A-Za-z_{]`
117/// passes through unchanged; there is no escape syntax.
118pub fn interpolate(ctx: &Context, template: &str) -> String {
119    let mut out = String::with_capacity(template.len());
120    let bytes = template.as_bytes();
121    let mut i = 0;
122
123    while i < bytes.len() {
124        if bytes[i] != b'$' {
125            let start = i;
126            while i < bytes.len() && bytes[i] != b'$' {
127                i += 1;
128            }
129            out.push_str(&template[start..i]);
130            continue;
131        }
132
133        // At a '$'
134        if i + 1 < bytes.len() && bytes[i + 1] == b'{' {
135            if let Some(end) = template[i + 2..].find('}') {
136                let name = &template[i + 2..i + 2 + end];
137                if let Some(v) = resolve(ctx, name) {
138                    out.push_str(&v);
139                }
140                i += 2 + end + 1;
141                continue;
142            }
143            out.push('$');
144            i += 1;
145        } else {
146            let start = i + 1;
147            let mut end = start;
148            while end < bytes.len() && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'_') {
149                end += 1;
150            }
151            if end == start {
152                out.push('$');
153                i += 1;
154                continue;
155            }
156            if let Some(v) = resolve(ctx, &template[start..end]) {
157                out.push_str(&v);
158            }
159            i = end;
160        }
161    }
162
163    out
164}
165
166/// A compiled condition expression in APISIX's triple-array form.
167///
168/// Parsed once at config load ([`Expr::parse`]); regexes are compiled at
169/// parse time so evaluation is allocation-light.
170pub struct Expr {
171    root: Node,
172}
173
174enum Node {
175    And(Vec<Node>),
176    Or(Vec<Node>),
177    Rule { var: String, negate: bool, op: Op },
178}
179
180enum Op {
181    Eq(String),
182    Ne(String),
183    Gt(f64),
184    Ge(f64),
185    Lt(f64),
186    Le(f64),
187    Regex(Regex),
188    In(Vec<String>),
189    Has(String),
190    IpMatch(Vec<IpNet>),
191}
192
193impl Expr {
194    /// Parses the APISIX `vars` shape: a JSON array of rules, ANDed.
195    ///
196    /// Each rule is `[var, op, value]`, `[var, "!", op, value]`, or a nested
197    /// `["AND"|"OR", rule...]`. Fails with a descriptive message on unknown
198    /// operators, malformed rules, invalid regexes, or invalid CIDRs.
199    pub fn parse(v: &serde_json::Value) -> Result<Self, String> {
200        let rules = v.as_array().ok_or("vars must be an array of rules")?;
201        Ok(Self {
202            root: Node::And(
203                rules
204                    .iter()
205                    .map(parse_node)
206                    .collect::<Result<Vec<_>, _>>()?,
207            ),
208        })
209    }
210
211    /// Evaluates the expression against the context. Rules referencing absent
212    /// variables evaluate as if the variable were the empty string, except
213    /// `ipmatch`, which is false for an absent/unparsable address.
214    pub fn eval(&self, ctx: &Context) -> bool {
215        eval_node(&self.root, ctx)
216    }
217}
218
219fn parse_node(v: &serde_json::Value) -> Result<Node, String> {
220    let arr = v.as_array().ok_or("each rule must be an array")?;
221    if arr.is_empty() {
222        return Err("empty rule".to_string());
223    }
224
225    // Nested logic: ["AND"|"OR", rule...]
226    if let Some(first) = arr[0].as_str() {
227        if first.eq_ignore_ascii_case("and") || first.eq_ignore_ascii_case("or") {
228            let children = arr[1..]
229                .iter()
230                .map(parse_node)
231                .collect::<Result<Vec<_>, _>>()?;
232            return Ok(if first.eq_ignore_ascii_case("and") {
233                Node::And(children)
234            } else {
235                Node::Or(children)
236            });
237        }
238    }
239
240    // [var, op, value] or [var, "!", op, value]
241    let var = arr[0]
242        .as_str()
243        .ok_or("rule variable must be a string")?
244        .to_string();
245    let (negate, op_idx) = if arr.get(1).and_then(|v| v.as_str()) == Some("!") {
246        (true, 2)
247    } else {
248        (false, 1)
249    };
250    let op_str = arr
251        .get(op_idx)
252        .and_then(|v| v.as_str())
253        .ok_or_else(|| format!("rule for '{}' is missing an operator", var))?;
254    let value = arr
255        .get(op_idx + 1)
256        .ok_or_else(|| format!("rule for '{}' is missing a value", var))?;
257
258    let scalar = || -> Result<String, String> {
259        match value {
260            serde_json::Value::String(s) => Ok(s.clone()),
261            serde_json::Value::Number(n) => Ok(n.to_string()),
262            serde_json::Value::Bool(b) => Ok(b.to_string()),
263            _ => Err(format!("rule for '{}' needs a scalar value", var)),
264        }
265    };
266    let number = || -> Result<f64, String> {
267        value
268            .as_f64()
269            .or_else(|| value.as_str().and_then(|s| s.parse().ok()))
270            .ok_or_else(|| format!("rule for '{}' ({}) needs a numeric value", var, op_str))
271    };
272    let string_list = || -> Result<Vec<String>, String> {
273        value
274            .as_array()
275            .ok_or_else(|| format!("rule for '{}' ({}) needs an array value", var, op_str))?
276            .iter()
277            .map(|item| {
278                item.as_str()
279                    .map(String::from)
280                    .or_else(|| item.as_f64().map(|n| n.to_string()))
281                    .ok_or_else(|| format!("rule for '{}': array items must be scalars", var))
282            })
283            .collect()
284    };
285
286    let op = match op_str {
287        "==" => Op::Eq(scalar()?),
288        "~=" | "!=" => Op::Ne(scalar()?),
289        ">" => Op::Gt(number()?),
290        ">=" => Op::Ge(number()?),
291        "<" => Op::Lt(number()?),
292        "<=" => Op::Le(number()?),
293        "~~" => Op::Regex(
294            Regex::new(&scalar()?).map_err(|e| format!("invalid regex for '{}': {}", var, e))?,
295        ),
296        "~*" => Op::Regex(
297            Regex::new(&format!("(?i){}", scalar()?))
298                .map_err(|e| format!("invalid regex for '{}': {}", var, e))?,
299        ),
300        "in" => Op::In(string_list()?),
301        "has" => Op::Has(scalar()?),
302        "ipmatch" => {
303            let nets = string_list()?
304                .iter()
305                .map(|s| {
306                    if let Ok(net) = s.parse::<IpNet>() {
307                        Ok(net)
308                    } else if let Ok(ip) = s.parse::<IpAddr>() {
309                        Ok(IpNet::from(ip))
310                    } else {
311                        Err(format!("invalid IP/CIDR '{}' for '{}'", s, var))
312                    }
313                })
314                .collect::<Result<Vec<_>, _>>()?;
315            Op::IpMatch(nets)
316        }
317        other => {
318            return Err(format!(
319                "unknown operator '{}' — supported: ==, ~=, >, >=, <, <=, ~~, ~*, in, has, ipmatch",
320                other
321            ));
322        }
323    };
324
325    Ok(Node::Rule { var, negate, op })
326}
327
328fn eval_node(node: &Node, ctx: &Context) -> bool {
329    match node {
330        Node::And(children) => children.iter().all(|c| eval_node(c, ctx)),
331        Node::Or(children) => children.iter().any(|c| eval_node(c, ctx)),
332        Node::Rule { var, negate, op } => {
333            let value = resolve(ctx, var);
334            let result = eval_op(op, value.as_deref());
335            if *negate {
336                !result
337            } else {
338                result
339            }
340        }
341    }
342}
343
344fn eval_op(op: &Op, value: Option<&str>) -> bool {
345    let v = value.unwrap_or("");
346    match op {
347        Op::Eq(expected) => v == expected,
348        Op::Ne(expected) => v != expected,
349        Op::Gt(n) => v.parse::<f64>().is_ok_and(|x| x > *n),
350        Op::Ge(n) => v.parse::<f64>().is_ok_and(|x| x >= *n),
351        Op::Lt(n) => v.parse::<f64>().is_ok_and(|x| x < *n),
352        Op::Le(n) => v.parse::<f64>().is_ok_and(|x| x <= *n),
353        Op::Regex(re) => re.is_match(v),
354        Op::In(list) => list.iter().any(|item| item == v),
355        Op::Has(needle) => v.split(',').map(str::trim).any(|part| part == needle),
356        Op::IpMatch(nets) => value
357            .and_then(|v| v.parse::<IpAddr>().ok())
358            .is_some_and(|ip| nets.iter().any(|net| net.contains(&ip))),
359    }
360}
361
362// ---- helpers ---------------------------------------------------------------
363
364/// Splits `ip:port`, tolerating bare IPs and bracketed IPv6.
365fn split_remote_addr(addr: &str) -> (&str, Option<&str>) {
366    if let Some(stripped) = addr.strip_prefix('[') {
367        // [v6]:port
368        if let Some((ip, rest)) = stripped.split_once(']') {
369            return (ip, rest.strip_prefix(':'));
370        }
371    }
372    match addr.rsplit_once(':') {
373        // An IPv6 without brackets contains multiple ':'; treat as bare IP.
374        Some((ip, port)) if !ip.contains(':') => (ip, Some(port)),
375        _ => (addr, None),
376    }
377}
378
379fn query_string(ctx: &Context) -> String {
380    let mut pairs: Vec<String> = Vec::new();
381    for (k, values) in &ctx.request.query_params {
382        for v in values {
383            pairs.push(format!("{}={}", k, v));
384        }
385    }
386    pairs.sort();
387    pairs.join("&")
388}
389
390fn cookie_value(ctx: &Context, name: &str) -> Option<String> {
391    let header = ctx.request.headers.get("cookie")?.first()?;
392    for pair in header.split(';') {
393        let (k, v) = pair.trim().split_once('=')?;
394        if k == name {
395            return Some(v.to_string());
396        }
397    }
398    None
399}
400
401fn post_arg(ctx: &Context, field: &str) -> Option<String> {
402    let content_type = ctx.request.headers.get("content-type")?.first()?;
403    if !content_type.starts_with("application/x-www-form-urlencoded") {
404        return None;
405    }
406    let body = std::str::from_utf8(&ctx.request.body).ok()?;
407    for pair in body.split('&') {
408        let (k, v) = pair.split_once('=')?;
409        if k == field {
410            return Some(urldecode(v));
411        }
412    }
413    None
414}
415
416fn urldecode(s: &str) -> String {
417    let mut out = Vec::with_capacity(s.len());
418    let bytes = s.as_bytes();
419    let mut i = 0;
420    while i < bytes.len() {
421        match bytes[i] {
422            b'+' => out.push(b' '),
423            b'%' if i + 2 < bytes.len() => {
424                if let (Some(h), Some(l)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) {
425                    out.push(h * 16 + l);
426                    i += 3;
427                    continue;
428                }
429                out.push(b'%');
430            }
431            b => out.push(b),
432        }
433        i += 1;
434    }
435    String::from_utf8_lossy(&out).into_owned()
436}
437
438fn hex_val(b: u8) -> Option<u8> {
439    match b {
440        b'0'..=b'9' => Some(b - b'0'),
441        b'a'..=b'f' => Some(b - b'a' + 10),
442        b'A'..=b'F' => Some(b - b'A' + 10),
443        _ => None,
444    }
445}
446
447fn message_str<'a>(ctx: &'a Context, key: &str) -> Option<Cow<'a, str>> {
448    match ctx.message.get(key)? {
449        serde_json::Value::String(s) => Some(Cow::Borrowed(s.as_str())),
450        other => Some(Cow::Owned(other.to_string())),
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use super::*;
457    use crate::context::{GatewayRequest, GatewayResponse, Protocol};
458    use bytes::Bytes;
459    use std::collections::HashMap;
460
461    fn test_ctx() -> Context {
462        let mut headers = HashMap::new();
463        headers.insert("user-agent".to_string(), vec!["Mozilla/5.0".to_string()]);
464        headers.insert(
465            "cookie".to_string(),
466            vec!["session=abc123; theme=dark".to_string()],
467        );
468        headers.insert("x-tags".to_string(), vec!["beta, internal".to_string()]);
469        let mut query = HashMap::new();
470        query.insert("name".to_string(), vec!["jack".to_string()]);
471        query.insert("age".to_string(), vec!["30".to_string()]);
472        let mut message = HashMap::new();
473        message.insert("consumer.name".to_string(), serde_json::json!("alice"));
474
475        Context {
476            request: GatewayRequest {
477                method: "GET".to_string(),
478                path: "/api/users".to_string(),
479                host: "example.com".to_string(),
480                scheme: "http".to_string(),
481                headers,
482                query_params: query,
483                body: Bytes::new(),
484                remote_addr: "10.1.2.3:44321".to_string(),
485                protocol: Protocol::Http1,
486            },
487            response: GatewayResponse {
488                status_code: 502,
489                headers: HashMap::new(),
490                body: Bytes::from_static(b"bad gateway"),
491            },
492            message,
493            errors: Vec::new(),
494        }
495    }
496
497    #[test]
498    fn test_resolve_basic_vars() {
499        let ctx = test_ctx();
500        let cases = [
501            ("uri", Some("/api/users")),
502            ("method", Some("GET")),
503            ("host", Some("example.com")),
504            ("scheme", Some("http")),
505            ("remote_addr", Some("10.1.2.3")),
506            ("remote_port", Some("44321")),
507            ("status", Some("502")),
508            ("resp_body", Some("bad gateway")),
509            ("arg_name", Some("jack")),
510            ("arg_missing", None),
511            ("http_user_agent", Some("Mozilla/5.0")),
512            ("http_missing", None),
513            ("cookie_session", Some("abc123")),
514            ("cookie_theme", Some("dark")),
515            ("cookie_missing", None),
516            ("consumer_name", Some("alice")),
517            ("consumer_group_id", None),
518            ("unknown_var", None),
519        ];
520        for (name, expected) in cases {
521            assert_eq!(resolve(&ctx, name).as_deref(), expected, "var {name}");
522        }
523    }
524
525    #[test]
526    fn test_resolve_post_arg() {
527        let mut ctx = test_ctx();
528        ctx.request.headers.insert(
529            "content-type".to_string(),
530            vec!["application/x-www-form-urlencoded".to_string()],
531        );
532        ctx.request.body = Bytes::from_static(b"user=bob&note=hello%20world&plus=a+b");
533        assert_eq!(resolve(&ctx, "post_arg_user").as_deref(), Some("bob"));
534        assert_eq!(
535            resolve(&ctx, "post_arg_note").as_deref(),
536            Some("hello world")
537        );
538        assert_eq!(resolve(&ctx, "post_arg_plus").as_deref(), Some("a b"));
539        assert_eq!(resolve(&ctx, "post_arg_missing"), None);
540
541        // wrong content type -> no post args
542        ctx.request.headers.insert(
543            "content-type".to_string(),
544            vec!["application/json".to_string()],
545        );
546        assert_eq!(resolve(&ctx, "post_arg_user"), None);
547    }
548
549    #[test]
550    fn test_interpolate() {
551        let ctx = test_ctx();
552        assert_eq!(
553            interpolate(&ctx, "$remote_addr -> $uri"),
554            "10.1.2.3 -> /api/users"
555        );
556        assert_eq!(
557            interpolate(&ctx, "${scheme}://${host}${uri}"),
558            "http://example.com/api/users"
559        );
560        assert_eq!(interpolate(&ctx, "user=$arg_name!"), "user=jack!");
561        assert_eq!(interpolate(&ctx, "missing=[$arg_nope]"), "missing=[]");
562        assert_eq!(interpolate(&ctx, "cost: 5$"), "cost: 5$");
563        assert_eq!(interpolate(&ctx, "no vars here"), "no vars here");
564    }
565
566    fn expr(json: serde_json::Value) -> Expr {
567        Expr::parse(&json).expect("expression should parse")
568    }
569
570    #[test]
571    fn test_expr_operators() {
572        let ctx = test_ctx();
573        let truthy = [
574            serde_json::json!([["arg_name", "==", "jack"]]),
575            serde_json::json!([["arg_name", "~=", "jill"]]),
576            serde_json::json!([["arg_age", ">", 18]]),
577            serde_json::json!([["arg_age", "<=", "30"]]),
578            serde_json::json!([["http_user_agent", "~~", "Mozilla.+"]]),
579            serde_json::json!([["http_user_agent", "~*", "mozilla.+"]]),
580            serde_json::json!([["arg_name", "in", ["jack", "jill"]]]),
581            serde_json::json!([["http_x_tags", "has", "beta"]]),
582            serde_json::json!([["remote_addr", "ipmatch", ["10.0.0.0/8"]]]),
583            serde_json::json!([["remote_addr", "ipmatch", ["10.1.2.3"]]]),
584            serde_json::json!([["arg_name", "!", "==", "jill"]]),
585            serde_json::json!([["arg_name", "==", "jack"], ["arg_age", ">=", 30]]),
586            serde_json::json!([["OR", ["arg_name", "==", "nope"], ["arg_age", "==", "30"]]]),
587        ];
588        for case in &truthy {
589            assert!(expr(case.clone()).eval(&ctx), "should be true: {case}");
590        }
591
592        let falsy = [
593            serde_json::json!([["arg_name", "==", "jill"]]),
594            serde_json::json!([["arg_age", ">", 30]]),
595            serde_json::json!([["remote_addr", "ipmatch", ["192.168.0.0/16"]]]),
596            serde_json::json!([["arg_name", "==", "jack"], ["arg_age", ">", 99]]),
597            serde_json::json!([["AND", ["arg_name", "==", "jack"], ["arg_age", ">", 99]]]),
598            serde_json::json!([["arg_missing", "==", "x"]]),
599        ];
600        for case in &falsy {
601            assert!(!expr(case.clone()).eval(&ctx), "should be false: {case}");
602        }
603    }
604
605    #[test]
606    fn test_expr_parse_errors() {
607        let bad = [
608            serde_json::json!("not an array"),
609            serde_json::json!([["arg_x", "unknown_op", "v"]]),
610            serde_json::json!([["arg_x", "~~", "("]]),
611            serde_json::json!([["remote_addr", "ipmatch", ["not-an-ip"]]]),
612            serde_json::json!([["arg_x", "=="]]),
613            serde_json::json!([[]]),
614        ];
615        for case in &bad {
616            assert!(Expr::parse(case).is_err(), "should fail to parse: {case}");
617        }
618    }
619
620    #[test]
621    fn test_split_remote_addr_forms() {
622        assert_eq!(split_remote_addr("1.2.3.4:80"), ("1.2.3.4", Some("80")));
623        assert_eq!(split_remote_addr("1.2.3.4"), ("1.2.3.4", None));
624        assert_eq!(split_remote_addr("[::1]:8080"), ("::1", Some("8080")));
625        assert_eq!(split_remote_addr("::1"), ("::1", None));
626    }
627}