]> git.ipfire.org Git - thirdparty/rspamd.git/commitdiff
[Feature] composites: per-symbol Lua conditions and explicit dependencies
authorVsevolod Stakhov <vsevolod@rspamd.com>
Fri, 10 Jul 2026 07:32:07 +0000 (08:32 +0100)
committerVsevolod Stakhov <vsevolod@rspamd.com>
Fri, 10 Jul 2026 07:32:07 +0000 (08:32 +0100)
Allow gating composite atoms with synchronous Lua functions, by analogy
with re_conditions of mime expressions:

  rspamd_config:add_composite('C', {
    expression = 'SYM_A & SYM_B',
    conditions = {
      SYM_A = function(task, symbol) ... end,
    },
    depends_on = { 'SOME_POSTFILTER_SYM' },
  })

A condition is called as f(task, symbol) where symbol is a table in the
task:get_symbol() layout; it may return true/false or a number used as
the atom weight. The condition is ANDed with option filters of the atom
and a failed condition is treated exactly as a missing symbol, so
removal policies are not applied. Since a condition can consult symbols
invisible to the expression, the optional depends_on list feeds the
first/second pass placement as if those symbols were expression atoms.

rspamd_config:add_composite() now also accepts a full definition table,
and rspamd_lua_push_symbol_result() is exported for reuse.

src/libserver/composites/composites.cxx
src/libserver/composites/composites_internal.hxx
src/libserver/composites/composites_manager.cxx
src/lua/lua_common.h
src/lua/lua_config.c
src/lua/lua_task.c

index bfb59583912edbf5501bdf8c2ed797813e282ca3..6c69c0cc88a2bcc6aa6a943666e507b591786ff0 100644 (file)
@@ -14,6 +14,9 @@
  * limitations under the License.
  */
 #include "config.h"
+/* Must go first to keep the C linkage of the raw lua headers */
+#include "lua/lua_common.h"
+
 #include "logger.h"
 #include "expression.h"
 #include "task.h"
@@ -26,6 +29,7 @@
 #include <cmath>
 #include <vector>
 #include <variant>
+#include <optional>
 #include "libutil/cxx/util.hxx"
 #include "contrib/ankerl/unordered_dense.h"
 
@@ -589,6 +593,65 @@ process_symbol_removal(rspamd_expression_atom_t *atom,
        }
 }
 
