Files
kumomta/assets/policy-extras/typing.lua
T
Wez Furlong d1163a66f9 fix existential issue with queue helper and egress_pool in --validate mode
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.
2024-07-19 11:21:33 -07:00

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