HEX
Server: Apache/2
System: Linux nexus-01 4.18.0-553.120.1.el8_10.x86_64 #1 SMP Mon Apr 20 18:04:27 EDT 2026 x86_64
User: aglcoke (1118)
PHP: 8.2.31
Disabled: mail,exec,system,passthru,shell_exec,proc_close,proc_open,dl,popen,show_source,posix_kill,posix_mkfifo,posix_getpwuid,posix_setpgid,posix_setsid,posix_setuid,posix_setgid,posix_seteuid,posix_setegid,posix_uname
Upload Files
File: //usr/share/rspamd/lualib/lua_hs_cache.lua
--[[
Copyright (c) 2026, Vsevolod Stakhov <vsevolod@rspamd.com>

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.
]]--

local logger = require "rspamd_logger"
local rspamd_util = require "rspamd_util"
local lua_redis = require "lua_redis"
local lua_util = require "lua_util"
local rspamd_http = require "rspamd_http"

local exports = {}
-- Use "hyperscan" module name so debug output is enabled together with C code
local N = "hyperscan"

-- File backend
local file_backend = {}
file_backend.__index = file_backend

-- Zstd magic number: 0xFD2FB528 (little-endian bytes: 28 B5 2F FD)
local ZSTD_MAGIC = string.char(0x28, 0xB5, 0x2F, 0xFD)

function file_backend.new(config)
  local self = setmetatable({}, file_backend)
  -- Store config for logging context
  self.config = config.rspamd_config or config
  -- Remove trailing slashes from cache_dir
  local cache_dir = config.cache_dir or '/var/lib/rspamd/hs_cache'
  self.cache_dir = cache_dir:gsub("/+$", "")
  -- Default to flat directory structure for backward compatibility
  self.platform_dirs = config.platform_dirs == true
  -- Enable compression by default (consistent with redis/http backends)
  local opts = config.file or config
  self.use_compression = (opts.compression ~= false) and (config.compression ~= false)
  lua_util.debugm(N, self.config, "file backend config: cache_dir=%s, platform_dirs=%s, compression=%s",
      self.cache_dir, self.platform_dirs and "yes" or "no",
      self.use_compression and "enabled" or "disabled")
  return self
end

-- Get file extension based on compression setting
function file_backend:_get_extension()
  return self.use_compression and '.hs.zst' or '.hs'
end

-- Get the path for a cache file
-- @param cache_key string cache key (hash that already includes platform info)
-- @param platform_id string platform identifier (unused in flat mode for backward compat)
-- @param ext string optional extension override (e.g., '.hs' or '.hs.zst')
function file_backend:_get_path(cache_key, platform_id, ext)
  local extension = ext or self:_get_extension()
  if self.platform_dirs then
    -- Optional: use platform subdirectories (not default)
    return string.format("%s/%s/%s%s", self.cache_dir, platform_id, cache_key, extension)
  else
    -- Default: flat structure matching original C code behavior
    -- Platform info is already embedded in cache_key hash
    return string.format("%s/%s%s", self.cache_dir, cache_key, extension)
  end
end

-- Check if data starts with zstd magic bytes
function file_backend:_is_zstd(data)
  if not data or #data < 4 then
    return false
  end
  return data:sub(1, 4) == ZSTD_MAGIC
end

-- Find existing cache file, trying both compressed and uncompressed extensions
-- Returns: path, is_compressed (or nil if not found)
function file_backend:_find_existing_path(cache_key, platform_id)
  -- Try compressed first if compression is enabled, otherwise try uncompressed first
  local primary_ext = self:_get_extension()
  local secondary_ext = self.use_compression and '.hs' or '.hs.zst'

  local primary_path = self:_get_path(cache_key, platform_id, primary_ext)
  -- rspamd_util.stat returns (err, stat_table) - check for no error AND valid stat
  local err, stat = rspamd_util.stat(primary_path)
  if not err and stat then
    return primary_path, primary_ext == '.hs.zst'
  end

  local secondary_path = self:_get_path(cache_key, platform_id, secondary_ext)
  err, stat = rspamd_util.stat(secondary_path)
  if not err and stat then
    return secondary_path, secondary_ext == '.hs.zst'
  end

  return nil, nil
