]> git.ipfire.org Git - thirdparty/rspamd.git/commitdiff
[Fix] autolearnstats: fix crash and truncate long table columns
authorAlexander Moisseev <moiseev@mezonplus.ru>
Thu, 21 May 2026 06:34:27 +0000 (09:34 +0300)
committerAlexander Moisseev <moiseev@mezonplus.ru>
Thu, 21 May 2026 06:34:27 +0000 (09:34 +0300)
LuaJIT string.format only parses 2-digit widths (max 99); 3-digit
column widths like %-176s caused "invalid option" errors. Replace
header string.format with pad() calls.

Cap From/Recipients column display width at 60 chars; introduce
cell() helper that truncates overlong values with a ".." suffix.

Add unit tests for pad() and cell() covering truncation, width
invariant, and the >= 100 width regression.

lualib/rspamadm/autolearnstats.lua
test/lua/unit/autolearnstats.lua [new file with mode: 0644]

index cd17f70d8851e686c72cb808dc4787b576fffded..d61914dce0dab14d1459fab5c31b54f9ea54bad6 100644 (file)
@@ -94,6 +94,13 @@ local function pad(s, n)
   return s .. string.rep(' ', n - len)
 end
 
+local MAX_COL = 60
+
+local function cell(s, n)
+  if #s > n then s = s:sub(1, n - 2) .. '..' end
+  return pad(s, n)
+end
+
 local function iterate_bayes_log(handle, start_time, end_time, candidates, learned, ips)
   local ts_format = nil
 
@@ -254,6 +261,8 @@ local function handler(args)
     col.from    = math.max(col.from,    #c.from)
     col.rcpts   = math.max(col.rcpts,   #c.rcpts)
   end
+  col.from  = math.min(col.from,  MAX_COL)
+  col.rcpts = math.min(col.rcpts, MAX_COL)
 
   local sep = '  '
 
@@ -266,11 +275,10 @@ local function handler(args)
     col.ts + #sep + col.tid + #sep + col.ip + #sep + col.from + #sep + col.rcpts
 
   -- Header: [L]  Verd  Score  Timestamp  Task  IP  From  Recipients
-  io.write(string.format("%-3s" .. sep .. "%-" .. col.verdict .. "s" .. sep ..
-    "%-" .. col.score .. "s" .. sep .. "%-" .. col.ts .. "s" .. sep ..
-    "%-" .. col.tid .. "s" .. sep .. "%-" .. col.ip .. "s" .. sep ..
-    "%-" .. col.from .. "s" .. sep .. "%-" .. col.rcpts .. "s\n",
-    '', 'Verd', 'Score', 'Timestamp', 'Task', 'IP', 'From', 'Recipients'))
+  io.write(pad('', 3) .. sep .. pad('Verd', col.verdict) .. sep ..
+    pad('Score', col.score) .. sep .. pad('Timestamp', col.ts) .. sep ..
+    pad('Task', col.tid) .. sep .. pad('IP', col.ip) .. sep ..
+    pad('From', col.from) .. sep .. 'Recipients\n')
   io.write(string.rep('-', sep_width) .. '\n')
 
   local n_learned = 0
@@ -302,8 +310,8 @@ local function handler(args)
       pad(c.ts,     col.ts)      .. sep ..
       pad(tid,      col.tid)     .. sep ..
       pad(from_ip,  col.ip)     .. sep ..
-      pad(c.from,   col.from)   .. sep ..
-      c.rcpts .. '\n'
+      cell(c.from,   col.from)  .. sep ..
+      cell(c.rcpts,  col.rcpts) .. '\n'
     )
   end
 
@@ -331,8 +339,14 @@ local function handler(args)
   end
 end
 
-return {
+-- Exposed for unit tests.
+local exports = {
   handler = handler,
   description = parser._description,
-  name = 'autolearnstats'
+  name = 'autolearnstats',
+  _pad     = pad,
+  _cell    = cell,
+  _MAX_COL = MAX_COL,
 }
+
+return exports
diff --git a/test/lua/unit/autolearnstats.lua b/test/lua/unit/autolearnstats.lua
new file mode 100644 (file)
index 0000000..46cf4a6
--- /dev/null
@@ -0,0 +1,82 @@
+local m = require 'rspamadm.autolearnstats'
+local pad     = m._pad
+local cell    = m._cell
+local MAX_COL = m._MAX_COL
+
+context("autolearnstats - pad", function()
+  test("pads short string to given width", function()
+    assert_equal("hi   ", pad("hi", 5))
+    assert_equal(5, #pad("hi", 5))
+  end)
+
+  test("returns string unchanged when length equals width", function()
+    assert_equal("hello", pad("hello", 5))
+  end)
+
+  test("returns string unchanged when longer than width", function()
+    assert_equal("toolong", pad("toolong", 4))
+  end)
+
+  test("works for width >= 100 without string.format crash", function()
+    local result = pad("x", 100)
+    assert_equal(100, #result)
+    assert_equal("x", result:sub(1, 1))
+    assert_equal(" ", result:sub(100, 100))
+  end)
+end)
+
+context("autolearnstats - cell", function()
+  test("pads short string to given width", function()
+    local result = cell("hi", 10)
+    assert_equal(10, #result)
+    assert_equal("hi        ", result)
+  end)
+
+  test("returns string unchanged when length equals width", function()
+    local result = cell("hello", 5)
+    assert_equal("hello", result)
+    assert_equal(5, #result)
+  end)
+
+  test("truncates long string and appends '..'", function()
+    local result = cell("very long string here", 10)
+    assert_equal(10, #result)
+    assert_equal("very lon..", result)
+  end)
+
+  test("truncated suffix is always '..'", function()
+    local result = cell(string.rep("a", 80), 40)
+    assert_equal("..", result:sub(39, 40))
+  end)
+
+  test("result is always exactly n chars for short input", function()
+    for _, n in ipairs({4, 5, 10, 40, 60, 99, 100, 150}) do
+      local result = cell("x", n)
+      assert_equal(n, #result,
+        string.format("cell('x', %d): expected length %d, got %d", n, n, #result))
+    end
+  end)
+
+  test("result is always exactly n chars for long input", function()
+    local long = string.rep("a", 200)
+    for _, n in ipairs({4, 5, 10, 40, 60, 99, 100, 150}) do
+      local result = cell(long, n)
+      assert_equal(n, #result,
+        string.format("cell(200*'a', %d): expected length %d, got %d", n, n, #result))
+    end
+  end)
+
+  test("no crash and correct truncation for width >= 100 (LuaJIT regression)", function()
+    -- Prior to the fix, string.format("%-176s", ...) in the table header crashed
+    -- because LuaJIT only parses 2-digit format widths.  cell() itself must not
+    -- crash for any width and must return a string of exactly that width.
+    local long = string.rep("a", 200)
+    local result = cell(long, 176)
+    assert_equal(176, #result)
+    assert_equal("..", result:sub(175, 176))
+  end)
+
+  test("MAX_COL is 60", function()
+    assert_equal(60, MAX_COL)
+  end)
+end)