From: Vsevolod Stakhov Date: Fri, 10 Jul 2026 07:32:07 +0000 (+0100) Subject: [Feature] composites: per-symbol Lua conditions and explicit dependencies X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=51ff7cbc529672392641e0cb5f3b7f6e203da7b0;p=thirdparty%2Frspamd.git [Feature] composites: per-symbol Lua conditions and explicit dependencies 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. --- diff --git a/src/libserver/composites/composites.cxx b/src/libserver/composites/composites.cxx index bfb5958391..6c69c0cc88 100644 --- a/src/libserver/composites/composites.cxx +++ b/src/libserver/composites/composites.cxx @@ -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 #include #include +#include #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 +{ + struct rspamd_task *task = cd->task; + auto *L = (lua_State *) task->cfg->lua_state; + std::optional 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 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; + } } } } diff --git a/src/libserver/composites/composites_internal.hxx b/src/libserver/composites/composites_internal.hxx index ffe4a66f32..7d40158da0 100644 --- a/src/libserver/composites/composites_internal.hxx +++ b/src/libserver/composites/composites_internal.hxx @@ -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 + conditions; + /* Symbols consulted by conditions beyond the expression atoms; feed + * first/second pass placement in process_dependencies() */ + std::vector 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; + } }; /** diff --git a/src/libserver/composites/composites_manager.cxx b/src/libserver/composites/composites_manager.cxx index c43f032f99..9f3628d8b4 100644 --- a/src/libserver/composites/composites_manager.cxx +++ b/src/libserver/composites/composites_manager.cxx @@ -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 #include #include @@ -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(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; diff --git a/src/lua/lua_common.h b/src/lua/lua_common.h index 382021ba0f..6d8ff35af3 100644 --- a/src/lua/lua_common.h +++ b/src/lua/lua_common.h @@ -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 */ diff --git a/src/lua/lua_config.c b/src/lua/lua_config.c index 7190dc638d..06be739507 100644 --- a/src/lua/lua_config.c +++ b/src/lua/lua_config.c @@ -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; + } } } } diff --git a/src/lua/lua_task.c b/src/lua/lua_task.c index 13a8141fb7..8805d40ad2 100644 --- a/src/lua/lua_task.c +++ b/src/lua/lua_task.c @@ -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++); } });