+/*
+ * Call a per-symbol Lua condition: f(task, symbol) where symbol is a table
+ * in the task:get_symbol() layout. Must be synchronous (no yields/coroutines).
+ *
+ * Returns std::nullopt when the condition returned `true` (the atom keeps its
+ * score-based weight) or an explicit weight: a number returned by the
+ * condition, or 0.0 for `false`/nil/error (the atom is treated as unmatched).
+ */
+static auto
+check_lua_condition(struct composites_data *cd,
+                                       std::string_view sym,
+                                       struct rspamd_symbol_result *ms,
+                                       int cbref) -> std::optional<double>
+{
+       struct rspamd_task *task = cd->task;
+       auto *L = (lua_State *) task->cfg->lua_state;
+       std::optional<double> ret = 0.0;
+
+       lua_pushcfunction(L, &rspamd_lua_traceback);
+       auto err_idx = lua_gettop(L);
+
+       lua_rawgeti(L, LUA_REGISTRYINDEX, cbref);
+       rspamd_lua_task_push(L, task);
+
+       if (!rspamd_lua_push_symbol_result(L, task, sym.data(), ms,
+                                                                          cd->metric_res, FALSE, TRUE)) {
+               lua_pushnil(L);
+       }
+
+       if (lua_pcall(L, 2, 1, err_idx) != 0) {
+               msg_err_task("cannot execute lua condition for symbol %s in composite %s: %s",
+                                        sym.data(), cd->composite->sym.c_str(), lua_tostring(L, -1));
+       }
+       else {
+               switch (lua_type(L, -1)) {
+               case LUA_TBOOLEAN:
+                       if (lua_toboolean(L, -1)) {
+                               ret = std::nullopt;
+                       }
+                       break;
+               case LUA_TNUMBER:
+                       ret = lua_tonumber(L, -1);
+                       break;
+               case LUA_TNIL:
+                       break;
+               default:
+                       msg_err_task("lua condition for symbol %s in composite %s returned %s; "
+                                                "boolean or number expected",
+                                                sym.data(), cd->composite->sym.c_str(),
+                                                lua_typename(L, lua_type(L, -1)));
+                       break;
+               }
+       }
+
+       lua_settop(L, err_idx - 1);
+
+       return ret;
+}
+
 static auto
 process_single_symbol(struct composites_data *cd,
                                          std::string_view sym,
@@ -674,11 +737,32 @@ process_single_symbol(struct composites_data *cd,
                }
 
                if (ms) {
-                       if (ms->score == 0) {
-                               rc = epsilon * 16.0; /* Distinguish from 0 */
+                       /* A Lua condition is ANDed with option filters; a failed
+                        * condition is treated exactly as a missing symbol */
+                       std::optional<double> cond_weight;
+                       auto cbref = cd->composite->find_condition(sym);
+
+                       if (cbref != -1) {
+                               cond_weight = check_lua_condition(cd, sym, ms, cbref);
+
+                               if (cond_weight && *cond_weight == 0) {
+                                       msg_debug_composites("symbol %s in composite %s failed lua condition",
+                                                                                sym.data(), cd->composite->sym.c_str());
+                                       ms = nullptr;
+                               }
                        }
-                       else {
-                               rc = ms->score;
+
+                       if (ms) {
+                               if (cond_weight) {
+                                       /* Explicit atom weight returned by the condition */
+                                       rc = *cond_weight;
+                               }
+                               else if (ms->score == 0) {
+                                       rc = epsilon * 16.0; /* Distinguish from 0 */
+                               }
+                               else {
+                                       rc = ms->score;
+                               }
                        }
                }
        }
index ffe4a66f32b7127c564cb5f796eed21b53045e6c..7d40158da015c05fc1621852384c4e3ad02bd947 100644 (file)
@@ -54,6 +54,27 @@ struct rspamd_composite {
        bool second_pass;        /**< true if this composite needs second pass evaluation */
        bool has_positive_atoms; /**< true if composite has at least one non-negated atom */
        bool disabled;           /**< true if composite is a placeholder stub (evaluates to false) */
+
+       /* Per-symbol Lua conditions (registry refs owned by the config UCL tree),
+        * keyed by symbol name as written in the expression (no prefixes/brackets).
+        * A condition is ANDed with option filters of the matching atom. */
+       ankerl::unordered_dense::map<std::string, int,
+                                                                rspamd::smart_str_hash, rspamd::smart_str_equal>
+               conditions;
+       /* Symbols consulted by conditions beyond the expression atoms; feed
+        * first/second pass placement in process_dependencies() */
+       std::vector<std::string> depends_on;
+
+       auto find_condition(std::string_view sym_name) const -> int
+       {
+               if (conditions.empty()) {
+                       return -1;
+               }
+
+               auto it = conditions.find(sym_name);
+
+               return it != conditions.end() ? it->second : -1;
+       }
 };
 
 /**
index c43f032f99165cdbaeac758517082551668d8d5e..9f3628d8b407f2b0c9cd181156b14938d0da7105 100644 (file)
@@ -14,6 +14,9 @@
  * limitations under the License.
  */
 
+/* Must go first to keep the C linkage of the raw lua headers */
+#include "lua/lua_common.h"
+
 #include <memory>
 #include <vector>
 #include <cmath>
@@ -52,6 +55,84 @@ composite_policy_from_str(const std::string_view &inp) -> enum rspamd_composite_
        return rspamd_composite_policy::RSPAMD_COMPOSITE_POLICY_UNKNOWN;
 }// namespace rspamd::composites
 
+/*
+ * Parse the optional `conditions` object (symbol name -> Lua function, coming
+ * through the config UCL tree as UCL_USERDATA closures) and the optional
+ * `depends_on` string/array. Returns false on a malformed definition: the
+ * whole composite must be rejected rather than silently accepted with a
+ * weaker match, since conditions are used as gates.
+ */
+static auto
+composite_parse_conditions(struct rspamd_config *cfg,
+                                                  std::string_view composite_name,
+                                                  const ucl_object_t *obj,
+                                                  rspamd_composite &composite) -> bool
+{
+       const auto *val = ucl_object_lookup(obj, "conditions");
+
+       if (val != nullptr) {
+               if (ucl_object_type(val) != UCL_OBJECT) {
+                       msg_err_config("composite %*s: 'conditions' must be an object keyed by symbol name",
+                                                  (int) composite_name.size(), composite_name.data());
+                       return false;
+               }
+
+               auto *it = ucl_object_iterate_new(val);
+               const ucl_object_t *cur;
+
+               while ((cur = ucl_object_iterate_safe(it, true)) != nullptr) {
+                       const auto *sym_name = ucl_object_key(cur);
+                       const auto *fd = ucl_object_toclosure(cur);
+
+                       if (sym_name == nullptr || fd == nullptr) {
+                               msg_err_config("composite %*s: condition for symbol '%s' must be a function",
+                                                          (int) composite_name.size(), composite_name.data(),
+                                                          sym_name ? sym_name : "unknown");
+                               ucl_object_iterate_free(it);
+                               return false;
+                       }
+
+                       composite.conditions[std::string{sym_name}] = fd->idx;
+               }
+
+               ucl_object_iterate_free(it);
+       }
+
+       val = ucl_object_lookup(obj, "depends_on");
+
+       if (val != nullptr) {
+               if (ucl_object_type(val) == UCL_STRING) {
+                       composite.depends_on.emplace_back(ucl_object_tostring(val));
+               }
+               else if (ucl_object_type(val) == UCL_ARRAY) {
+                       auto *it = ucl_object_iterate_new(val);
+                       const ucl_object_t *cur;
+
+                       while ((cur = ucl_object_iterate_safe(it, true)) != nullptr) {
+                               const char *dep_sym = nullptr;
+
+                               if (!ucl_object_tostring_safe(cur, &dep_sym)) {
+                                       msg_err_config("composite %*s: 'depends_on' elements must be strings",
+                                                                  (int) composite_name.size(), composite_name.data());
+                                       ucl_object_iterate_free(it);
+                                       return false;
+                               }
+
+                               composite.depends_on.emplace_back(dep_sym);
+                       }
+
+                       ucl_object_iterate_free(it);
+               }
+               else {
+                       msg_err_config("composite %*s: 'depends_on' must be a string or an array of strings",
+                                                  (int) composite_name.size(), composite_name.data());
+                       return false;
+               }
+       }
+
+       return true;
+}
+
 auto composites_manager::add_composite(std::string_view composite_name, const ucl_object_t *obj, bool silent_duplicate) -> rspamd_composite *
 {
 
@@ -95,7 +176,16 @@ auto composites_manager::add_composite(std::string_view composite_name, const uc
                return nullptr;
        }
 
+       /* Parse conditions/depends_on before the composite is registered anywhere,
+        * so a malformed definition is rejected atomically */
+       rspamd_composite parsed_conditions;
+       if (!composite_parse_conditions(cfg, composite_name, obj, parsed_conditions)) {
+               return nullptr;
+       }
+
        const auto &composite = new_composite(composite_name, expr, composite_expression);
+       composite->conditions = std::move(parsed_conditions.conditions);
+       composite->depends_on = std::move(parsed_conditions.depends_on);
 
        auto score = std::isnan(cfg->unknown_weight) ? 0.0 : cfg->unknown_weight;
        val = ucl_object_lookup(obj, "score");
@@ -498,6 +588,24 @@ void composites_manager::process_dependencies(composites_generation &gen)
                                                                           composite_dep_callback,
                                                                           &cbd);
 
+               if (!cbd.needs_second_pass) {
+                       /* Symbols consulted by Lua conditions are invisible to the atom
+                        * walk; treat explicit depends_on entries as extra atoms */
+                       for (const auto &dep: comp->depends_on) {
+                               if (gen.find(dep) != nullptr) {
+                                       /* Reference to another composite: transitive pass handles it */
+                                       continue;
+                               }
+
+                               if (symbol_needs_second_pass(cfg, dep.c_str())) {
+                                       msg_debug_config("composite '%s' depends on second-pass symbol %s (depends_on)",
+                                                                        comp->sym.c_str(), dep.c_str());
+                                       cbd.needs_second_pass = true;
+                                       break;
+                               }
+                       }
+               }
+
                if (cbd.needs_second_pass) {
                        second_pass_set.insert(comp);
                        msg_debug_config("composite '%s' marked for second pass (direct dependency)",
@@ -532,6 +640,18 @@ void composites_manager::process_dependencies(composites_generation &gen)
                                                                                                   }
                                                                                           } }, &trans_data);
 
