Add core modules for RCON Data Bridge: audit log, Base64 codec, JSON parser, config management, logging, and options registry
This commit is contained in:
@@ -0,0 +1,48 @@
|
|||||||
|
-- Structured audit log: every remote action is logged with its request id,
|
||||||
|
-- operation, result, and timestamp, including rejected requests
|
||||||
|
-- (bad payload, unknown op, rate-limited, etc). Persisted as a bounded
|
||||||
|
-- ring buffer via ModData, plus a console line for live tailing.
|
||||||
|
|
||||||
|
require("RconDataBridge.RDB_Log")
|
||||||
|
|
||||||
|
RDB_AuditLog = {}
|
||||||
|
|
||||||
|
local TAG = "RconDataBridge_AuditLog"
|
||||||
|
local MAX_ENTRIES = 500
|
||||||
|
|
||||||
|
local store
|
||||||
|
|
||||||
|
local function ensureStore()
|
||||||
|
if not store then
|
||||||
|
store = ModData.getOrCreate(TAG)
|
||||||
|
if store.entries == nil then
|
||||||
|
store.entries = {}
|
||||||
|
store.nextIndex = 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return store
|
||||||
|
end
|
||||||
|
|
||||||
|
-- entry: { id, op, ok, code, ts }
|
||||||
|
function RDB_AuditLog.record(entry)
|
||||||
|
local s = ensureStore()
|
||||||
|
entry.ts = entry.ts or getTimestampMs()
|
||||||
|
if #s.entries < MAX_ENTRIES then
|
||||||
|
s.entries[#s.entries + 1] = entry
|
||||||
|
else
|
||||||
|
s.entries[s.nextIndex] = entry
|
||||||
|
s.nextIndex = (s.nextIndex % MAX_ENTRIES) + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
local resultText = entry.ok and "ok" or ("error:" .. tostring(entry.code))
|
||||||
|
RDB_Log.info(
|
||||||
|
"audit id=" .. tostring(entry.id) ..
|
||||||
|
" op=" .. tostring(entry.op) ..
|
||||||
|
" result=" .. resultText
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
function RDB_AuditLog.getEntries()
|
||||||
|
return ensureStore().entries
|
||||||
|
end
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
---@diagnostic disable: need-check-nil
|
||||||
|
|
||||||
|
-- ModData-backed bridge configuration: rate limits, payload cap, sensitive
|
||||||
|
-- field policy. Defaults are baked in and only written to the ModData table
|
||||||
|
-- the first time it's created.
|
||||||
|
|
||||||
|
require("RconDataBridge.RDB_Constants")
|
||||||
|
|
||||||
|
RDB_Config = {}
|
||||||
|
|
||||||
|
local TAG = "RconDataBridge_Config"
|
||||||
|
|
||||||
|
local DEFAULTS = {
|
||||||
|
maxPayloadBytes = RDB_Constants.MAX_PAYLOAD_BYTES,
|
||||||
|
rateLimitMaxRequests = 10,
|
||||||
|
rateLimitWindowSeconds = 10,
|
||||||
|
includeSensitiveFields = false,
|
||||||
|
}
|
||||||
|
|
||||||
|
local store
|
||||||
|
|
||||||
|
function RDB_Config.init()
|
||||||
|
store = ModData.getOrCreate(TAG)
|
||||||
|
for key, value in pairs(DEFAULTS) do
|
||||||
|
if store[key] == nil then
|
||||||
|
store[key] = value
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function RDB_Config.get(key)
|
||||||
|
if not store then
|
||||||
|
RDB_Config.init()
|
||||||
|
end
|
||||||
|
|
||||||
|
return store[key]
|
||||||
|
end
|
||||||
|
|
||||||
|
function RDB_Config.set(key, value)
|
||||||
|
if not store then
|
||||||
|
RDB_Config.init()
|
||||||
|
end
|
||||||
|
|
||||||
|
store[key] = value
|
||||||
|
end
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
-- Thin wrapper around zombie.network.ServerOptions.
|
||||||
|
--
|
||||||
|
-- Registration must do two things for an option to be both settable (via
|
||||||
|
-- changeoption) and visible (via showoptions):
|
||||||
|
-- 1. ServerOptions.instance:addOption(...) -- makes it settable/gettable
|
||||||
|
-- 2. ServerOptions.instance:getPublicOptions():add(name) -- makes it visible
|
||||||
|
-- addOption alone is not enough: showoptions reads a separate publicOptions
|
||||||
|
-- list that addOption never touches.
|
||||||
|
--
|
||||||
|
-- Registration is idempotent: Events.OnServerStarted fires more than once per
|
||||||
|
-- boot. A getOptionByName pre-check alone isn't sufficient to suppress the
|
||||||
|
-- resulting duplicate-name IllegalArgumentException from addOption (observed
|
||||||
|
-- in practice even with the pre-check in place, likely a timing/ordering
|
||||||
|
-- quirk between the two OnServerStarted firings); addOption is wrapped in
|
||||||
|
-- pcall as a second, authoritative line of defense. Runtime state ends up
|
||||||
|
-- correct either way.
|
||||||
|
|
||||||
|
require("RconDataBridge.RDB_Log")
|
||||||
|
|
||||||
|
RDB_OptionsRegistry = {}
|
||||||
|
|
||||||
|
function RDB_OptionsRegistry.register(name, defaultValue, maxLength)
|
||||||
|
local so = ServerOptions.instance
|
||||||
|
if so:getOptionByName(name) ~= nil then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local opt = TextServerOption.new(so, name, defaultValue, maxLength)
|
||||||
|
local ok = pcall(function() so:addOption(opt) end)
|
||||||
|
if not ok then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
so:getPublicOptions():add(name)
|
||||||
|
RDB_Log.info("registered option " .. name)
|
||||||
|
end
|
||||||
|
|
||||||
|
function RDB_OptionsRegistry.get(name)
|
||||||
|
return ServerOptions.instance:getOption(name)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Uses putOption (in-memory only), not changeOption: changeOption also does a
|
||||||
|
-- full saveServerTextFile disk write on every call, which is fine for a real
|
||||||
|
-- RCON-initiated config change but wasteful for our own frequent internal
|
||||||
|
-- publishes (Response, WorldStats, etc. are runtime state, not persisted config).
|
||||||
|
function RDB_OptionsRegistry.set(name, value)
|
||||||
|
ServerOptions.instance:putOption(name, value)
|
||||||
|
end
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
---@diagnostic disable: param-type-mismatch, need-check-nil
|
||||||
|
|
||||||
|
-- Minimal base64 encode/decode. Required for the request channel: RCON's
|
||||||
|
-- command-line tokenizer (CommandBase's constructor) strips every literal
|
||||||
|
-- double-quote character from every changeoption argument, confirmed by
|
||||||
|
-- decompiling CommandBase, and confirmed empirically (a raw JSON payload
|
||||||
|
-- comes out with every " removed, corrupting it beyond recovery). No quoting
|
||||||
|
-- strategy survives this since the strip is unconditional and happens after
|
||||||
|
-- tokenization regardless of which token form matched. Base64 has no quote
|
||||||
|
-- characters in its alphabet, so it survives changeoption intact.
|
||||||
|
--
|
||||||
|
-- Only the request channel needs this: responses are written via
|
||||||
|
-- ServerOptions:putOption (a direct Lua/Java call, not the RCON text-command
|
||||||
|
-- path) and read via showoptions, neither of which goes through the
|
||||||
|
-- tokenizer, so they carry raw JSON unmolested.
|
||||||
|
|
||||||
|
-- TODO: does output still base64 encode? otherwise the API is weird (base64 json in, json out)
|
||||||
|
|
||||||
|
RDB_Base64 = {}
|
||||||
|
|
||||||
|
local ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
||||||
|
|
||||||
|
local decodeTable
|
||||||
|
local function buildDecodeTable()
|
||||||
|
decodeTable = {}
|
||||||
|
for i = 1, #ALPHABET do
|
||||||
|
decodeTable[ALPHABET:sub(i, i)] = i - 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function RDB_Base64.encode(data)
|
||||||
|
local out = {}
|
||||||
|
local len = #data
|
||||||
|
local i = 1
|
||||||
|
while i <= len do
|
||||||
|
local b1, b2, b3 = data:byte(i, i + 2)
|
||||||
|
b2 = b2 or 0
|
||||||
|
b3 = b3 or 0
|
||||||
|
|
||||||
|
local n = b1 * 65536 + b2 * 256 + b3
|
||||||
|
|
||||||
|
local c1 = math.floor(n / 262144) % 64
|
||||||
|
local c2 = math.floor(n / 4096) % 64
|
||||||
|
local c3 = math.floor(n / 64) % 64
|
||||||
|
local c4 = n % 64
|
||||||
|
|
||||||
|
out[#out + 1] = ALPHABET:sub(c1 + 1, c1 + 1)
|
||||||
|
out[#out + 1] = ALPHABET:sub(c2 + 1, c2 + 1)
|
||||||
|
out[#out + 1] = (i + 1 <= len) and ALPHABET:sub(c3 + 1, c3 + 1) or "="
|
||||||
|
out[#out + 1] = (i + 2 <= len) and ALPHABET:sub(c4 + 1, c4 + 1) or "="
|
||||||
|
|
||||||
|
i = i + 3
|
||||||
|
end
|
||||||
|
return table.concat(out)
|
||||||
|
end
|
||||||
|
|
||||||
|
function RDB_Base64.decode(str)
|
||||||
|
if not decodeTable then
|
||||||
|
buildDecodeTable()
|
||||||
|
end
|
||||||
|
|
||||||
|
str = str:gsub("=", "")
|
||||||
|
local out = {}
|
||||||
|
local i = 1
|
||||||
|
local len = #str
|
||||||
|
while i <= len do
|
||||||
|
local c1 = decodeTable[str:sub(i, i)]
|
||||||
|
local c2 = decodeTable[str:sub(i + 1, i + 1)]
|
||||||
|
local c3str = str:sub(i + 2, i + 2)
|
||||||
|
local c4str = str:sub(i + 3, i + 3)
|
||||||
|
local c3 = c3str ~= "" and decodeTable[c3str] or nil
|
||||||
|
local c4 = c4str ~= "" and decodeTable[c4str] or nil
|
||||||
|
|
||||||
|
if c1 == nil or c2 == nil then
|
||||||
|
return nil, "invalid base64 input"
|
||||||
|
end
|
||||||
|
|
||||||
|
local n = c1 * 262144 + c2 * 4096 + (c3 or 0) * 64 + (c4 or 0)
|
||||||
|
|
||||||
|
out[#out + 1] = string.char(math.floor(n / 65536) % 256)
|
||||||
|
if c3 ~= nil then
|
||||||
|
out[#out + 1] = string.char(math.floor(n / 256) % 256)
|
||||||
|
end
|
||||||
|
if c4 ~= nil then
|
||||||
|
out[#out + 1] = string.char(n % 256)
|
||||||
|
end
|
||||||
|
|
||||||
|
i = i + 4
|
||||||
|
end
|
||||||
|
return table.concat(out)
|
||||||
|
end
|
||||||
|
|
||||||
|
function RDB_Base64.selfTest()
|
||||||
|
local samples = { "", "a", "ab", "abc", '{"v":1,"id":"t1","op":"echo","args":{"x":1}}' }
|
||||||
|
|
||||||
|
for _, s in ipairs(samples) do
|
||||||
|
local encoded = RDB_Base64.encode(s)
|
||||||
|
local decoded, err = RDB_Base64.decode(encoded)
|
||||||
|
if err then
|
||||||
|
return false, "decode error for " .. s .. ": " .. err
|
||||||
|
end
|
||||||
|
if decoded ~= s then
|
||||||
|
return false, "round-trip mismatch for " .. s
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return true
|
||||||
|
end
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- Shared constants: option names, protocol version, limits.
|
||||||
|
--
|
||||||
|
-- Option names use underscores, not dots: RCON's changeoption command validates
|
||||||
|
-- the option-name argument against the regex \w+ (word characters only), so a
|
||||||
|
-- dotted name is silently rejected before it ever reaches the option lookup.
|
||||||
|
|
||||||
|
RDB_Constants = {}
|
||||||
|
|
||||||
|
RDB_Constants.DEBUG = true
|
||||||
|
|
||||||
|
RDB_Constants.PROTOCOL_VERSION = 1
|
||||||
|
|
||||||
|
RDB_Constants.OPT = {
|
||||||
|
PROTOCOL_VERSION = "RconDataBridge_ProtocolVersion",
|
||||||
|
WORLD_STATS = "RconDataBridge_WorldStats",
|
||||||
|
REQUEST = "RconDataBridge_Request",
|
||||||
|
RESPONSE = "RconDataBridge_Response",
|
||||||
|
LAST_RESPONSE = "RconDataBridge_LastResponse",
|
||||||
|
}
|
||||||
|
|
||||||
|
RDB_Constants.PLAYER_STATS_PREFIX = "RconDataBridge_PlayerStats_"
|
||||||
|
|
||||||
|
RDB_Constants.MAX_PAYLOAD_BYTES = 4096
|
||||||
|
RDB_Constants.OPTION_MAX_LENGTH = 8192
|
||||||
|
RDB_Constants.POLL_EVERY_TICKS = 30
|
||||||
|
|
||||||
|
RDB_Constants.ERROR_CODES = {
|
||||||
|
SCHEMA_ERROR = "SCHEMA_ERROR",
|
||||||
|
PAYLOAD_TOO_LARGE = "PAYLOAD_TOO_LARGE",
|
||||||
|
OP_NOT_ALLOWED = "OP_NOT_ALLOWED",
|
||||||
|
RATE_LIMITED = "RATE_LIMITED",
|
||||||
|
PLAYER_NOT_FOUND = "PLAYER_NOT_FOUND",
|
||||||
|
SAVE_FAILED = "SAVE_FAILED",
|
||||||
|
INTERNAL_ERROR = "INTERNAL_ERROR",
|
||||||
|
}
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
---@diagnostic disable: need-check-nil
|
||||||
|
|
||||||
|
-- Dependency-free JSON encode/decode. No JSON library exists anywhere in the
|
||||||
|
-- PZ Lua API (checked exhaustively against the umbrella + zdoc-lua stub
|
||||||
|
-- trees), so the mod brings its own minimal one.
|
||||||
|
--
|
||||||
|
-- encode() always produces compact output (no whitespace). This is required,
|
||||||
|
-- not just stylistic: RCON's command tokenizer only treats double-quoted
|
||||||
|
-- spans as a single argument, so a payload containing a literal space would
|
||||||
|
-- be split into multiple arguments and fail parsing.
|
||||||
|
|
||||||
|
RDB_Json = {}
|
||||||
|
|
||||||
|
local ESCAPES = {
|
||||||
|
['"'] = '\\"',
|
||||||
|
['\\'] = '\\\\',
|
||||||
|
['\b'] = '\\b',
|
||||||
|
['\f'] = '\\f',
|
||||||
|
['\n'] = '\\n',
|
||||||
|
['\r'] = '\\r',
|
||||||
|
['\t'] = '\\t',
|
||||||
|
}
|
||||||
|
|
||||||
|
local function encodeString(s)
|
||||||
|
local out = { '"' }
|
||||||
|
|
||||||
|
for i = 1, #s do
|
||||||
|
local c = s:sub(i, i)
|
||||||
|
local esc = ESCAPES[c]
|
||||||
|
if esc then
|
||||||
|
out[#out + 1] = esc
|
||||||
|
elseif c:byte() < 0x20 then
|
||||||
|
out[#out + 1] = string.format('\\u%04x', c:byte())
|
||||||
|
else
|
||||||
|
out[#out + 1] = c
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
out[#out + 1] = '"'
|
||||||
|
|
||||||
|
return table.concat(out)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function isArray(t)
|
||||||
|
local n = 0
|
||||||
|
|
||||||
|
for _ in pairs(t) do
|
||||||
|
n = n + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
if n == 0 then
|
||||||
|
return true -- empty table encodes as [] ; use {} explicitly for an empty object
|
||||||
|
end
|
||||||
|
|
||||||
|
for i = 1, n do
|
||||||
|
if t[i] == nil then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
local encodeValue
|
||||||
|
|
||||||
|
local function encodeArray(t)
|
||||||
|
local parts = {}
|
||||||
|
|
||||||
|
for i = 1, #t do
|
||||||
|
parts[i] = encodeValue(t[i])
|
||||||
|
end
|
||||||
|
|
||||||
|
return "[" .. table.concat(parts, ",") .. "]"
|
||||||
|
end
|
||||||
|
|
||||||
|
local function encodeObject(t)
|
||||||
|
local parts = {}
|
||||||
|
|
||||||
|
for k, v in pairs(t) do
|
||||||
|
parts[#parts + 1] = encodeString(tostring(k)) .. ":" .. encodeValue(v)
|
||||||
|
end
|
||||||
|
|
||||||
|
return "{" .. table.concat(parts, ",") .. "}"
|
||||||
|
end
|
||||||
|
|
||||||
|
encodeValue = function(v)
|
||||||
|
local t = type(v)
|
||||||
|
|
||||||
|
if v == nil then
|
||||||
|
return "null"
|
||||||
|
elseif t == "boolean" then
|
||||||
|
return v and "true" or "false"
|
||||||
|
elseif t == "number" then
|
||||||
|
return tostring(v)
|
||||||
|
elseif t == "string" then
|
||||||
|
return encodeString(v)
|
||||||
|
elseif t == "table" then
|
||||||
|
if isArray(v) then
|
||||||
|
return encodeArray(v)
|
||||||
|
end
|
||||||
|
return encodeObject(v)
|
||||||
|
end
|
||||||
|
|
||||||
|
error("RDB_Json.encode: unsupported type " .. t)
|
||||||
|
end
|
||||||
|
|
||||||
|
function RDB_Json.encode(value)
|
||||||
|
return encodeValue(value)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Decoder: simple recursive-descent parser over a string + cursor index.
|
||||||
|
|
||||||
|
local function newParser(str)
|
||||||
|
return { str = str, pos = 1, len = #str }
|
||||||
|
end
|
||||||
|
|
||||||
|
local function peek(p)
|
||||||
|
if p.pos > p.len then
|
||||||
|
return nil
|
||||||
|
end
|
||||||
|
|
||||||
|
return p.str:sub(p.pos, p.pos)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function skipWhitespace(p)
|
||||||
|
while p.pos <= p.len do
|
||||||
|
local c = p.str:sub(p.pos, p.pos)
|
||||||
|
|
||||||
|
if c == " " or c == "\t" or c == "\n" or c == "\r" then
|
||||||
|
p.pos = p.pos + 1
|
||||||
|
else
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local parseValue
|
||||||
|
|
||||||
|
local function parseLiteral(p, literal, value)
|
||||||
|
if p.str:sub(p.pos, p.pos + #literal - 1) ~= literal then
|
||||||
|
return nil, "expected " .. literal
|
||||||
|
end
|
||||||
|
p.pos = p.pos + #literal
|
||||||
|
return value
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parseString(p)
|
||||||
|
if peek(p) ~= '"' then
|
||||||
|
return nil, "expected string"
|
||||||
|
end
|
||||||
|
|
||||||
|
p.pos = p.pos + 1
|
||||||
|
local out = {}
|
||||||
|
|
||||||
|
while true do
|
||||||
|
if p.pos > p.len then
|
||||||
|
return nil, "unterminated string"
|
||||||
|
end
|
||||||
|
|
||||||
|
local c = p.str:sub(p.pos, p.pos)
|
||||||
|
|
||||||
|
if c == '"' then
|
||||||
|
p.pos = p.pos + 1
|
||||||
|
return table.concat(out)
|
||||||
|
elseif c == "\\" then
|
||||||
|
local nextC = p.str:sub(p.pos + 1, p.pos + 1)
|
||||||
|
|
||||||
|
if nextC == "u" then
|
||||||
|
local hex = p.str:sub(p.pos + 2, p.pos + 5)
|
||||||
|
local code = tonumber(hex, 16)
|
||||||
|
if not code then
|
||||||
|
return nil, "invalid \\u escape"
|
||||||
|
end
|
||||||
|
|
||||||
|
out[#out + 1] = string.char(code < 256 and code or 63)
|
||||||
|
p.pos = p.pos + 6
|
||||||
|
else
|
||||||
|
local unescaped = ({
|
||||||
|
['"'] = '"', ['\\'] = '\\', ['/'] = '/',
|
||||||
|
b = '\b', f = '\f', n = '\n', r = '\r', t = '\t',
|
||||||
|
})[nextC]
|
||||||
|
|
||||||
|
if not unescaped then
|
||||||
|
return nil, "invalid escape \\" .. tostring(nextC)
|
||||||
|
end
|
||||||
|
|
||||||
|
out[#out + 1] = unescaped
|
||||||
|
p.pos = p.pos + 2
|
||||||
|
end
|
||||||
|
else
|
||||||
|
out[#out + 1] = c
|
||||||
|
p.pos = p.pos + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parseNumber(p)
|
||||||
|
local start = p.pos
|
||||||
|
|
||||||
|
if peek(p) == "-" then
|
||||||
|
p.pos = p.pos + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
while peek(p) and peek(p):match("%d") do
|
||||||
|
p.pos = p.pos + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
if peek(p) == "." then
|
||||||
|
p.pos = p.pos + 1
|
||||||
|
|
||||||
|
while peek(p) and peek(p):match("%d") do
|
||||||
|
p.pos = p.pos + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
if peek(p) == "e" or peek(p) == "E" then
|
||||||
|
p.pos = p.pos + 1
|
||||||
|
|
||||||
|
if peek(p) == "+" or peek(p) == "-" then
|
||||||
|
p.pos = p.pos + 1
|
||||||
|
end
|
||||||
|
|
||||||
|
while peek(p) and peek(p):match("%d") do
|
||||||
|
p.pos = p.pos + 1
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local numStr = p.str:sub(start, p.pos - 1)
|
||||||
|
local n = tonumber(numStr)
|
||||||
|
if not n then
|
||||||
|
return nil, "invalid number"
|
||||||
|
end
|
||||||
|
|
||||||
|
return n
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parseArray(p)
|
||||||
|
p.pos = p.pos + 1 -- consume [
|
||||||
|
local out = {}
|
||||||
|
skipWhitespace(p)
|
||||||
|
|
||||||
|
if peek(p) == "]" then
|
||||||
|
p.pos = p.pos + 1
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
local i = 1
|
||||||
|
while true do
|
||||||
|
skipWhitespace(p)
|
||||||
|
|
||||||
|
local v, err = parseValue(p)
|
||||||
|
if err then
|
||||||
|
return nil, err
|
||||||
|
end
|
||||||
|
|
||||||
|
out[i] = v
|
||||||
|
i = i + 1
|
||||||
|
skipWhitespace(p)
|
||||||
|
local c = peek(p)
|
||||||
|
|
||||||
|
if c == "," then
|
||||||
|
p.pos = p.pos + 1
|
||||||
|
elseif c == "]" then
|
||||||
|
p.pos = p.pos + 1
|
||||||
|
return out
|
||||||
|
else
|
||||||
|
return nil, "expected , or ] in array"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parseObject(p)
|
||||||
|
p.pos = p.pos + 1 -- consume {
|
||||||
|
local out = {}
|
||||||
|
skipWhitespace(p)
|
||||||
|
|
||||||
|
if peek(p) == "}" then
|
||||||
|
p.pos = p.pos + 1
|
||||||
|
return out
|
||||||
|
end
|
||||||
|
|
||||||
|
while true do
|
||||||
|
skipWhitespace(p)
|
||||||
|
|
||||||
|
local key, err = parseString(p)
|
||||||
|
if err then
|
||||||
|
return nil, err
|
||||||
|
end
|
||||||
|
|
||||||
|
skipWhitespace(p)
|
||||||
|
|
||||||
|
if peek(p) ~= ":" then
|
||||||
|
return nil, "expected : in object"
|
||||||
|
end
|
||||||
|
|
||||||
|
p.pos = p.pos + 1
|
||||||
|
skipWhitespace(p)
|
||||||
|
|
||||||
|
local value
|
||||||
|
value, err = parseValue(p)
|
||||||
|
if err then
|
||||||
|
return nil, err
|
||||||
|
end
|
||||||
|
|
||||||
|
out[key] = value
|
||||||
|
skipWhitespace(p)
|
||||||
|
|
||||||
|
local c = peek(p)
|
||||||
|
if c == "," then
|
||||||
|
p.pos = p.pos + 1
|
||||||
|
elseif c == "}" then
|
||||||
|
p.pos = p.pos + 1
|
||||||
|
return out
|
||||||
|
else
|
||||||
|
return nil, "expected , or } in object"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
parseValue = function(p)
|
||||||
|
skipWhitespace(p)
|
||||||
|
|
||||||
|
local c = peek(p)
|
||||||
|
if c == nil then
|
||||||
|
return nil, "unexpected end of input"
|
||||||
|
elseif c == '"' then
|
||||||
|
return parseString(p)
|
||||||
|
elseif c == "{" then
|
||||||
|
return parseObject(p)
|
||||||
|
elseif c == "[" then
|
||||||
|
return parseArray(p)
|
||||||
|
elseif c == "t" then
|
||||||
|
return parseLiteral(p, "true", true)
|
||||||
|
elseif c == "f" then
|
||||||
|
return parseLiteral(p, "false", false)
|
||||||
|
elseif c == "n" then
|
||||||
|
return parseLiteral(p, "null", nil)
|
||||||
|
elseif c == "-" or c:match("%d") then
|
||||||
|
return parseNumber(p)
|
||||||
|
end
|
||||||
|
|
||||||
|
return nil, "unexpected character " .. c
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Round-trips a nested table through encode/decode and checks the result
|
||||||
|
-- matches. Not exhaustive, just a smoke test that the codec isn't broken.
|
||||||
|
function RDB_Json.selfTest()
|
||||||
|
local sample = {
|
||||||
|
v = 1,
|
||||||
|
id = "t1",
|
||||||
|
ok = true,
|
||||||
|
nested = { a = 1, b = "two", c = { 1, 2, 3 } },
|
||||||
|
empty = {},
|
||||||
|
}
|
||||||
|
|
||||||
|
local encoded = RDB_Json.encode(sample)
|
||||||
|
local decoded, err = RDB_Json.decode(encoded)
|
||||||
|
|
||||||
|
if err then
|
||||||
|
return false, "decode error: " .. err
|
||||||
|
end
|
||||||
|
if decoded.v ~= 1 or decoded.id ~= "t1" or decoded.ok ~= true then
|
||||||
|
return false, "top-level mismatch"
|
||||||
|
end
|
||||||
|
if decoded.nested.a ~= 1 or decoded.nested.b ~= "two" then
|
||||||
|
return false, "nested mismatch"
|
||||||
|
end
|
||||||
|
if #decoded.nested.c ~= 3 or decoded.nested.c[2] ~= 2 then
|
||||||
|
return false, "array mismatch"
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
function RDB_Json.decode(str)
|
||||||
|
if type(str) ~= "string" then
|
||||||
|
return nil, "input is not a string"
|
||||||
|
end
|
||||||
|
|
||||||
|
local p = newParser(str)
|
||||||
|
skipWhitespace(p)
|
||||||
|
local value, err = parseValue(p)
|
||||||
|
if err then
|
||||||
|
return nil, err
|
||||||
|
end
|
||||||
|
|
||||||
|
skipWhitespace(p)
|
||||||
|
if p.pos <= p.len then
|
||||||
|
return nil, "trailing data after value"
|
||||||
|
end
|
||||||
|
|
||||||
|
return value
|
||||||
|
end
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
RDB_Log = {}
|
||||||
|
|
||||||
|
local PREFIX = "[RconDataBridge] "
|
||||||
|
|
||||||
|
function RDB_Log.info(msg)
|
||||||
|
print(PREFIX .. "INFO: " .. tostring(msg))
|
||||||
|
end
|
||||||
|
|
||||||
|
function RDB_Log.warn(msg)
|
||||||
|
print(PREFIX .. "WARN: " .. tostring(msg))
|
||||||
|
end
|
||||||
|
|
||||||
|
function RDB_Log.error(msg)
|
||||||
|
print(PREFIX .. "ERROR: " .. tostring(msg))
|
||||||
|
end
|
||||||
Reference in New Issue
Block a user