]> git.ipfire.org Git - thirdparty/rspamd.git/commitdiff
[Fix] tokenizer: bound the words retained for a message
authorVsevolod Stakhov <vsevolod@rspamd.com>
Mon, 27 Jul 2026 12:44:35 +0000 (13:44 +0100)
committerVsevolod Stakhov <vsevolod@rspamd.com>
Mon, 27 Jul 2026 12:44:35 +0000 (13:44 +0100)
The UTF tokenizer marked the decayed words as skipped but still added
them to the words vector, unlike the raw tokenizer which drops them. The
decay therefore bounded nothing: every word of a text part was retained
(72 bytes each) and then normalized and stemmed in
rspamd_mime_part_create_words, with several task pool allocations per
word. For a single 5 MB text part of 500k words that is 34 MiB of word
vectors and 27 MiB of pool allocations, where the decay is meant to keep
around 1200 words.

The 200 ms tokenization guard did not help either, as it is only enabled
for the parts above 1 MiB, while the remaining bound is 1 GiB of words
per part. A message of many sub-megabyte parts could thus accumulate
huge word vectors within the 50 MiB message limit.

- drop the decayed words in the UTF tokenizer as the raw one does
- add a message wide budget (100k words, 1 MiB of text) shared by all
  text parts and the meta words, so that many small parts cannot bypass
  the per part limits; the parts that hit it are flagged with
  RSPAMD_MIME_TEXT_PART_FLAG_WORDS_TRUNCATED and the message is logged
  once
- enable the time checks by the number of the produced words as well as
  by the part size
- skip normalizing and stemming the words that are flagged as skipped

Note that the decayed words of the UTF parts are no longer visible to
the word regexp classes and to the part similarity hashes, which matches
the long standing behaviour of the raw tokenizer.

src/libmime/message.c
src/libmime/message.h
src/libserver/word.h
src/libstat/tokenizers/tokenizers.c
src/libstat/tokenizers/tokenizers.h
src/lua/lua_parsers.c
test/rspamd_cxx_unit.cxx
test/rspamd_cxx_unit_tokenizer.hxx [new file with mode: 0644]

index 9589096fa806d4cb4ec979b4ee7d89ef34dfb7f9..5067c9edf682ddc08e44c15cbfe12f97a4bff961 100644 (file)
@@ -147,6 +147,15 @@ rspamd_mime_part_extract_words(struct rspamd_task *task,
        }
 }
 
+/*
+ * Bound the words retained for the whole message: the per part decay merely
+ * samples words within a single part, so a message built of many small parts
+ * can still produce a huge amount of words, and each of them is normalized and
+ * stemmed with several allocations from the task pool afterwards
+ */
+static const uint64_t max_message_words = 100000;
+static const uint64_t max_message_words_bytes = 1024 * 1024;
+
 static void
 rspamd_mime_part_create_words(struct rspamd_task *task,
                                                          struct rspamd_mime_text_part *part)
@@ -201,8 +210,21 @@ rspamd_mime_part_create_words(struct rspamd_task *task,
                part->exceptions,
                NULL,
                &part->utf_words,
+               &MESSAGE_FIELD(task, words_budget),
                task->task_pool);
 
