-- SPDX-FileCopyrightText: © 2022—2026 Vladimir Zorin -- SPDX-License-Identifier: LicenseRef-OWL-1.0-or-later -- Licensed under OWL v1.0+. See LICENSE. local testimony = require("testimony") local cache = require("dns.cache") local types = require("dns.types") local std_core = require("std.core") local TYPE = types.TYPE local RCODE = types.RCODE local testify = testimony.new("== dns.cache ==") --------------------------------------------------------------------------- -- Test helpers --------------------------------------------------------------------------- local make_a_records = function(addresses) local records = {} for _, addr in ipairs(addresses) do records[#records + 1] = { name = "example.com.", type = TYPE.A, class = 1, ttl = 300, rdata = { address = addr }, } end return records end local make_aaaa_records = function(addresses) local records = {} for _, addr in ipairs(addresses) do records[#records + 1] = { name = "example.com.", type = TYPE.AAAA, class = 1, ttl = 300, rdata = { address = addr }, } end return records end local make_soa_record = function(minimum) return { name = "example.com.", type = TYPE.SOA, class = 1, ttl = 300, rdata = { mname = "ns1.example.com.", rname = "admin.example.com.", serial = 2024010101, refresh = 3600, retry = 900, expire = 604800, minimum = minimum or 300, }, } end -- ========================================================================= -- Constructor and Defaults -- ========================================================================= testify:that("new: returns cache with default config", function() local c = cache.new() testimony.assert_not_nil(c) testimony.assert_equal(30, c.cfg.min_ttl) testimony.assert_equal(86400, c.cfg.max_ttl) testimony.assert_equal(300, c.cfg.max_negative_ttl) testimony.assert_equal(10000, c.cfg.max_entries) testimony.assert_equal(5000, c.cfg.max_negative) testimony.assert_equal("memory", c.__state.backend) end) testify:that("new: accepts custom config values", function() local c = cache.new({ min_ttl = 10, max_ttl = 3600, max_negative_ttl = 60, max_entries = 500, max_negative = 100, }) testimony.assert_equal(10, c.cfg.min_ttl) testimony.assert_equal(3600, c.cfg.max_ttl) testimony.assert_equal(60, c.cfg.max_negative_ttl) testimony.assert_equal(500, c.cfg.max_entries) testimony.assert_equal(100, c.cfg.max_negative) end) -- ========================================================================= -- In-Memory Put and Get -- ========================================================================= testify:that("memory: put and get A records", function() local c = cache.new() local records = make_a_records({ "93.184.216.34", "93.184.216.35" }) c:put("example.com.", TYPE.A, records, 300) local got = c:get("example.com.", TYPE.A) testimony.assert_not_nil(got) testimony.assert_equal(2, #got) testimony.assert_equal("93.184.216.34", got[1].rdata.address) testimony.assert_equal("93.184.216.35", got[2].rdata.address) end) testify:that("memory: get returns nil for missing entry", function() local c = cache.new() local got = c:get("nonexistent.com.", TYPE.A) testimony.assert_nil(got) end) testify:that("memory: get returns nil for wrong qtype", function() local c = cache.new() local records = make_a_records({ "1.2.3.4" }) c:put("example.com.", TYPE.A, records, 300) local got = c:get("example.com.", TYPE.AAAA) testimony.assert_nil(got) end) testify:that("memory: put overwrites existing entry", function() local c = cache.new() local records1 = make_a_records({ "1.1.1.1" }) local records2 = make_a_records({ "2.2.2.2", "3.3.3.3" }) c:put("example.com.", TYPE.A, records1, 300) c:put("example.com.", TYPE.A, records2, 300) local got = c:get("example.com.", TYPE.A) testimony.assert_not_nil(got) testimony.assert_equal(2, #got) testimony.assert_equal("2.2.2.2", got[1].rdata.address) end) testify:that("memory: put ignores empty records", function() local c = cache.new() c:put("example.com.", TYPE.A, {}, 300) local got = c:get("example.com.", TYPE.A) testimony.assert_nil(got) c:put("example.com.", TYPE.A, nil, 300) got = c:get("example.com.", TYPE.A) testimony.assert_nil(got) end) testify:that("memory: records are independent copies", function() local c = cache.new() local records = make_a_records({ "1.2.3.4" }) c:put("example.com.", TYPE.A, records, 300) -- Mutate the original records[1].rdata.address = "9.9.9.9" local got = c:get("example.com.", TYPE.A) testimony.assert_equal("1.2.3.4", got[1].rdata.address) -- Mutate the returned copy got[1].rdata.address = "8.8.8.8" local got2 = c:get("example.com.", TYPE.A) testimony.assert_equal("1.2.3.4", got2[1].rdata.address) end) -- ========================================================================= -- TTL Handling -- ========================================================================= testify:that("memory: TTL clamped to min_ttl", function() local c = cache.new({ min_ttl = 30 }) local records = make_a_records({ "1.2.3.4" }) c:put("example.com.", TYPE.A, records, 5) local got = c:get("example.com.", TYPE.A) testimony.assert_not_nil(got) -- TTL should be close to 30 (clamped from 5), not close to 5 testimony.assert_true(got[1].ttl >= 28, "expected TTL >= 28, got " .. got[1].ttl) end) testify:that("memory: TTL clamped to max_ttl", function() local c = cache.new({ max_ttl = 3600 }) local records = make_a_records({ "1.2.3.4" }) c:put("example.com.", TYPE.A, records, 100000) local got = c:get("example.com.", TYPE.A) testimony.assert_not_nil(got) testimony.assert_true(got[1].ttl <= 3600, "expected TTL <= 3600, got " .. got[1].ttl) end) testify:that("memory: returned TTL reflects remaining time", function() local c = cache.new({ min_ttl = 1 }) local records = make_a_records({ "1.2.3.4" }) c:put("example.com.", TYPE.A, records, 60) std_core.sleep_ms(200) local got = c:get("example.com.", TYPE.A) testimony.assert_not_nil(got) testimony.assert_true(got[1].ttl < 60, "expected TTL < 60, got " .. got[1].ttl) end) testify:that("memory: expired entry returns nil", function() local c = cache.new({ min_ttl = 1, max_ttl = 86400 }) local records = make_a_records({ "1.2.3.4" }) c:put("example.com.", TYPE.A, records, 1) std_core.sleep_ms(1100) local got = c:get("example.com.", TYPE.A) testimony.assert_nil(got) end) testify:that("memory: expired entry is lazily removed (size decremented)", function() local c = cache.new({ min_ttl = 1, max_ttl = 86400, max_entries = 3 }) local records = make_a_records({ "1.2.3.4" }) c:put("short-lived.com.", TYPE.A, records, 1) c:put("long-lived.com.", TYPE.A, records, 3600) testimony.assert_equal(2, c:stats().size) std_core.sleep_ms(1100) -- Access the expired entry to trigger lazy removal local got = c:get("short-lived.com.", TYPE.A) testimony.assert_nil(got) testimony.assert_equal(1, c:stats().size) -- Long-lived entry should still be there got = c:get("long-lived.com.", TYPE.A) testimony.assert_not_nil(got) end) -- ========================================================================= -- Negative Caching -- ========================================================================= testify:that("memory: put_negative and get_negative NXDOMAIN", function() local c = cache.new() local soa = make_soa_record(120) c:put_negative("nonexistent.com.", TYPE.A, RCODE.NXDOMAIN, soa) local got = c:get_negative("nonexistent.com.", TYPE.A) testimony.assert_not_nil(got) testimony.assert_equal(RCODE.NXDOMAIN, got.rcode) testimony.assert_not_nil(got.soa) end) testify:that("memory: get_negative returns nil for missing", function() local c = cache.new() local got = c:get_negative("nonexistent.com.", TYPE.A) testimony.assert_nil(got) end) testify:that("memory: expired negative entry returns nil", function() local c = cache.new({ min_ttl = 1, max_negative_ttl = 1 }) local soa = make_soa_record(1) c:put_negative("test.com.", TYPE.A, RCODE.NXDOMAIN, soa) std_core.sleep_ms(1100) local got = c:get_negative("test.com.", TYPE.A) testimony.assert_nil(got) end) -- ========================================================================= -- Case Insensitivity and Normalization -- ========================================================================= testify:that("memory: get is case-insensitive", function() local c = cache.new() local records = make_a_records({ "1.2.3.4" }) c:put("Example.COM.", TYPE.A, records, 300) local got = c:get("example.com.", TYPE.A) testimony.assert_not_nil(got) testimony.assert_equal("1.2.3.4", got[1].rdata.address) end) -- ========================================================================= -- Eviction -- ========================================================================= testify:that("memory: evicts soonest-expiring entry when full", function() local c = cache.new({ min_ttl = 1, max_ttl = 86400, max_entries = 3 }) local records = make_a_records({ "1.2.3.4" }) -- Insert 3 entries with different TTLs c:put("short.com.", TYPE.A, records, 10) c:put("medium.com.", TYPE.A, records, 100) c:put("long.com.", TYPE.A, records, 1000) testimony.assert_equal(3, c:stats().size) -- Insert 4th entry — should evict the shortest TTL (short.com.) c:put("new.com.", TYPE.A, records, 500) testimony.assert_equal(3, c:stats().size) -- short.com should be evicted local got = c:get("short.com.", TYPE.A) testimony.assert_nil(got) end) testify:that("memory: eviction preserves higher-TTL entries", function() local c = cache.new({ min_ttl = 1, max_ttl = 86400, max_entries = 3 }) local records = make_a_records({ "1.2.3.4" }) c:put("short.com.", TYPE.A, records, 10) c:put("medium.com.", TYPE.A, records, 100) c:put("long.com.", TYPE.A, records, 1000) c:put("new.com.", TYPE.A, records, 500) -- medium, long, and new should still be present testimony.assert_not_nil(c:get("medium.com.", TYPE.A)) testimony.assert_not_nil(c:get("long.com.", TYPE.A)) testimony.assert_not_nil(c:get("new.com.", TYPE.A)) end) testify:that("memory: negative cache evicts independently", function() local c = cache.new({ min_ttl = 1, max_negative_ttl = 86400, max_negative = 2 }) local soa = make_soa_record(60) c:put_negative("first.com.", TYPE.A, RCODE.NXDOMAIN, make_soa_record(10)) c:put_negative("second.com.", TYPE.A, RCODE.NXDOMAIN, make_soa_record(100)) testimony.assert_equal(2, c:stats().neg_size) -- Insert 3rd — should evict the shortest TTL (first.com.) c:put_negative("third.com.", TYPE.A, RCODE.NXDOMAIN, soa) testimony.assert_equal(2, c:stats().neg_size) testimony.assert_nil(c:get_negative("first.com.", TYPE.A)) testimony.assert_not_nil(c:get_negative("second.com.", TYPE.A)) testimony.assert_not_nil(c:get_negative("third.com.", TYPE.A)) end) testify:that("memory: overwrite does not increment size", function() local c = cache.new({ max_entries = 3 }) local records = make_a_records({ "1.2.3.4" }) c:put("example.com.", TYPE.A, records, 300) c:put("example.com.", TYPE.A, records, 600) testimony.assert_equal(1, c:stats().size) end) -- ========================================================================= -- Flush -- ========================================================================= testify:that("memory: flush clears all entries", function() local c = cache.new() local records = make_a_records({ "1.2.3.4" }) c:put("a.com.", TYPE.A, records, 300) c:put("b.com.", TYPE.A, records, 300) c:put("c.com.", TYPE.AAAA, make_aaaa_records({ "::1" }), 300) c:flush() testimony.assert_nil(c:get("a.com.", TYPE.A)) testimony.assert_nil(c:get("b.com.", TYPE.A)) testimony.assert_nil(c:get("c.com.", TYPE.AAAA)) testimony.assert_equal(0, c:stats().size) end) testify:that("memory: flush clears negative entries", function() local c = cache.new() local soa = make_soa_record(120) c:put_negative("a.com.", TYPE.A, RCODE.NXDOMAIN, soa) c:put_negative("b.com.", TYPE.A, RCODE.NXDOMAIN, soa) c:flush() testimony.assert_nil(c:get_negative("a.com.", TYPE.A)) testimony.assert_nil(c:get_negative("b.com.", TYPE.A)) testimony.assert_equal(0, c:stats().neg_size) end) testify:that("memory: cache is usable after flush", function() local c = cache.new({ max_entries = 3 }) local records = make_a_records({ "1.2.3.4" }) c:put("a.com.", TYPE.A, records, 300) c:put("b.com.", TYPE.A, records, 300) c:put("c.com.", TYPE.A, records, 300) c:flush() -- Should be able to insert max_entries again without eviction c:put("d.com.", TYPE.A, records, 300) c:put("e.com.", TYPE.A, records, 300) c:put("f.com.", TYPE.A, records, 300) testimony.assert_equal(3, c:stats().size) testimony.assert_not_nil(c:get("d.com.", TYPE.A)) end) -- ========================================================================= -- Sweep -- ========================================================================= testify:that("memory: sweep removes expired positive entries", function() local c = cache.new({ min_ttl = 1, max_ttl = 86400, sweep_interval = 0 }) local records = make_a_records({ "1.2.3.4" }) c:put("short.com.", TYPE.A, records, 1) c:put("long.com.", TYPE.A, records, 3600) testimony.assert_equal(2, c:stats().size) std_core.sleep_ms(1100) c:sweep() testimony.assert_equal(1, c:stats().size) testimony.assert_nil(c:get("short.com.", TYPE.A)) testimony.assert_not_nil(c:get("long.com.", TYPE.A)) end) testify:that("memory: sweep removes expired negative entries", function() local c = cache.new({ min_ttl = 1, max_negative_ttl = 3600, sweep_interval = 0 }) local soa = make_soa_record(1) c:put_negative("neg1.com.", TYPE.A, RCODE.NXDOMAIN, soa) c:put_negative("neg2.com.", TYPE.A, RCODE.NXDOMAIN, make_soa_record(3600)) testimony.assert_equal(2, c:stats().neg_size) std_core.sleep_ms(1100) c:sweep() testimony.assert_equal(1, c:stats().neg_size) testimony.assert_nil(c:get_negative("neg1.com.", TYPE.A)) testimony.assert_not_nil(c:get_negative("neg2.com.", TYPE.A)) end) testify:that("memory: sweep is rate-limited by sweep_interval", function() local c = cache.new({ min_ttl = 1, max_ttl = 86400, sweep_interval = 60 }) local records = make_a_records({ "1.2.3.4" }) c:put("short.com.", TYPE.A, records, 1) std_core.sleep_ms(1100) -- First sweep should run (last_sweep is 0) c:sweep() testimony.assert_equal(0, c:stats().size) -- Insert another short-lived entry c:put("short2.com.", TYPE.A, records, 1) std_core.sleep_ms(1100) -- Second sweep should be skipped (interval not elapsed) c:sweep() -- Entry still counted in size (not swept yet) testimony.assert_equal(1, c:stats().size) end) -- ========================================================================= -- Multiple qtypes for same name -- ========================================================================= testify:that("memory: same name with different qtypes stored independently", function() local c = cache.new() local a_recs = make_a_records({ "1.2.3.4" }) local aaaa_recs = make_aaaa_records({ "::1" }) c:put("example.com.", TYPE.A, a_recs, 300) c:put("example.com.", TYPE.AAAA, aaaa_recs, 300) testimony.assert_equal(2, c:stats().size) local got_a = c:get("example.com.", TYPE.A) local got_aaaa = c:get("example.com.", TYPE.AAAA) testimony.assert_equal("1.2.3.4", got_a[1].rdata.address) testimony.assert_equal("::1", got_aaaa[1].rdata.address) end) -- ========================================================================= -- MNEME Backend -- ========================================================================= local mneme_available = false local mneme_mod do local ok, m = pcall(require, "mneme") if ok then mneme_available = true mneme_mod = m end end local mneme_test_path = function() return "/tmp/test_dns_cache_" .. tostring(os.time()) .. "_" .. tostring(math.random(100000)) .. ".mneme" end if mneme_available then testify:that("mneme: backend is mneme when configured", function() local path = mneme_test_path() local c = cache.new({ mneme = true, mneme_path = path }) testimony.assert_equal("mneme", c.__state.backend) c:flush() os.remove(path) end) testify:that("mneme: accepts pre-opened db handle", function() local path = mneme_test_path() local db = mneme_mod.open(path) local c = cache.new({ mneme_db = db }) testimony.assert_equal("mneme", c.__state.backend) testimony.assert_equal(false, c.__state.mneme_owned) c:flush() db:close() os.remove(path) end) testify:that("mneme: put and get A records", function() local path = mneme_test_path() local c = cache.new({ mneme = true, mneme_path = path }) local records = make_a_records({ "93.184.216.34" }) c:put("example.com.", TYPE.A, records, 300) local got = c:get("example.com.", TYPE.A) testimony.assert_not_nil(got) testimony.assert_equal(1, #got) testimony.assert_equal("93.184.216.34", got[1].rdata.address) c:flush() os.remove(path) end) testify:that("mneme: get returns nil for missing entry", function() local path = mneme_test_path() local c = cache.new({ mneme = true, mneme_path = path }) local got = c:get("nonexistent.com.", TYPE.A) testimony.assert_nil(got) c:flush() os.remove(path) end) testify:that("mneme: returned TTL reflects remaining time", function() local path = mneme_test_path() local c = cache.new({ mneme = true, mneme_path = path }) local records = make_a_records({ "1.2.3.4" }) c:put("example.com.", TYPE.A, records, 60) local got = c:get("example.com.", TYPE.A) testimony.assert_not_nil(got) testimony.assert_true(got[1].ttl <= 60, "expected TTL <= 60, got " .. got[1].ttl) testimony.assert_true(got[1].ttl >= 58, "expected TTL >= 58, got " .. got[1].ttl) c:flush() os.remove(path) end) testify:that("mneme: put_negative and get_negative", function() local path = mneme_test_path() local c = cache.new({ mneme = true, mneme_path = path }) local soa = make_soa_record(120) c:put_negative("nonexistent.com.", TYPE.A, RCODE.NXDOMAIN, soa) local got = c:get_negative("nonexistent.com.", TYPE.A) testimony.assert_not_nil(got) testimony.assert_equal(RCODE.NXDOMAIN, got.rcode) testimony.assert_not_nil(got.soa) c:flush() os.remove(path) end) testify:that("mneme: flush clears all entries", function() local path = mneme_test_path() local c = cache.new({ mneme = true, mneme_path = path }) local records = make_a_records({ "1.2.3.4" }) c:put("a.com.", TYPE.A, records, 300) c:put("b.com.", TYPE.A, records, 300) c:put_negative("c.com.", TYPE.A, RCODE.NXDOMAIN, make_soa_record(120)) c:flush() testimony.assert_nil(c:get("a.com.", TYPE.A)) testimony.assert_nil(c:get("b.com.", TYPE.A)) testimony.assert_nil(c:get_negative("c.com.", TYPE.A)) os.remove(path) end) testify:that("mneme: TTL clamped on insertion", function() local path = mneme_test_path() local c = cache.new({ min_ttl = 30, max_ttl = 3600, mneme = true, mneme_path = path }) local records = make_a_records({ "1.2.3.4" }) -- Insert with TTL=5 (below min_ttl=30) c:put("example.com.", TYPE.A, records, 5) local got = c:get("example.com.", TYPE.A) testimony.assert_not_nil(got) -- MNEME TTL should be around 30 (clamped), not 5 testimony.assert_true(got[1].ttl >= 28, "expected TTL >= 28, got " .. got[1].ttl) c:flush() os.remove(path) end) testify:that("mneme: case-insensitive lookup", function() local path = mneme_test_path() local c = cache.new({ mneme = true, mneme_path = path }) local records = make_a_records({ "1.2.3.4" }) c:put("Example.COM.", TYPE.A, records, 300) local got = c:get("example.com.", TYPE.A) testimony.assert_not_nil(got) testimony.assert_equal("1.2.3.4", got[1].rdata.address) c:flush() os.remove(path) end) testify:that("mneme: sweep purges expired entries", function() local path = mneme_test_path() local c = cache.new({ min_ttl = 1, max_ttl = 86400, mneme = true, mneme_path = path }) local records = make_a_records({ "1.2.3.4" }) c:put("short.com.", TYPE.A, records, 1) c:put("long.com.", TYPE.A, records, 3600) std_core.sleep_ms(1100) c:sweep() testimony.assert_nil(c:get("short.com.", TYPE.A)) testimony.assert_not_nil(c:get("long.com.", TYPE.A)) c:flush() os.remove(path) end) end -- ========================================================================= -- Conclude -- ========================================================================= testify:conclude()