+                       if (!has_second_pass_dep) {
+                               /* depends_on may also name other composites */
+                               for (const auto &dep: comp->depends_on) {
+                                       if (auto *dep_comp = gen.find(dep);
+                                               dep_comp != nullptr &&
+                                               second_pass_set.contains(const_cast<rspamd_composite *>(dep_comp))) {
+                                               has_second_pass_dep = true;
+                                               break;
+                                       }
+                               }
+                       }
+
                        if (has_second_pass_dep) {
                                second_pass_set.insert(comp);
                                changed = true;
@@ -1041,6 +1161,15 @@ auto composites_manager::add_composite_to_staging(composites_generation &staging
                composite->policy = p;
        }
 
+       /*
+        * Dynamic maps deliver plain UCL text, so `conditions` closures cannot
+        * arrive through this path; the parser will reject them with an error.
+        * `depends_on` is fully supported though.
+        */
+       if (!composite_parse_conditions(cfg, name, obj, *composite)) {
+               return nullptr;
+       }
+
        /* Replace any existing entry under this name (came from base_gen
         * clone or from an earlier entry in this same map). */
        auto sym_key = composite->sym;
index 382021ba0f6a1cbef3e8a44273d7e030eca1fbf7..6d8ff35af3bfe57a1c3b733db8a9ad5cc78ef884 100644 (file)
@@ -283,6 +283,20 @@ void rspamd_lua_ip_push(lua_State *L, rspamd_inet_addr_t *addr);
 */
 void rspamd_lua_task_push(lua_State *L, struct rspamd_task *task);
 
+/**
+* Push a symbol result as a table in the same layout as task:get_symbol();
+* returns FALSE (nothing is pushed) if the symbol is not found or ignored
+*/
+struct rspamd_symbol_result;
+struct rspamd_scan_result;
+gboolean rspamd_lua_push_symbol_result(lua_State *L,
+                                                                          struct rspamd_task *task,
+                                                                          const char *symbol,
+                                                                          struct rspamd_symbol_result *symbol_result,
+                                                                          struct rspamd_scan_result *metric_res,
+                                                                          gboolean add_metric,
+                                                                          gboolean add_name);
+
 /**
 * Return lua ip structure at the specified address
 */
index 7190dc638d004652c1e51ecbddb984989ba22c05..06be739507d3f12ca931cb76318b01d531e5835d 100644 (file)
@@ -362,7 +362,12 @@ LUA_FUNCTION_DEF(config, get_all_actions);
 /**
  * @method rspamd_config:add_composite(name, expression)
  * @param {string} name name of composite symbol
- * @param {string} expression symbolic expression of the composite rule
+ * @param {string|table} expression symbolic expression of the composite rule,
+ * or a full definition table: {expression = ..., score = ..., group = ...,
+ * description = ..., policy = ..., conditions = {SYM = function(task, symbol) ... end},
+ * depends_on = {'SYM1', ...}}. A condition is called as f(task, symbol) where
+ * symbol is a table in the task:get_symbol() layout; it must be synchronous and
+ * may return true/false or a number used as the atom weight.
  * @return {bool} true if a composite has been added successfully
  */
 LUA_FUNCTION_DEF(config, add_composite);
@@ -3069,16 +3074,41 @@ lua_config_add_composite(lua_State *L)
 
        if (cfg) {
                name = rspamd_mempool_strdup(cfg->cfg_pool, luaL_checkstring(L, 2));
-               expr_str = luaL_checkstring(L, 3);
 
-               if (name && expr_str) {
-                       composite = rspamd_composites_manager_add_from_string(cfg->composites_manager,
-                                                                                                                                 name, expr_str);
+               if (name && lua_type(L, 3) == LUA_TTABLE) {
+                       /* Full definition table, converted to UCL (functions are
+                        * preserved as closures with registry refs) */
+                       ucl_object_t *obj = ucl_object_lua_import(L, 3);
 
-                       if (composite) {
-                               rspamd_symcache_add_symbol(cfg->cache, name,
-                                                                                  0, NULL, composite, SYMBOL_TYPE_COMPOSITE, -1);
-                               ret = TRUE;
+                       if (obj != NULL) {
+                               /*
+                                * The object owns lua registry refs of the conditions, so it
+                                * must live as long as the config does
+                                */
+                               rspamd_mempool_add_destructor(cfg->cfg_pool,
+                                                                                         (rspamd_mempool_destruct_t) ucl_object_unref, obj);
+                               composite = rspamd_composites_manager_add_from_ucl(cfg->composites_manager,
+                                                                                                                                  name, obj);
+
+                               if (composite) {
+                                       rspamd_symcache_add_symbol(cfg->cache, name,
+                                                                                          0, NULL, composite, SYMBOL_TYPE_COMPOSITE, -1);
+                                       ret = TRUE;
+                               }
+                       }
+               }
+               else if (name) {
+                       expr_str = luaL_checkstring(L, 3);
+
+                       if (expr_str) {
+                               composite = rspamd_composites_manager_add_from_string(cfg->composites_manager,
+                                                                                                                                         name, expr_str);
+
+                               if (composite) {
+                                       rspamd_symcache_add_symbol(cfg->cache, name,
+                                                                                          0, NULL, composite, SYMBOL_TYPE_COMPOSITE, -1);
+                                       ret = TRUE;
+                               }
                        }
                }
        }
index 13a8141fb701f4517637cbe549a423c6b612e6e2..8805d40ad2bb632c111de50c0cabc307c7fedd60 100644 (file)
@@ -5240,14 +5240,14 @@ lua_task_get_dkim_results(lua_State *L)
        return 1;
 }
 
-static inline gboolean
-lua_push_symbol_result(lua_State *L,
-                                          struct rspamd_task *task,
-                                          const char *symbol,
-                                          struct rspamd_symbol_result *symbol_result,
-                                          struct rspamd_scan_result *metric_res,
-                                          gboolean add_metric,
-                                          gboolean add_name)
+gboolean
+rspamd_lua_push_symbol_result(lua_State *L,
+                                                         struct rspamd_task *task,
+                                                         const char *symbol,
+                                                         struct rspamd_symbol_result *symbol_result,
+                                                         struct rspamd_scan_result *metric_res,
+                                                         gboolean add_metric,
+                                                         gboolean add_name)
 {
 
        struct rspamd_symbol_result *s = NULL;
@@ -5374,7 +5374,7 @@ lua_task_symbol_push_into_map(lua_State *L,
                                                          struct rspamd_scan_result *sres,
                                                          unsigned int *count)
 {
-       if (lua_push_symbol_result(L, task, name, s, sres, FALSE, FALSE)) {
+       if (rspamd_lua_push_symbol_result(L, task, name, s, sres, FALSE, FALSE)) {
                lua_setfield(L, -2, name);
                (*count)++;
        }
@@ -5430,8 +5430,8 @@ lua_task_get_symbol(lua_State *L)
                /* Always push as a table for compatibility :( */
                lua_createtable(L, 1, 0);
 
-               if ((found = lua_push_symbol_result(L, task, symbol,
-                                                                                       NULL, sres, TRUE, FALSE))) {
+               if ((found = rspamd_lua_push_symbol_result(L, task, symbol,
+                                                                                                  NULL, sres, TRUE, FALSE))) {
                        lua_rawseti(L, -2, 1);
                }
                else {
@@ -5718,7 +5718,7 @@ lua_task_get_symbols_all(lua_State *L)
 
                        kh_foreach_value(mres->symbols, s, {
                                if (!(s->flags & RSPAMD_SYMBOL_RESULT_IGNORED)) {
-                                       lua_push_symbol_result(L, task, s->name, s, mres, FALSE, TRUE);
+                                       rspamd_lua_push_symbol_result(L, task, s->name, s, mres, FALSE, TRUE);
                                        lua_rawseti(L, -2, i++);
                                }
                        });