+       if (MESSAGE_FIELD(task, words_budget).exceeded) {
+               part->flags |= RSPAMD_MIME_TEXT_PART_FLAG_WORDS_TRUNCATED;
+
+               if (!MESSAGE_FIELD(task, text_words_limit_reached)) {
+                       msg_warn_task("words limit reached: %uL words of %uL bytes are "
+                                                 "tokenized for the whole message, the remaining text "
+                                                 "is not tokenized",
+                                                 MESSAGE_FIELD(task, words_budget).words,
+                                                 MESSAGE_FIELD(task, words_budget).bytes);
+                       MESSAGE_FIELD(task, text_words_limit_reached) = TRUE;
+               }
+       }
 
        if (part->utf_words.a) {
                part->normalized_hashes = g_array_sized_new(FALSE, FALSE,
@@ -1541,6 +1563,8 @@ rspamd_message_new(struct rspamd_task *task)
        msg->parts = g_ptr_array_sized_new(4);
        msg->text_parts = g_ptr_array_sized_new(2);
        msg->task = task;
+       msg->words_budget.max_words = max_message_words;
+       msg->words_budget.max_bytes = max_message_words_bytes;
 
        REF_INIT_RETAIN(msg, rspamd_message_dtor);
 
index dff270f96942cbc9d8a0d81a204b373f46c593fb..b07a087284d9e8daaf41e51143d1a5e80a11dcda 100644 (file)
@@ -123,6 +123,7 @@ struct rspamd_mime_part {
 #define RSPAMD_MIME_TEXT_PART_FLAG_8BIT_ENCODED (1 << 4)
 #define RSPAMD_MIME_TEXT_PART_ATTACHMENT (1 << 5)
 #define RSPAMD_MIME_TEXT_PART_FLAG_NEWLINES_TRUNCATED (1 << 6)
+#define RSPAMD_MIME_TEXT_PART_FLAG_WORDS_TRUNCATED (1 << 7)
 
 #define IS_TEXT_PART_EMPTY(part) ((part)->flags & RSPAMD_MIME_TEXT_PART_FLAG_EMPTY)
 #define IS_TEXT_PART_UTF(part) ((part)->flags & RSPAMD_MIME_TEXT_PART_FLAG_UTF)
@@ -203,6 +204,8 @@ struct rspamd_message {
        gboolean archive_files_limit_reached;
        unsigned int ntext_newlines; /**< total tracked text newline positions */
        gboolean text_newlines_limit_reached;
+       struct rspamd_tokenize_budget words_budget; /**< message wide words budget */
+       gboolean text_words_limit_reached;
        struct rspamd_task *task;
        GPtrArray *rcpt_mime;
        GPtrArray *from_mime;
index 7698bf327591ea17d01254394b012290919096e4..35c992fd9233b90987b28be9ed10bdcb69e06ef5 100644 (file)
@@ -62,6 +62,22 @@ typedef struct rspamd_word_s {
  */
 typedef kvec_t(rspamd_word_t) rspamd_words_t;
 
+/**
+ * Bounds tokenization for a single message: both the number of the retained
+ * words and the amount of text behind them, as every retained word is
+ * normalized and stemmed afterwards, and that costs several allocations from
+ * the task pool. The same budget is used for all text parts of a message, so
+ * that many small parts cannot bypass the per-part limits.
+ * Zero limits mean 'unlimited'.
+ */
+struct rspamd_tokenize_budget {
+       uint64_t max_words; /**< maximum number of the retained words */
+       uint64_t max_bytes; /**< maximum total length of the retained words */
+       uint64_t words;     /**< words retained so far */
+       uint64_t bytes;     /**< bytes retained so far */
+       gboolean exceeded;  /**< set when one of the limits has been hit */
+};
+
 /* Legacy typedefs for backward compatibility */
 typedef rspamd_word_t rspamd_stat_token_t;
 
index 6179ae0017447e0e71a61b106570fdfe0a1d2ad0..17f99117f63970215510ad5fd63e6608105a9251 100644 (file)
@@ -243,8 +243,54 @@ rspamd_utf_word_valid(const unsigned char *text, const unsigned char *end,
                }                                                       \
        } while (0)
 
+/*
+ * Account a token that is about to be retained against the message wide budget.
+ * Returns FALSE when the budget is exhausted, meaning that tokenization must
+ * stop: the token must not be added in that case.
+ */
+static inline gboolean
+rspamd_tokenize_budget_take(struct rspamd_tokenize_budget *budget, gsize len)
+{
+       if (budget == NULL) {
+               return TRUE;
+       }
+
+       if ((budget->max_words > 0 && budget->words >= budget->max_words) ||
+               (budget->max_bytes > 0 && budget->bytes + len > budget->max_bytes)) {
+               budget->exceeded = TRUE;
+
+               return FALSE;
+       }
+
+       budget->words++;
+       budget->bytes += len;
+
+       return TRUE;
+}
+
+/*
+ * Time checks are not free, so they are performed for the inputs that are
+ * likely to be slow: large parts and, regardless of the part size, the inputs
+ * that have already produced a lot of tokens (e.g. many small parts of the
+ * same message)
+ */
+static inline gboolean
+rspamd_tokenize_needs_time_check(gboolean long_text_mode,
+                                                                const struct rspamd_tokenize_budget *budget,
+                                                                gsize nwords)
+{
+       static const gsize long_text_words = 4096;
+
+       if (long_text_mode || nwords >= long_text_words) {
+               return TRUE;
+       }
+
+       return budget != NULL && budget->words >= long_text_words;
+}
+
 static inline void
-rspamd_tokenize_exception(struct rspamd_process_exception *ex, rspamd_words_t *res)
+rspamd_tokenize_exception(struct rspamd_process_exception *ex, rspamd_words_t *res,
+                                                 struct rspamd_tokenize_budget *budget)
 {
        rspamd_word_t token;
 
@@ -255,6 +301,10 @@ rspamd_tokenize_exception(struct rspamd_process_exception *ex, rspamd_words_t *r
                token.original.len = sizeof("!!EX!!") - 1;
                token.flags = RSPAMD_STAT_TOKEN_FLAG_EXCEPTION;
 
+               if (!rspamd_tokenize_budget_take(budget, token.original.len)) {
+                       return;
+               }
+
                kv_push_safe(rspamd_word_t, *res, token, exception_error);
                token.flags = 0;
        }
@@ -273,6 +323,11 @@ rspamd_tokenize_exception(struct rspamd_process_exception *ex, rspamd_words_t *r
                }
 
                token.flags = RSPAMD_STAT_TOKEN_FLAG_EXCEPTION;
+
+               if (!rspamd_tokenize_budget_take(budget, token.original.len)) {
+                       return;
+               }
+
                kv_push_safe(rspamd_word_t, *res, token, exception_error);
                token.flags = 0;
        }
@@ -292,6 +347,7 @@ rspamd_tokenize_text(const char *text, gsize len,
                                         GList *exceptions,
                                         uint64_t *hash,
                                         rspamd_words_t *output_kvec,
+                                        struct rspamd_tokenize_budget *budget,
                                         rspamd_mempool_t *pool)
 {
        rspamd_word_t token, buf;
@@ -320,9 +376,14 @@ rspamd_tokenize_text(const char *text, gsize len,
                 * In this mode we do additional checks to avoid performance issues
                 */
                long_text_mode = TRUE;
-               start = ev_time();
        }
 
+       /*
+        * Time checks can also be enabled by the number of the produced tokens, so
+        * the start time is always needed
+        */
+       start = ev_time();
+
        buf.original.begin = text;
        buf.original.len = len;
        buf.flags = 0;
@@ -363,6 +424,16 @@ rspamd_tokenize_text(const char *text, gsize len,
 
                                /* Copy custom tokenizer results to output kvec */
                                for (unsigned int i = 0; i < kv_size(*custom_res); i++) {
+                                       if (!rspamd_tokenize_budget_take(budget,
+                                                                                                        kv_A(*custom_res, i).original.len)) {
+                                               msg_debug_pool_check(
+                                                       "words budget is exhausted, stop tokenization: "
+                                                       "%z words of %z are copied from the custom tokenizer",
+                                                       (gsize) i, kv_size(*custom_res));
+
+                                               break;
+                                       }
+
                                        kv_push_safe(rspamd_word_t, *res, kv_A(*custom_res, i), custom_tokenizer_error);
                                }
 
@@ -411,7 +482,8 @@ rspamd_tokenize_text(const char *text, gsize len,
                                }
                        }
 
-                       if (long_text_mode) {
+                       if (rspamd_tokenize_needs_time_check(long_text_mode, budget,
+                                                                                                kv_size(*res))) {
                                if ((kv_size(*res) + 1) % 16 == 0) {
                                        ev_tstamp now = ev_time();
 
@@ -427,6 +499,15 @@ rspamd_tokenize_text(const char *text, gsize len,
                                }
                        }
 
+                       if (!rspamd_tokenize_budget_take(budget, token.original.len)) {
+                               msg_debug_pool_check(
+                                       "words budget is exhausted: %uL words, %uL bytes; "
+                                       "stop tokenization",
+                                       budget->words, budget->bytes);
+
+                               goto end;
+                       }
+
                        kv_push_safe(rspamd_word_t, *res, token, tokenize_error);
 
                        if (kv_size(*res) * sizeof(token) > (0x1ull << 30u)) {
@@ -474,7 +555,7 @@ rspamd_tokenize_text(const char *text, gsize len,
                                                while (cur && ex->pos <= last) {
                                                        /* We have an exception at the beginning, skip those */
                                                        last += ex->len;
-                                                       rspamd_tokenize_exception(ex, res);
+                                                       rspamd_tokenize_exception(ex, res, budget);
 
                                                        if (last > p) {
                                                                /* Exception spread over the boundaries */
@@ -514,7 +595,7 @@ rspamd_tokenize_text(const char *text, gsize len,
                                                        /* Process the current exception */
                                                        last += ex->len + (ex->pos - last);
 
-                                                       rspamd_tokenize_exception(ex, res);
+                                                       rspamd_tokenize_exception(ex, res, budget);
 
                                                        if (last > p) {
                                                                /* Exception spread over the boundaries */
@@ -586,7 +667,13 @@ rspamd_tokenize_text(const char *text, gsize len,
                                                decay = TRUE;
                                        }
                                        else {
-                                               token.flags |= RSPAMD_STAT_TOKEN_FLAG_SKIPPED;
+                                               /*
+                                                * Drop the decayed token as the raw tokenizer does:
+                                                * keeping it in the vector defeats the decay, as all
+                                                * retained tokens are normalized and stemmed later on
+                                                */
+                                               token.original.len = 0;
+                                               token.flags = 0;
                                        }
                                }
                        }
@@ -601,11 +688,21 @@ rspamd_tokenize_text(const char *text, gsize len,
                                        goto end;
                                }
 
+                               if (!rspamd_tokenize_budget_take(budget, token.original.len)) {
+                                       msg_debug_pool_check(
+                                               "words budget is exhausted: %uL words, %uL bytes; "
+                                               "stop tokenization",
+                                               budget->words, budget->bytes);
+
+                                       goto end;
+                               }
+
                                kv_push_safe(rspamd_word_t, *res, token, tokenize_error);
                        }
 
-                       /* Also check for long text mode */
-                       if (long_text_mode) {
+                       /* Also check for long text mode or a large number of words */
+                       if (rspamd_tokenize_needs_time_check(long_text_mode, budget,
+                                                                                                kv_size(*res))) {
                                /* Check time each 128 words added */
                                const int words_check_mask = 0x7F;
 
@@ -666,6 +763,8 @@ rspamd_add_metawords_from_str(const char *beg, gsize len,
        unsigned int i = 0;
        UChar32 uc;
        gboolean valid_utf = TRUE;
+       /* Meta words share the message wide budget with the text parts */
+       struct rspamd_tokenize_budget *budget = task->message ? &MESSAGE_FIELD(task, words_budget) : NULL;
 
        while (i < len) {
                U8_NEXT(beg, i, len, uc);
@@ -703,6 +802,7 @@ rspamd_add_metawords_from_str(const char *beg, gsize len,
                                                         &utxt, RSPAMD_TOKENIZE_UTF,
                                                         task->cfg, NULL, NULL,
                                                         &task->meta_words,
+                                                        budget,
                                                         task->task_pool);
 
                utext_close(&utxt);
@@ -712,6 +812,7 @@ rspamd_add_metawords_from_str(const char *beg, gsize len,
                                                         NULL, RSPAMD_TOKENIZE_RAW,
                                                         task->cfg, NULL, NULL,
                                                         &task->meta_words,
+                                                        budget,
                                                         task->task_pool);
        }
 }
@@ -935,6 +1036,15 @@ void rspamd_normalize_words(rspamd_words_t *words, rspamd_mempool_t *pool)
 
        for (i = 0; i < kv_size(*words); i++) {
                tok = &kv_A(*words, i);
+
+               /*
+                * Statistics and shingles ignore skipped words, so there is no point
+                * in spending allocations on normalizing them
+                */
+               if (tok->flags & RSPAMD_WORD_FLAG_SKIPPED) {
+                       continue;
+               }
+
                rspamd_normalize_single_word(tok, pool);
        }
 }
@@ -983,6 +1093,11 @@ void rspamd_stem_words(rspamd_words_t *words, rspamd_mempool_t *pool,
        for (i = 0; i < kv_size(*words); i++) {
                tok = &kv_A(*words, i);
 
+               /* Skipped words are not normalized, so there is nothing to stem */
+               if (tok->flags & RSPAMD_WORD_FLAG_SKIPPED) {
+                       continue;
+               }
+
                /* Skip stemming if token has already been stemmed by custom tokenizer */
                if (tok->flags & RSPAMD_STAT_TOKEN_FLAG_STEMMED) {
                        /* Already stemmed, just check for stop words */
index bb0bb54e24f0b28b32e4dd7ccfd094fff3b76c61..b9026ddad51ba60c1e967f6a16f084576ed7bf3b 100644 (file)
@@ -68,6 +68,7 @@ rspamd_words_t *rspamd_tokenize_text(const char *text, gsize len,
                                                                         GList *exceptions,
                                                                         uint64_t *hash,
                                                                         rspamd_words_t *output_kvec,
+                                                                        struct rspamd_tokenize_budget *budget,
                                                                         rspamd_mempool_t *pool);
 
 /* OSB tokenize function */
index a3174d74f04a6fcd13e3ed201bd18c429585bcf3..7fd0ca31a556412fa3211dcf21f435a518095b2f 100644 (file)
@@ -178,7 +178,7 @@ int lua_parsers_tokenize_text(lua_State *L)
                                                           &utxt,
                                                           RSPAMD_TOKENIZE_UTF, NULL,
                                                           exceptions,
-                                                          NULL, NULL, NULL);
+                                                          NULL, NULL, NULL, NULL);
 
        if (res == NULL) {
                lua_pushnil(L);
index 0c5cbca75c5a3679d1771c1b86876ba3bea9b28b..6fca520bed39320c2d87ed11de02ac81797aa093 100644 (file)
@@ -46,6 +46,7 @@
 #include "rspamd_cxx_unit_text_stats.hxx"
 #include "rspamd_cxx_unit_multipattern.hxx"
 #include "rspamd_cxx_unit_compression.hxx"
+#include "rspamd_cxx_unit_tokenizer.hxx"
 #include "rspamd_cxx_unit_http_timeout.hxx"
 
 static gboolean verbose = false;
diff --git a/test/rspamd_cxx_unit_tokenizer.hxx b/test/rspamd_cxx_unit_tokenizer.hxx
new file mode 100644 (file)
index 0000000..337a187
--- /dev/null
@@ -0,0 +1,210 @@
+/*
+ * Copyright 2026 Vsevolod Stakhov
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+#ifndef RSPAMD_RSPAMD_CXX_UNIT_TOKENIZER_HXX
+#define RSPAMD_RSPAMD_CXX_UNIT_TOKENIZER_HXX
+
+#define DOCTEST_CONFIG_IMPLEMENTATION_IN_DLL
+#include "doctest/doctest.h"
+
+/* Both headers provide their own C++ linkage guards */
+#include "libstat/tokenizers/tokenizers.h"
+#include <unicode/utext.h>
+
+#include <memory>
+#include <string>
+
+/*
+ * Bounds of the text tokenizer: the per part words decay and the message wide
+ * words budget. Both must limit the words that are actually retained, as every
+ * retained word is normalized and stemmed later on, with several allocations
+ * from the task pool each.
+ */
+TEST_SUITE("tokenizer limits")
+{
+       /* Distinct words, so that nothing is deduplicated on the way */
+       static auto make_words_text(unsigned int nwords) -> std::string
+       {
+               std::string text;
+
+               text.reserve(nwords * 9);
+
+               for (unsigned int i = 0; i < nwords; i++) {
+                       char buf[16];
+
+                       rspamd_snprintf(buf, sizeof(buf), "word%04d ", i % 10000);
+                       text += buf;
+               }
+
+               return text;
+       }
+
+       static auto tokenize(const std::string &text,
+                                                enum rspamd_tokenize_type how,
+                                                struct rspamd_config *cfg,
+                                                struct rspamd_tokenize_budget *budget,
+                                                rspamd_words_t *words,
+                                                rspamd_mempool_t *pool) -> void
+       {
+               UText utxt = UTEXT_INITIALIZER;
+               UErrorCode uc_err = U_ZERO_ERROR;
+
+               utext_openUTF8(&utxt, text.data(), text.size(), &uc_err);
+               REQUIRE(U_SUCCESS(uc_err));
+
+               rspamd_tokenize_text(text.data(), text.size(),
+                                                        how == RSPAMD_TOKENIZE_UTF ? &utxt : nullptr,
+                                                        how, cfg, nullptr, nullptr, words, budget, pool);
+
+               utext_close(&utxt);
+       }
+
+       static auto count_skipped(const rspamd_words_t &words) -> unsigned int
+       {
+               unsigned int nskipped = 0;
+
+               for (unsigned int i = 0; i < kv_size(words); i++) {
+                       if (kv_A(words, i).flags & RSPAMD_WORD_FLAG_SKIPPED) {
+                               nskipped++;
+                       }
+               }
+
+               return nskipped;
+       }
+
+       TEST_CASE("words decay bounds the retained words")
+       {
+               constexpr auto nwords = 5000;
+               auto cfg = std::make_unique<struct rspamd_config>();
+               auto *pool = rspamd_mempool_new(rspamd_mempool_suggest_size(), "tok", 0);
+               auto text = make_words_text(nwords);
+
+               cfg->words_decay = 50;
+
+               for (auto how: {RSPAMD_TOKENIZE_UTF, RSPAMD_TOKENIZE_RAW}) {
+                       rspamd_words_t words;
+
+                       kv_init(words);
+                       tokenize(text, how, cfg.get(), nullptr, &words, pool);
+
+                       /*
+                        * Decay keeps roughly words_decay more words after it starts, so
+                        * the retained amount must be nowhere near the words in the text
+                        */
+                       CHECK(kv_size(words) > 0);
+                       CHECK(kv_size(words) < nwords / 4);
+                       /* Decayed words must be dropped, not retained and normalized */
+                       CHECK(count_skipped(words) == 0);
+
+                       kv_destroy(words);
+               }
+
+               rspamd_mempool_delete(pool);
+       }
+
+       TEST_CASE("words budget bounds a single part")
+       {
+               auto cfg = std::make_unique<struct rspamd_config>();
+               auto *pool = rspamd_mempool_new(rspamd_mempool_suggest_size(), "tok", 0);
+               auto text = make_words_text(1000);
+               struct rspamd_tokenize_budget budget = {};
+               rspamd_words_t words;
+
+               budget.max_words = 100;
+               kv_init(words);
+
+               tokenize(text, RSPAMD_TOKENIZE_UTF, cfg.get(), &budget, &words, pool);
+
+               CHECK(kv_size(words) == 100);
+               CHECK(budget.words == 100);
+               CHECK(budget.exceeded);
+
+               kv_destroy(words);
+               rspamd_mempool_delete(pool);
+       }
+
+       TEST_CASE("words budget is shared by all parts of a message")
+       {
+               auto cfg = std::make_unique<struct rspamd_config>();
+               auto *pool = rspamd_mempool_new(rspamd_mempool_suggest_size(), "tok", 0);
+               /* Each part on its own is far too small to hit any per part limit */
+               auto text = make_words_text(50);
+               struct rspamd_tokenize_budget budget = {};
+               uint64_t total = 0;
+
+               budget.max_words = 120;
+
+               for (auto i = 0; i < 10; i++) {
+                       rspamd_words_t words;
+
+                       kv_init(words);
+                       tokenize(text, RSPAMD_TOKENIZE_UTF, cfg.get(), &budget, &words, pool);
+                       total += kv_size(words);
+                       kv_destroy(words);
+               }
+
+               CHECK(total == 120);
+               CHECK(budget.words == 120);
+               CHECK(budget.exceeded);
+
+               rspamd_mempool_delete(pool);
+       }
+
+       TEST_CASE("words budget bounds the retained bytes")
+       {
+               auto cfg = std::make_unique<struct rspamd_config>();
+               auto *pool = rspamd_mempool_new(rspamd_mempool_suggest_size(), "tok", 0);
+               auto text = make_words_text(1000);
+               struct rspamd_tokenize_budget budget = {};
+               rspamd_words_t words;
+               uint64_t bytes = 0;
+
+               budget.max_bytes = 256;
+               kv_init(words);
+
+               tokenize(text, RSPAMD_TOKENIZE_UTF, cfg.get(), &budget, &words, pool);
+
+               for (unsigned int i = 0; i < kv_size(words); i++) {
+                       bytes += kv_A(words, i).original.len;
+               }
+
+               CHECK(bytes <= 256);
+               CHECK(budget.bytes == bytes);
+               CHECK(budget.exceeded);
+
+               kv_destroy(words);
+               rspamd_mempool_delete(pool);
+       }
+
+       TEST_CASE("no budget means no limits")
+       {
+               constexpr auto nwords = 1000;
+               auto cfg = std::make_unique<struct rspamd_config>();
+               auto *pool = rspamd_mempool_new(rspamd_mempool_suggest_size(), "tok", 0);
+               auto text = make_words_text(nwords);
+               rspamd_words_t words;
+
+               kv_init(words);
+               tokenize(text, RSPAMD_TOKENIZE_UTF, cfg.get(), nullptr, &words, pool);
+
+               CHECK(kv_size(words) == nwords);
+
+               kv_destroy(words);
+               rspamd_mempool_delete(pool);
+       }
+}
+
+#endif