mirror of
https://github.com/mailscope/kumomta.git
synced 2026-09-13 14:02:13 +00:00
When running in validation mode, if a given queue block didn't specify an egress_pool, we could hit a validation error when the validator tried to access that missing field. The issue is that while we can dynamically validate properties against the underlying rust code on assignment (via newindex), we don't have a way to validate a read (index) operation because the validator works by creating a dummy value and serializing it. We could probably fix this up by introducing a read validator that tries to create an empty object and reading back its fields, but we don't currently generate index/field getters for those rust structs. So for now we will allow any read of a field that isn't set in a typed record table when it has a dynamic field validator present.
671 lines
16 KiB
Lua
671 lines
16 KiB
Lua
local mod = {}
|
|
local kumo = require 'kumo'
|
|
|
|
local function value_dump(v)
|
|
if type(v) == 'table' then
|
|
local status, encoded = pcall(kumo.serde.json_encode_pretty, v)
|
|
if status then
|
|
return encoded
|
|
end
|
|
end
|
|
if type(v) == 'string' then
|
|
return "'" .. v .. "'"
|
|
end
|
|
return v
|
|
end
|
|
|
|
-- Figure out the number of frames to skip in the error() call
|
|
-- when reporting an error.
|
|
-- We want to report the caller outside of this module,
|
|
-- but we do also want to correctly report the line number
|
|
-- of tests in this file.
|
|
-- We do this by looking at the stack trace and comparing
|
|
-- frames; the very first frame is by definition in this function,
|
|
-- so we can use that to figure out the current source file
|
|
-- and filter based on that.
|
|
local function error_skip()
|
|
local trace = kumo.traceback(1)
|
|
|
|
-- print(kumo.serde.json_encode_pretty(trace))
|
|
local first_frame = trace[1]
|
|
|
|
for level, frame in ipairs(trace) do
|
|
if frame.what ~= 'C' then
|
|
if frame.source ~= first_frame.source then
|
|
-- It's not in this file
|
|
return level, frame
|
|
end
|
|
-- This is fragile; the number here should be
|
|
-- approximately the line number in this file where
|
|
-- mod:test is defined. If the number is too low,
|
|
-- we'll attribute errors to the typing helpers in
|
|
-- this file, rather than the caller.
|
|
if frame.curr_line > 580 then
|
|
return level, frame
|
|
end
|
|
end
|
|
end
|
|
|
|
return 2, trace[2]
|
|
end
|
|
|
|
local TypeError = {}
|
|
|
|
function TypeError:format_frame()
|
|
return string.format(
|
|
'%s:%d %s',
|
|
self.frame.short_src,
|
|
self.frame.curr_line,
|
|
self.message
|
|
)
|
|
end
|
|
|
|
function TypeError:__tostring()
|
|
if self.context then
|
|
local frames = { self:format_frame() }
|
|
local context = self.context
|
|
while context do
|
|
table.insert(frames, context:format_frame())
|
|
context = context.context
|
|
end
|
|
return table.concat(frames, '\n')
|
|
else
|
|
return self:format_frame()
|
|
end
|
|
end
|
|
|
|
function TypeError:raise()
|
|
error(tostring(self), error_skip())
|
|
end
|
|
|
|
TypeError.__index = TypeError
|
|
|
|
function TypeError:new(message, context)
|
|
if not message then
|
|
error('TypeError:new called with no message', 2)
|
|
end
|
|
|
|
--[[
|
|
print("TypeError:new called with message", kumo.json_encode_pretty(message))
|
|
print("TypeError:new called with context", kumo.json_encode_pretty(context))
|
|
]]
|
|
|
|
if context then
|
|
if type(context) ~= 'table' then
|
|
context = TypeError:new(context)
|
|
end
|
|
end
|
|
|
|
local skip, frame = error_skip()
|
|
|
|
local err = {
|
|
message = message,
|
|
context = context,
|
|
frame = frame,
|
|
}
|
|
-- print("TypeError:new -> ", kumo.json_encode_pretty(err))
|
|
setmetatable(err, self)
|
|
return err
|
|
end
|
|
|
|
local function casting_validate_value(ty, other)
|
|
local other_mt = getmetatable(other)
|
|
if other_mt == ty then
|
|
return true, other
|
|
end
|
|
|
|
if not other_mt then
|
|
if type(other) == 'table' then
|
|
-- Let's try to construct one implicitly
|
|
local status, fixup = pcall(ty.construct, ty, other)
|
|
if not status then
|
|
return false,
|
|
TypeError:new(
|
|
string.format(
|
|
"Expected value of type '%s' and encountered an error during coercion",
|
|
ty.name
|
|
),
|
|
fixup
|
|
)
|
|
end
|
|
return true, fixup
|
|
end
|
|
|
|
return false,
|
|
TypeError:new(
|
|
string.format(
|
|
"Expected value of type '%s' but got type '%s' %s",
|
|
ty.name,
|
|
type(other),
|
|
value_dump(other)
|
|
)
|
|
)
|
|
end
|
|
|
|
if other_mt.name then
|
|
return false,
|
|
TypeError:new(
|
|
string.format(
|
|
"Expected value of type '%s' but got type '%s' with value %s",
|
|
ty.name,
|
|
other_mt.name,
|
|
value_dump(other)
|
|
)
|
|
)
|
|
end
|
|
|
|
return false,
|
|
TypeError:new(
|
|
string.format(
|
|
"Expected value of type '%s' but got type '%s' %s",
|
|
ty.name,
|
|
type(other),
|
|
value_dump(other)
|
|
)
|
|
)
|
|
end
|
|
|
|
-- Returns a record constructor
|
|
--
|
|
-- fields is a map<string, type>, where the key is the field
|
|
-- name and the value is the type of that field.
|
|
--
|
|
-- _dynamic is a special entry: it enables performing a runtime
|
|
-- check to see if a given key/value pair is a valid value for
|
|
-- assignment. This is used to outsource validation to native
|
|
-- rust code in the embedding application. The value in that
|
|
-- case must be a function that accepts the key and value
|
|
-- and returns a boolean to indicate whether that combination
|
|
-- is acceptable. If the function returns `false, nil` then
|
|
-- that signals that the field is unknown. Otherwise it should
|
|
-- return `false, err` with the second element of the tuple
|
|
-- containing the error about the mismatched type.
|
|
--
|
|
-- { _dynamic = function(key, value) return true end }
|
|
function mod.record(name, fields)
|
|
local ty = {
|
|
name = name,
|
|
fields = fields,
|
|
}
|
|
|
|
function ty.__index(t, k)
|
|
local v = rawget(t, k)
|
|
if v then
|
|
return v
|
|
end
|
|
local field_def = ty.fields[k]
|
|
if not field_def then
|
|
local dyn_validate = ty.fields._dynamic
|
|
if dyn_validate then
|
|
-- Unfortunately, we can only call dyn_validate with a candidate
|
|
-- value, and we don't have one here because we want to read
|
|
-- the value from the table, rather than write one.
|
|
-- We just have to shrug and assume that the validator would
|
|
-- do the job of preventing setting a bad value if it was legit,
|
|
-- and just let this potentially invalid field name breeze by.
|
|
return nil
|
|
end
|
|
|
|
TypeError
|
|
:new(
|
|
string.format("%s: attempt to read unknown field '%s'", ty.name, k)
|
|
)
|
|
:raise()
|
|
end
|
|
end
|
|
|
|
function ty.__newindex(t, k, v)
|
|
local field_def = ty.fields[k]
|
|
if not field_def then
|
|
local dyn_validate = ty.fields._dynamic
|
|
if dyn_validate then
|
|
local status, err = dyn_validate(k, v)
|
|
if status then
|
|
rawset(t, k, v)
|
|
return
|
|
end
|
|
if err then
|
|
TypeError
|
|
:new(
|
|
string.format("%s: invalid value for field '%s'", ty.name, k),
|
|
err
|
|
)
|
|
:raise()
|
|
end
|
|
end
|
|
|
|
TypeError:new(string.format("%s: unknown field '%s'", ty.name, k))
|
|
:raise()
|
|
end
|
|
local status, result = field_def:validate_value(v)
|
|
if status then
|
|
rawset(t, k, result)
|
|
else
|
|
TypeError
|
|
:new(
|
|
string.format("%s: invalid value for field '%s'", ty.name, k),
|
|
result
|
|
)
|
|
:raise()
|
|
end
|
|
end
|
|
|
|
function ty:construct(params)
|
|
local obj = {}
|
|
setmetatable(obj, ty)
|
|
|
|
for k, v in pairs(params) do
|
|
obj[k] = v
|
|
end
|
|
|
|
for k, def in pairs(ty.fields) do
|
|
if not obj[k] then
|
|
if type(def) == 'table' then
|
|
if def.default_value then
|
|
obj[k] = def.default_value
|
|
elseif not def.is_optional then
|
|
TypeError
|
|
:new(
|
|
string.format(
|
|
"%s: missing value for field '%s' of type '%s'",
|
|
ty.name,
|
|
k,
|
|
def.name
|
|
)
|
|
)
|
|
:raise()
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
return obj
|
|
end
|
|
|
|
function ty:validate_value(other)
|
|
return casting_validate_value(ty, other)
|
|
end
|
|
|
|
local ctor_mt = {}
|
|
setmetatable(ty, ctor_mt)
|
|
|
|
function ctor_mt:__call(params)
|
|
return self:construct(params)
|
|
end
|
|
|
|
return ty
|
|
end
|
|
|
|
function mod.list(value_type)
|
|
local ty = {
|
|
name = string.format('list<%s>', value_type.name),
|
|
value_type = value_type,
|
|
}
|
|
|
|
function ty:construct(params)
|
|
local obj = {}
|
|
setmetatable(obj, ty)
|
|
|
|
for i, v in ipairs(params) do
|
|
obj[i] = v
|
|
end
|
|
|
|
return obj
|
|
end
|
|
|
|
function ty.__newindex(t, idx, v)
|
|
if type(idx) ~= 'number' then
|
|
TypeError
|
|
:new(
|
|
string.format(
|
|
"%s: invalid index '%s' list",
|
|
ty.name,
|
|
value_dump(idx)
|
|
)
|
|
)
|
|
:raise()
|
|
end
|
|
|
|
local val_ok, val_fixup = ty.value_type:validate_value(v)
|
|
if not val_ok then
|
|
TypeError
|
|
:new(
|
|
string.format('%s: invalid value for idx %d', ty.name, idx),
|
|
val_fixup
|
|
)
|
|
:raise()
|
|
end
|
|
|
|
rawset(t, idx, val_fixup)
|
|
end
|
|
|
|
function ty:validate_value(other)
|
|
return casting_validate_value(ty, other)
|
|
end
|
|
|
|
local ctor_mt = {}
|
|
setmetatable(ty, ctor_mt)
|
|
|
|
function ctor_mt:__call(params)
|
|
return self:construct(params)
|
|
end
|
|
|
|
return ty
|
|
end
|
|
|
|
function mod.map(key_type, value_type)
|
|
local ty = {
|
|
name = string.format('map<%s,%s>', key_type.name, value_type.name),
|
|
key_type = key_type,
|
|
value_type = value_type,
|
|
}
|
|
|
|
function ty:construct(params)
|
|
local obj = {}
|
|
setmetatable(obj, ty)
|
|
|
|
for k, v in pairs(params) do
|
|
obj[k] = v
|
|
end
|
|
|
|
return obj
|
|
end
|
|
|
|
function ty.__newindex(t, k, v)
|
|
local key_ok, key_fixup = ty.key_type:validate_value(k)
|
|
if not key_ok then
|
|
TypeError
|
|
:new(
|
|
string.format('%s: invalid key %s', ty.name, value_dump(k)),
|
|
key_fixup
|
|
)
|
|
:raise()
|
|
end
|
|
|
|
local val_ok, val_fixup = ty.value_type:validate_value(v)
|
|
if not val_ok then
|
|
TypeError
|
|
:new(
|
|
string.format(
|
|
'%s: invalid value for key %s',
|
|
ty.name,
|
|
value_dump(k)
|
|
),
|
|
val_fixup
|
|
)
|
|
:raise()
|
|
end
|
|
|
|
rawset(t, key_fixup, val_fixup)
|
|
end
|
|
|
|
function ty:validate_value(other)
|
|
return casting_validate_value(ty, other)
|
|
end
|
|
|
|
local ctor_mt = {}
|
|
setmetatable(ty, ctor_mt)
|
|
|
|
function ctor_mt:__call(params)
|
|
return self:construct(params)
|
|
end
|
|
|
|
return ty
|
|
end
|
|
|
|
local function make_simple_ctor(ty)
|
|
local ctor_mt = {}
|
|
setmetatable(ty, ctor_mt)
|
|
function ctor_mt:__call(value)
|
|
local status, fixup = ty:validate_value(value)
|
|
if not status then
|
|
error(err, error_skip())
|
|
end
|
|
return fixup
|
|
end
|
|
return ty
|
|
end
|
|
|
|
local function make_scalar(type_name)
|
|
local ty = {
|
|
name = type_name,
|
|
}
|
|
|
|
function ty:validate_value(v)
|
|
if type(v) ~= type_name then
|
|
return false,
|
|
TypeError:new(
|
|
string.format(
|
|
"Expected '%s', got '%s' %s",
|
|
type_name,
|
|
type(v),
|
|
value_dump(v)
|
|
)
|
|
)
|
|
end
|
|
return true, v
|
|
end
|
|
|
|
return make_simple_ctor(ty)
|
|
end
|
|
|
|
mod.number = make_scalar 'number'
|
|
mod.string = make_scalar 'string'
|
|
mod.boolean = make_scalar 'boolean'
|
|
mod.Function = make_scalar 'function'
|
|
|
|
local function make_any()
|
|
local ty = {
|
|
name = 'Any',
|
|
}
|
|
|
|
function ty:validate_value(v)
|
|
return true, v
|
|
end
|
|
|
|
local ctor_mt = {}
|
|
setmetatable(ty, ctor_mt)
|
|
function ctor_mt:__call(value)
|
|
return value
|
|
end
|
|
return ty
|
|
end
|
|
|
|
mod.any = make_any()
|
|
|
|
function mod.enum(name, ...)
|
|
local variants = { ... }
|
|
local is_valid = {}
|
|
for _, v in ipairs(variants) do
|
|
is_valid[v] = true
|
|
end
|
|
|
|
local ty = {
|
|
name = name,
|
|
variants = { ... },
|
|
is_valid = is_valid,
|
|
}
|
|
|
|
function ty:validate_value(v)
|
|
if not self.is_valid[v] then
|
|
local list = {}
|
|
for _, valid in ipairs(self.variants) do
|
|
table.insert(list, value_dump(valid))
|
|
end
|
|
return false,
|
|
TypeError:new(
|
|
string.format(
|
|
"Unexpected '%s' value %s, expected one of %s",
|
|
ty.name,
|
|
value_dump(v),
|
|
table.concat(list, ', ')
|
|
)
|
|
)
|
|
end
|
|
return true, v
|
|
end
|
|
|
|
return make_simple_ctor(ty)
|
|
end
|
|
|
|
function mod.default(target_type, default_value)
|
|
local ty = {
|
|
name = target_type.name,
|
|
default_value = default_value,
|
|
target = target_type,
|
|
}
|
|
|
|
function ty:validate_value(v)
|
|
if v == nil then
|
|
return self.default_value
|
|
end
|
|
local status, error = self.target:validate_value(v)
|
|
return status, error
|
|
end
|
|
|
|
return make_simple_ctor(ty)
|
|
end
|
|
|
|
function mod.option(target_type)
|
|
local ty = {
|
|
name = string.format('option<%s>', target_type.name),
|
|
is_optional = true,
|
|
target = target_type,
|
|
}
|
|
|
|
function ty:validate_value(v)
|
|
if v == nil then
|
|
return true
|
|
end
|
|
local status, error = self.target:validate_value(v)
|
|
return status, error
|
|
end
|
|
|
|
return make_simple_ctor(ty)
|
|
end
|
|
|
|
function mod.extract_deserialize_error(err)
|
|
local err = tostring(err)
|
|
local re = kumo.regex.compile 'deserialize error: (.*), while processing'
|
|
local cap = re:captures(err)
|
|
if cap then
|
|
return cap[1]
|
|
end
|
|
return err
|
|
end
|
|
|
|
function mod:test()
|
|
local utils = require 'policy-extras.policy_utils'
|
|
|
|
local Layer = mod.enum('Layer', 'Above', 'Below')
|
|
|
|
local Point = mod.record('Point', {
|
|
x = mod.number,
|
|
y = mod.number,
|
|
})
|
|
|
|
local Example = mod.record('Example', {
|
|
point = Point,
|
|
layer = mod.option(Layer),
|
|
})
|
|
|
|
-- Check that we can construct with a nested record type
|
|
local pt = Point { x = 123, y = 2.5 }
|
|
local a = Example { point = pt, layer = 'Above' }
|
|
utils.assert_eq(a, { point = { y = 2.5, x = 123 }, layer = 'Above' })
|
|
|
|
-- Check that we can construct with the optional field
|
|
local a = Example { point = pt }
|
|
utils.assert_eq(a, { point = { y = 2.5, x = 123 } })
|
|
|
|
-- Check that we can construct with an implicit, inline
|
|
-- record type (the point)
|
|
local b = Example { point = { x = 123, y = 4 }, layer = 'Above' }
|
|
utils.assert_eq(b, { point = { y = 4, x = 123 }, layer = 'Above' })
|
|
|
|
-- Check error for missing field
|
|
local status, err = pcall(Point, { x = 123 })
|
|
assert(not status)
|
|
utils.assert_matches(
|
|
err,
|
|
"Point: missing value for field 'y' of type 'number'"
|
|
)
|
|
|
|
-- Check invalid type assignment
|
|
local status, err = pcall(Point, { x = 123, y = true })
|
|
assert(not status)
|
|
utils.assert_matches(
|
|
err,
|
|
"Point: invalid value for field 'y'\n.*Expected 'number', got 'boolean' true"
|
|
)
|
|
|
|
local status, err =
|
|
pcall(Example, { point = { x = 123, y = 4 }, layer = 'Wrong' })
|
|
assert(not status)
|
|
utils.assert_matches(
|
|
err,
|
|
"Example: invalid value for field 'layer'\n.*Unexpected 'Layer' value 'Wrong', expected one of 'Above', 'Below'"
|
|
)
|
|
|
|
local Foo = mod.record('Foo', {
|
|
ex = Example,
|
|
})
|
|
|
|
local status, err = pcall(Foo, { ex = Point { x = 123, y = 4 } })
|
|
assert(not status)
|
|
utils.assert_matches(
|
|
err,
|
|
"Foo: invalid value for field 'ex'\n.*Expected value of type 'Example' but got type 'Point' with value"
|
|
)
|
|
|
|
local StringMap = mod.map(mod.string, Example)
|
|
local m = StringMap { a = a, b = b }
|
|
utils.assert_eq(m, { a = a, b = b })
|
|
|
|
-- Check map assignment respects key type
|
|
local status, err = pcall(StringMap, { [123] = a })
|
|
assert(not status)
|
|
utils.assert_matches(
|
|
err,
|
|
"map<string,Example>: invalid key 123\n.*Expected 'string', got 'number' 123"
|
|
)
|
|
|
|
local status, err = pcall(function()
|
|
local map = StringMap {}
|
|
map[123] = 'boo'
|
|
end)
|
|
assert(not status)
|
|
utils.assert_matches(
|
|
err,
|
|
"map<string,Example>: invalid key 123\n.*Expected 'string', got 'number' 123"
|
|
)
|
|
|
|
-- Check map assignment respects value type
|
|
local status, err = pcall(StringMap, { hello = 'wrong' })
|
|
assert(not status)
|
|
utils.assert_matches(
|
|
err,
|
|
"map<string,Example>: invalid value for key 'hello'\n.*Expected value of type 'Example' but got type 'string' 'wrong'"
|
|
)
|
|
|
|
-- Verify that defaulting works
|
|
local WithDefaultLayer = mod.record('WithDefaultLayer', {
|
|
layer = mod.default(Layer, 'Above'),
|
|
})
|
|
local have_default_layer = WithDefaultLayer {}
|
|
assert(have_default_layer.layer == 'Above')
|
|
|
|
local StringList = mod.list(mod.string)
|
|
utils.assert_eq(StringList {}, {})
|
|
utils.assert_eq(StringList { 'hello', 'there' }, { 'hello', 'there' })
|
|
|
|
local status, err = pcall(StringList, { 1, 2, 3 })
|
|
assert(not status)
|
|
utils.assert_matches(
|
|
err,
|
|
"list<string>: invalid value for idx 1\n.*Expected 'string', got 'number' 1"
|
|
)
|
|
end
|
|
|
|
return mod
|