end

function file_backend:_ensure_dir(path)
  local dir = path:match("(.*/)")
  if dir then
    local ok, err = rspamd_util.mkdir(dir, true)
    if not ok and err then
      logger.warnx(N, "failed to create directory %s: %s", dir, err)
    end
  end
end

function file_backend:exists(cache_key, platform_id, callback)
  local path, is_compressed = self:_find_existing_path(cache_key, platform_id)

  if path then
    local err, stat = rspamd_util.stat(path)
    if not err and stat then
      lua_util.debugm(N, self.config, "file exists check: %s found, size: %d, compressed: %s",
          path, stat.size, is_compressed and "yes" or "no")
      callback(nil, true, { size = stat.size, mtime = stat.mtime, compressed = is_compressed })
    else
      -- Race condition: file disappeared between _find_existing_path and stat
      lua_util.debugm(N, self.config, "file exists check: %s disappeared (race)", path)
      callback(nil, false, nil)
    end
  else
    local expected_path = self:_get_path(cache_key, platform_id)
    lua_util.debugm(N, self.config, "file exists check: %s not found (checked both extensions)", expected_path)
    callback(nil, false, nil)
  end
end

function file_backend:load(cache_key, platform_id, callback)
  local path, expected_compressed = self:_find_existing_path(cache_key, platform_id)

  if not path then
    local expected_path = self:_get_path(cache_key, platform_id)
    lua_util.debugm(N, self.config, "file load failed: %s not found (checked both extensions)", expected_path)
    callback("file not found", nil)
    return
  end

  lua_util.debugm(N, self.config, "file load from: %s (expected compressed: %s)", path, expected_compressed and "yes" or "no")

  local f, err = io.open(path, "rb")
  if not f then
    lua_util.debugm(N, self.config, "file load failed from %s: %s", path, err or "open error")
    callback(err or "open error", nil)
    return
  end
  local data = f:read("*a")
  f:close()
  if not data then
    lua_util.debugm(N, self.config, "file read failed from %s", path)
    callback("read error", nil)
    return
  end

  -- Check if data is actually zstd compressed (magic byte verification)
  local is_zstd = self:_is_zstd(data)
  lua_util.debugm(N, self.config, "file loaded %d bytes from %s, zstd magic: %s",
      #data, path, is_zstd and "yes" or "no")

  -- Notify hyperscan cache that this file is known (for cleanup tracking)
  rspamd_util.hyperscan_notice_known(path)

  if is_zstd then
    -- Decompress the data
    local decompress_err, decompressed = rspamd_util.zstd_decompress(data)
    if not decompress_err and decompressed then
      lua_util.debugm(N, self.config, "file decompressed %d -> %d bytes from %s (compression ratio: %.1f%%)",
          #data, #decompressed, path, (1 - #data / #decompressed) * 100)
      callback(nil, decompressed)
    else
      lua_util.debugm(N, self.config, "file decompression failed for %s: %s", path, decompress_err or "unknown error")
      callback(decompress_err or "decompression failed", nil)
    end
  else
    -- Data is not compressed, return as-is
    if expected_compressed then
      lua_util.debugm(N, self.config, "file %s has .zst extension but no zstd magic - treating as uncompressed", path)
    end
    callback(nil, data)
  end
end

function file_backend:store(cache_key, platform_id, data, _ttl, callback)
  local path = self:_get_path(cache_key, platform_id)

  lua_util.debugm(N, self.config, "file store to: %s, original size: %d bytes, compression: %s",
      path, #data, self.use_compression and "enabled" or "disabled")

  self:_ensure_dir(path)

  local store_data = data
  -- Compress if enabled
  if self.use_compression then
    local compressed, compress_err = rspamd_util.zstd_compress(data)
    if compressed then
      lua_util.debugm(N, self.config, "file compressed %d -> %d bytes (%.1f%% size reduction) for %s",
          #data, #compressed, (1 - #compressed / #data) * 100, path)
      store_data = compressed
    else
      logger.warnx(N, "compression failed: %s, storing uncompressed to %s", compress_err, path)
    end
  end

  -- Write to temp file first, then rename atomically
  local tmp_path = path .. ".tmp." .. rspamd_util.random_hex(8)
  -- store_data can be string or rspamd_text userdata
  local ok, write_err
  if type(store_data) == "userdata" and store_data.save_in_file then
    ok, write_err = store_data:save_in_file(tmp_path)
  else
    local f, err = io.open(tmp_path, "wb")
    if not f then
      callback(err or "open failed")
      return
    end
    ok, write_err = f:write(store_data)
    f:close()
  end
  if not ok then
    os.remove(tmp_path)
    callback(write_err or "write failed")
    return
  end

  do
    local renamed, rename_err = os.rename(tmp_path, path)
    if renamed then
      lua_util.debugm(N, self.config, "stored %d bytes to %s", #store_data, path)
      -- Notify hyperscan cache that this file is known (for cleanup tracking)
      rspamd_util.hyperscan_notice_known(path)
      -- Remove old file with opposite extension if it exists (migration cleanup)
      local old_ext = self.use_compression and '.hs' or '.hs.zst'
      local old_path = self:_get_path(cache_key, platform_id, old_ext)
      local old_err, old_stat = rspamd_util.stat(old_path)
      if not old_err and old_stat then
        local removed = os.remove(old_path)
        if removed then
          lua_util.debugm(N, self.config, "removed old cache file %s (migrated to %s)", old_path, path)
        end
      end
      callback(nil)
    else
      os.remove(tmp_path)
      callback(rename_err or "rename failed")
    end
  end
end

function file_backend:delete(cache_key, platform_id, callback)
  -- Try to delete both compressed and uncompressed versions
  local deleted_any = false
  local last_err = nil

  for _, ext in ipairs({'.hs', '.hs.zst'}) do
    local path = self:_get_path(cache_key, platform_id, ext)
    local stat_err, stat = rspamd_util.stat(path)
    if not stat_err and stat then
      local ok, err = os.remove(path)
      if ok then
        lua_util.debugm(N, self.config, "deleted %s", path)
        deleted_any = true
      else
        last_err = err
      end
    end
  end

  if deleted_any then
    callback(nil)
  else
    callback(last_err or "file not found")
  end
end

function file_backend:exists_sync(cache_key, platform_id)
  local path, is_compressed = self:_find_existing_path(cache_key, platform_id)
  if path then
    lua_util.debugm(N, self.config, "file sync exists check: %s found (compressed: %s)",
        path, is_compressed and "yes" or "no")
    return true, nil
  else
    local expected_path = self:_get_path(cache_key, platform_id)
    lua_util.debugm(N, self.config, "file sync exists check: %s not found (checked both extensions)", expected_path)
    return false, nil
  end
end

function file_backend:save_async(cache_key, platform_id, data, callback)
  self:store(cache_key, platform_id, data, nil, callback)
end

function file_backend:load_async(cache_key, platform_id, callback)
  self:load(cache_key, platform_id, callback)
end

function file_backend:exists_async(cache_key, platform_id, callback)
  local exists, err = self:exists_sync(cache_key, platform_id)
  callback(err, exists)
end

function file_backend:load_sync(cache_key, platform_id)
  local path, expected_compressed = self:_find_existing_path(cache_key, platform_id)

  if not path then
    local expected_path = self:_get_path(cache_key, platform_id)
    lua_util.debugm(N, self.config, "file sync load failed: %s not found (checked both extensions)", expected_path)
    return nil, "file not found"
  end

  lua_util.debugm(N, self.config, "file sync load from: %s (expected compressed: %s)",
      path, expected_compressed and "yes" or "no")

  local f, err = io.open(path, "rb")
  if not f then
    lua_util.debugm(N, self.config, "file sync load failed from %s: %s", path, err or "open error")
    return nil, err or "open error"
  end
  local data = f:read("*a")
  f:close()
  if not data then
    lua_util.debugm(N, self.config, "file sync read failed from %s", path)
    return nil, "read error"
  end

  -- Check if data is actually zstd compressed (magic byte verification)
  local is_zstd = self:_is_zstd(data)
  lua_util.debugm(N, self.config, "file sync loaded %d bytes from %s, zstd magic: %s",
      #data, path, is_zstd and "yes" or "no")

  -- Notify hyperscan cache that this file is known (for cleanup tracking)
  rspamd_util.hyperscan_notice_known(path)

  if is_zstd then
    -- Decompress the data
    local decompress_err, decompressed = rspamd_util.zstd_decompress(data)
    if not decompress_err and decompressed then
      lua_util.debugm(N, self.config, "file sync decompressed %d -> %d bytes from %s (compression ratio: %.1f%%)",
          #data, #decompressed, path, (1 - #data / #decompressed) * 100)
      return decompressed, nil
    else
      lua_util.debugm(N, self.config, "file sync decompression failed for %s: %s", path, decompress_err or "unknown error")
      return nil, decompress_err or "decompression failed"
    end
  else
    -- Data is not compressed, return as-is
    if expected_compressed then
      lua_util.debugm(N, self.config, "file %s has .zst extension but no zstd magic - treating as uncompressed", path)
    end
    return data, nil
  end
end

function file_backend:save_sync(cache_key, platform_id, data)
  local path = self:_get_path(cache_key, platform_id)
  lua_util.debugm(N, self.config, "file sync save to: %s, original size: %d bytes, compression: %s",
      path, #data, self.use_compression and "enabled" or "disabled")
  self:_ensure_dir(path)

  local store_data = data
  -- Compress if enabled
  if self.use_compression then
    local compressed, compress_err = rspamd_util.zstd_compress(data)
    if compressed then
      lua_util.debugm(N, self.config, "file sync compressed %d -> %d bytes (%.1f%% size reduction) for %s",
          #data, #compressed, (1 - #compressed / #data) * 100, path)
      store_data = compressed
    else
      logger.warnx(N, "compression failed: %s, storing uncompressed to %s", compress_err, path)
    end
  end

  local tmp_path = path .. ".tmp." .. rspamd_util.random_hex(8)
  -- store_data can be string or rspamd_text userdata
  local ok, write_err
  if type(store_data) == "userdata" and store_data.save_in_file then
    ok, write_err = store_data:save_in_file(tmp_path)
  else
    local f, err = io.open(tmp_path, "wb")
    if not f then
      lua_util.debugm(N, self.config, "file sync open failed for %s: %s", tmp_path, err)
      return false, err
    end
    ok, write_err = f:write(store_data)
    f:close()
  end
  if not ok then
    lua_util.debugm(N, self.config, "file sync write failed to %s: %s", tmp_path, write_err)
    os.remove(tmp_path)
    return false, write_err
  end

  local renamed, rename_err = os.rename(tmp_path, path)
  if not renamed then
    lua_util.debugm(N, self.config, "file sync rename failed %s -> %s: %s", tmp_path, path, rename_err)
    os.remove(tmp_path)
    return false, rename_err
  end

  lua_util.debugm(N, self.config, "file sync stored %d bytes to %s", #store_data, path)

  -- Notify hyperscan cache that this file is known (for cleanup tracking)
  rspamd_util.hyperscan_notice_known(path)

  -- Remove old file with opposite extension if it exists (migration cleanup)
  local old_ext = self.use_compression and '.hs' or '.hs.zst'
  local old_path = self:_get_path(cache_key, platform_id, old_ext)
  local old_err, old_stat = rspamd_util.stat(old_path)
  if not old_err and old_stat then
    local removed = os.remove(old_path)
    if removed then
      lua_util.debugm(N, self.config, "removed old cache file %s (migrated to %s)", old_path, path)
    end
  end

  return true, nil
end

-- Redis backend
local redis_backend = {}
redis_backend.__index = redis_backend

function redis_backend.new(config)
  local self = setmetatable({}, redis_backend)

  -- Redis config can be:
  -- 1. In a 'redis' sub-section of hs_helper worker options
  -- 2. Directly in the hs_helper worker options (servers, write_servers, etc.)
  -- 3. Fallback to global 'redis' configuration section
  local redis_opts = config.redis or config
  self.redis_params = lua_redis.parse_redis_server(nil, redis_opts, true)
  if not self.redis_params then
    -- Fallback to global redis config
    self.redis_params = lua_redis.parse_redis_server('redis')
  end

  if not self.redis_params then
    logger.warnx(N, "redis backend: no redis configuration found in hs_helper worker or global redis section")
  end

  if config.ev_base and self.redis_params then
    self.redis_params.ev_base = config.ev_base
  end

  if config.rspamd_config then
    self.config = config.rspamd_config
  else
    self.config = config
  end

  -- Config options can be in redis sub-section or at top level
  local opts = config.redis or config
  self.default_ttl = opts.ttl or config.ttl or (86400 * 30) -- 30 days default
  self.refresh_ttl = (opts.refresh_ttl ~= false) and (config.refresh_ttl ~= false)
  self.use_compression = (opts.compression ~= false) and (config.compression ~= false)
  -- Use different default prefix for compressed (rspamd_zhs) vs uncompressed (rspamd_hs)
  local default_prefix = self.use_compression and 'rspamd_zhs' or 'rspamd_hs'
  self.prefix = opts.prefix or config.prefix or default_prefix

  lua_util.debugm(N, self.config, "redis backend config: prefix=%s, ttl=%s, refresh_ttl=%s, compression=%s",
      self.prefix, self.default_ttl, self.refresh_ttl, self.use_compression)

  return self
end

function redis_backend:_get_key(cache_key, platform_id)
  return string.format("%s:%s:%s", self.prefix, platform_id, cache_key)
end

function redis_backend:exists(cache_key, platform_id, callback)
  local key = self:_get_key(cache_key, platform_id)

  if not self.redis_params then
    callback("redis not configured", false, nil)
    return
  end

  lua_util.debugm(N, self.config, "redis EXISTS check for key: %s", key)

  local attrs = {
    ev_base = self.redis_params.ev_base,
    config = self.config,
    callback = function(err, data)
      if err then
        lua_util.debugm(N, self.config, "redis EXISTS failed for key %s: %s", key, err)
        callback(err, false, nil)
      else
        lua_util.debugm(N, self.config, "redis EXISTS result for key %s: %s", key, data == 1 and "found" or "not found")
        callback(nil, data == 1, nil)
      end
    end
  }

  local req = {'EXISTS', key}
  lua_redis.request(self.redis_params, attrs, req)
end

function redis_backend:load(cache_key, platform_id, callback)
  local key = self:_get_key(cache_key, platform_id)

  if not self.redis_params then
    callback("redis not configured", nil)
    return
  end

  -- Use GETEX to refresh TTL on read if enabled
  local req
  if self.refresh_ttl then
    lua_util.debugm(N, self.config, "redis GETEX (with TTL refresh %d) for key: %s", self.default_ttl, key)
    req = {'GETEX', key, 'EX', tostring(self.default_ttl)}
  else
    lua_util.debugm(N, self.config, "redis GET for key: %s", key)
    req = {'GET', key}
  end

  local attrs = {
    ev_base = self.redis_params.ev_base,
    config = self.config,
    callback = function(err, data)
      if err then
        lua_util.debugm(N, self.config, "redis GET failed for key %s: %s", key, err)
        callback(err, nil)
      elseif not data then
        lua_util.debugm(N, self.config, "redis cache miss for key %s", key)
        callback("not found", nil)
      else
        -- Decompress if needed
        if self.use_compression then
          -- Defensive check: ensure data is a string or text before decompression
          local data_type = type(data)
          if data_type ~= 'string' and data_type ~= 'userdata' then
            logger.errx(self.config, "redis GET returned unexpected type %s for key %s, data=%s",
                data_type, key, tostring(data))
            callback("unexpected data type: " .. data_type, nil)
            return
          end
          local ok, decompress_err, decompressed = pcall(rspamd_util.zstd_decompress, data)
          if not ok then
            -- pcall failed - decompress_err contains the error message
            logger.errx(self.config, "zstd_decompress pcall failed for key %s: %s, data type=%s, len=%s",
                key, tostring(decompress_err), data_type, data_type == 'string' and #data or 'N/A')
            callback("decompression error: " .. tostring(decompress_err), nil)
            return
          end
          if not decompress_err and decompressed then
            lua_util.debugm(N, self.config, "redis loaded and decompressed %d -> %d bytes from key %s (compression ratio: %.1f%%)",
                #data, #decompressed, key, (1 - #data / #decompressed) * 100)
            callback(nil, decompressed)
          else
            lua_util.debugm(N, self.config, "redis decompression failed for key %s: %s", key, decompress_err)
            callback(decompress_err or "decompression failed", nil)
          end
        else
          lua_util.debugm(N, self.config, "redis loaded %d bytes (uncompressed) from key %s", #data, key)
          callback(nil, data)
        end
      end
    end
  }

  lua_redis.request(self.redis_params, attrs, req)
end

function redis_backend:store(cache_key, platform_id, data, ttl, callback)
  local key = self:_get_key(cache_key, platform_id)
  local actual_ttl = ttl or self.default_ttl

  if not self.redis_params then
    callback("redis not configured")
    return
  end

  lua_util.debugm(N, self.config, "redis SETEX for key: %s, original size: %d bytes, TTL: %d, compression: %s",
      key, #data, actual_ttl, self.use_compression and "enabled" or "disabled")

  local store_data = data
  -- Compress if enabled
  if self.use_compression then
    local compressed, compress_err = rspamd_util.zstd_compress(data)
    if compressed then
      lua_util.debugm(N, self.config, "redis compressed %d -> %d bytes (%.1f%% size reduction) for key %s",
          #data, #compressed, (1 - #compressed / #data) * 100, key)
      store_data = compressed
    else
      logger.warnx(N, "compression failed: %s, storing uncompressed", compress_err)
    end
  end

  local attrs = {
    ev_base = self.redis_params.ev_base,
    config = self.config,
    is_write = true,
    callback = function(err)
      if err then
        lua_util.debugm(N, self.config, "redis SETEX failed for key %s: %s", key, err)
        callback(err)
      else
        lua_util.debugm(N, self.config, "redis stored %d bytes to key %s with TTL %d",
            #store_data, key, actual_ttl)
        callback(nil)
      end
    end
  }

  local req = {'SETEX', key, tostring(actual_ttl), store_data}
  lua_redis.request(self.redis_params, attrs, req)
end

function redis_backend:delete(cache_key, platform_id, callback)
  local key = self:_get_key(cache_key, platform_id)

  if not self.redis_params then
    callback("redis not configured")
    return
  end

  lua_util.debugm(N, self.config, "redis DEL for key: %s", key)

  local attrs = {
    ev_base = self.redis_params.ev_base,
    config = self.config,
    is_write = true,
    callback = function(err)
      if err then
        lua_util.debugm(N, self.config, "redis DEL failed for key %s: %s", key, err)
        callback(err)
      else
        lua_util.debugm(N, self.config, "redis deleted key %s", key)
        callback(nil)
      end
    end
  }

  local req = {'DEL', key}
  lua_redis.request(self.redis_params, attrs, req)
end

function redis_backend:save_async(cache_key, platform_id, data, callback)
  self:store(cache_key, platform_id, data, nil, callback)
end

function redis_backend:load_async(cache_key, platform_id, callback)
  self:load(cache_key, platform_id, callback)
end

function redis_backend:exists_async(cache_key, platform_id, callback)
  self:exists(cache_key, platform_id, callback)
end

-- HTTP backend
local http_backend = {}
http_backend.__index = http_backend

function http_backend.new(config)
  local self = setmetatable({}, http_backend)
  -- Store config for logging context
  self.config = config.rspamd_config or config

  -- HTTP config can be in 'http' sub-section or at top level
  local opts = config.http or config
  self.base_url = opts.base_url or opts.url
  self.timeout = opts.timeout or config.timeout or 30
  self.auth_header = opts.auth_header or config.auth_header
  self.auth_value = opts.auth_value or config.auth_value
  self.use_compression = (opts.compression ~= false) and (config.compression ~= false)
  return self
end

function http_backend:_get_url(cache_key, platform_id)
  return string.format("%s/%s/%s", self.base_url, platform_id, cache_key)
end

function http_backend:_get_headers()
  local headers = {}
  if self.auth_header and self.auth_value then
    headers[self.auth_header] = self.auth_value
  end
  return headers
end

function http_backend:exists(cache_key, platform_id, callback)
  local url = self:_get_url(cache_key, platform_id)

  lua_util.debugm(N, self.config, "http HEAD check for url: %s", url)

  rspamd_http.request({
    url = url,
    method = 'HEAD',
    headers = self:_get_headers(),
    timeout = self.timeout,
    callback = function(err, code, _, headers)
      if err then
        lua_util.debugm(N, self.config, "http HEAD failed for %s: %s", url, err)
        callback(err, false, nil)
      elseif code == 200 then
        local size = headers and headers['content-length']
        lua_util.debugm(N, self.config, "http HEAD found %s, size: %s", url, size or "unknown")
        callback(nil, true, { size = tonumber(size) })
      else
        lua_util.debugm(N, self.config, "http HEAD not found %s (code: %d)", url, code)
        callback(nil, false, nil)
      end
    end
  })
end

function http_backend:load(cache_key, platform_id, callback)
  local url = self:_get_url(cache_key, platform_id)

  lua_util.debugm(N, self.config, "http GET for url: %s", url)

  rspamd_http.request({
    url = url,
    method = 'GET',
    headers = self:_get_headers(),
    timeout = self.timeout,
    callback = function(err, code, body, headers)
      if err then
        lua_util.debugm(N, self.config, "http GET failed for %s: %s", url, err)
        callback(err, nil)
      elseif code == 200 and body then
        -- Check if content is compressed
        local content_encoding = headers and headers['content-encoding']
        if content_encoding == 'zstd' or self.use_compression then
          local decompress_err, decompressed = rspamd_util.zstd_decompress(body)
          if not decompress_err and decompressed then
            lua_util.debugm(N, self.config, "http loaded and decompressed %d -> %d bytes from %s",
                #body, #decompressed, url)
            callback(nil, decompressed)
          else
            lua_util.debugm(N, self.config, "http loaded %d bytes (no decompression) from %s", #body, url)
            callback(nil, body)
          end
        else
          lua_util.debugm(N, self.config, "http loaded %d bytes from %s", #body, url)
          callback(nil, body)
        end
      elseif code == 404 then
        lua_util.debugm(N, self.config, "http cache miss (404) for %s", url)
        callback("not found", nil)
      else
        lua_util.debugm(N, self.config, "http GET failed for %s: HTTP %d", url, code)
        callback(string.format("HTTP %d", code), nil)
      end
    end
  })
end

function http_backend:store(cache_key, platform_id, data, ttl, callback)
  local url = self:_get_url(cache_key, platform_id)
  local headers = self:_get_headers()

  lua_util.debugm(N, self.config, "http PUT for url: %s, original size: %d bytes, compression: %s",
      url, #data, self.use_compression and "enabled" or "disabled")

  local store_data = data
  if self.use_compression then
    local compressed = rspamd_util.zstd_compress(data)
    if compressed then
      lua_util.debugm(N, self.config, "http compressed %d -> %d bytes (%.1f%% size reduction) for %s",
          #data, #compressed, (1 - #compressed / #data) * 100, url)
      store_data = compressed
      headers['Content-Encoding'] = 'zstd'
    end
  end

  if ttl then
    headers['X-TTL'] = tostring(ttl)
  end

  rspamd_http.request({
    url = url,
    method = 'PUT',
    headers = headers,
    body = store_data,
    timeout = self.timeout,
    callback = function(err, code)
      if err then
        lua_util.debugm(N, self.config, "http PUT failed for %s: %s", url, err)
        callback(err)
      elseif code >= 200 and code < 300 then
        lua_util.debugm(N, self.config, "http stored %d bytes to %s", #store_data, url)
        callback(nil)
      else
        lua_util.debugm(N, self.config, "http PUT failed for %s: HTTP %d", url, code)
        callback(string.format("HTTP %d", code))
      end
    end
  })
end

function http_backend:delete(cache_key, platform_id, callback)
  local url = self:_get_url(cache_key, platform_id)

  lua_util.debugm(N, self.config, "http DELETE for url: %s", url)

  rspamd_http.request({
    url = url,
    method = 'DELETE',
    headers = self:_get_headers(),
    timeout = self.timeout,
    callback = function(err, code)
      if err then
        lua_util.debugm(N, self.config, "http DELETE failed for %s: %s", url, err)
        callback(err)
      elseif code >= 200 and code < 300 or code == 404 then
        lua_util.debugm(N, self.config, "http deleted %s", url)
        callback(nil)
      else
        lua_util.debugm(N, self.config, "http DELETE failed for %s: HTTP %d", url, code)
        callback(string.format("HTTP %d", code))
      end
    end
  })
end

-- Backend factory

-- Create a backend instance based on configuration
-- @param config table with:
--   - backend: "file"|"redis"|"http" (default: "file")
--   - cache_dir: directory for file backend
--   - redis: redis configuration table
--   - http: http configuration table
-- @return backend instance
function exports.create_backend(config)
  local backend_type = config.backend or config.cache_backend or 'file'

  local cfg = config.rspamd_config or config
  lua_util.debugm(N, cfg, "creating hyperscan cache backend: %s", backend_type)

  -- Always pass full config - backends will extract what they need
  -- (config contains ev_base, rspamd_config at top level, plus optional
  -- redis/http sub-sections for backend-specific settings)
  if backend_type == 'file' then
    local be = file_backend.new(config)
    lua_util.debugm(N, be.config, "file backend created, cache_dir: %s, compression: %s",
        be.cache_dir or "not set", be.use_compression and "enabled" or "disabled")
    return be
  elseif backend_type == 'redis' then
    local be = redis_backend.new(config)
    if be.redis_params then
      lua_util.debugm(N, be.config, "redis backend created, prefix: %s, compression: %s",
          be.prefix, be.use_compression and "enabled" or "disabled")
    else
      logger.errx(N, "redis backend created but no redis params - operations will fail!")
    end
    return be
  elseif backend_type == 'http' then
    local be = http_backend.new(config)
    lua_util.debugm(N, be.config, "http backend created, base_url: %s", be.base_url or "not set")
    return be
  else
    logger.errx(N, "unknown hyperscan cache backend: %s, falling back to file", backend_type)
    return file_backend.new(config)
  end
end

-- Export individual backend constructors for direct use
exports.file_backend = file_backend
exports.redis_backend = redis_backend
exports.http_backend = http_backend

return exports