Add opaque byte storage to mod API

This commit is contained in:
Shane McGovern
2026-08-14 21:42:45 +01:00
parent 797a6bebfe
commit 9fab992d42
6 changed files with 321 additions and 31 deletions
+110 -2
View File
@@ -7,6 +7,7 @@ love = love or require("tests.love_stub")
local T = require("tests.harness").suite("mod storage")
local Loader = require("src.mods.Loader")
local Runtime = require("src.mods.Runtime")
local Storage = require("src.mods.Storage")
local Version = require("src.core.Version")
local savedEvents, savedHooks = Runtime.events, Runtime.hooks
@@ -22,7 +23,9 @@ local function memfs(files)
function fs.read(path) return files[path] end
function fs.write(path, body)
if fs.failTmp and path:sub(-4) == ".tmp" then return false, "tmp denied" end
if fs.failMain and path:sub(-4) == ".lua" then return false, "main denied" end
if fs.failMain and (path:sub(-4) == ".lua" or path:sub(-4) == ".bin") then
return false, "main denied"
end
files[path] = body
return true
end
@@ -107,6 +110,50 @@ T.same(loaded, payload, "stored payload roundtrips as data")
T.check(loaded ~= payload and loaded.nested ~= payload.nested,
"read returns decoded data rather than the caller's live table")
T.check(type(alpha.writeBytes) == "function"
and type(alpha.readBytes) == "function",
"mod.storage exposes opaque byte read/write methods")
if type(alpha.writeBytes) == "function" and type(alpha.readBytes) == "function" then
local binary = "MESH\0\1\255\128\nreturn _G.MOD_STORAGE_EXECUTED = true"
local binaryOk, binaryCode, binaryMessage =
alpha:writeBytes(current, "states/quick/blob", binary)
T.check(binaryOk == true,
"opaque bytes write exactly: " .. tostring(binaryCode or binaryMessage))
local binaryLoaded, binaryReadCode =
alpha:readBytes(current, "states/quick/blob")
T.eq(binaryLoaded, binary,
"opaque bytes round-trip without text or Lua decoding")
T.eq(binaryReadCode, nil, "successful opaque byte read has no error")
T.eq(_G.MOD_STORAGE_EXECUTED, nil,
"Lua-looking opaque bytes are never executed")
local emptyOk = alpha:writeBytes(current, "binary/empty", "")
T.check(emptyOk == true, "empty opaque byte payloads are valid")
T.eq(alpha:readBytes(current, "binary/empty"), "",
"empty opaque byte payloads round-trip")
local badBytes, badBytesCode =
alpha:writeBytes(current, "binary/bad-type", { byte = true })
T.check(not badBytes and badBytesCode == "invalid_bytes",
"non-string opaque payloads are rejected")
local savedLimit = Storage.MAX_BYTES
Storage.MAX_BYTES = 4
local tooLarge, tooLargeCode =
alpha:writeBytes(current, "binary/too-large", "12345")
Storage.MAX_BYTES = savedLimit
T.check(not tooLarge and tooLargeCode == "size_limit",
"opaque payloads over the per-key limit are rejected")
local tableConflict, tableConflictCode =
alpha:writeBytes(current, "states/quick/q1", "table-key-conflict")
T.check(not tableConflict and tableConflictCode == "type_conflict",
"bytes cannot replace a table record without deletion")
local wrongType, wrongTypeCode = alpha:read(current, "states/quick/blob")
T.check(wrongType == nil and wrongTypeCode == "type_mismatch",
"table reads identify byte records as the wrong storage type")
end
local bad, badCode = alpha:write(current, "states/bad", { callback = function() end })
T.check(not bad and badCode == "encode_failed",
"functions are rejected with a stable data-only error")
@@ -120,19 +167,33 @@ T.check(alpha:write(current, "states/quick/zeta", { n = 2 }), "write zeta")
T.check(alpha:write(current, "states/quick/alpha", { n = 1 }), "write alpha")
T.check(alpha:write(current, "settings", { enabled = true }), "write settings")
local keys = alpha:list(current, "states/quick")
T.same(keys, { "states/quick/alpha", "states/quick/q1", "states/quick/zeta" },
T.same(keys, { "states/quick/alpha", "states/quick/blob",
"states/quick/q1", "states/quick/zeta" },
"list returns sorted logical keys under the requested prefix")
-- Mod, playthrough, and game namespaces cannot observe each other.
local missing, missingCode = beta:read(current, "states/quick/q1")
T.check(missing == nil and missingCode == "not_found",
"another mod cannot read the first mod's payload")
if type(alpha.readBytes) == "function" then
missing, missingCode = beta:readBytes(current, "states/quick/blob")
T.check(missing == nil and missingCode == "not_found",
"another mod cannot read the first mod's opaque payload")
end
missing, missingCode = alpha:read(game("red", "play-b"), "states/quick/q1")
T.check(missing == nil and missingCode == "not_found",
"another playthrough cannot read the payload")
missing, missingCode = alpha:read(game("blue", "play-a"), "states/quick/q1")
T.check(missing == nil and missingCode == "not_found",
"another game version cannot read the payload")
if type(alpha.readBytes) == "function" then
missing, missingCode = alpha:readBytes(game("red", "play-b"), "states/quick/blob")
T.check(missing == nil and missingCode == "not_found",
"another playthrough cannot read the opaque payload")
missing, missingCode = alpha:readBytes(game("blue", "play-a"), "states/quick/blob")
T.check(missing == nil and missingCode == "not_found",
"another game version cannot read the opaque payload")
end
-- Find the implementation-owned file only to inject corruption; assertions stay
-- on public read behavior, not the path shape.
@@ -142,6 +203,12 @@ local function mainFor(fragment)
end
end
local function byteMainFor(fragment)
for path in pairs(files) do
if path:find(fragment, 1, true) and path:sub(-4) == ".bin" then return path end
end
end
local q1Main = mainFor("q1")
T.check(type(q1Main) == "string", "failure fixture locates the persisted q1")
files[q1Main] = "not a serialized table"
@@ -158,6 +225,47 @@ T.check(not ok and code == "write_failed", "staging failure is reported")
T.same(alpha:read(current, "replace"), { version = 1 },
"staging failure leaves the prior value readable")
if type(alpha.writeBytes) == "function" and type(alpha.readBytes) == "function" then
T.check(alpha:writeBytes(current, "binary/recover", "old-bytes"),
"seed opaque recovery value")
local recoverMain = byteMainFor("binary/recover")
T.check(type(recoverMain) == "string", "failure fixture locates opaque recovery data")
files[recoverMain] = nil
T.eq(alpha:readBytes(current, "binary/recover"), "old-bytes",
"missing opaque main recovers the last verified backup")
T.check(alpha:writeBytes(current, "binary/replace", "version-1"),
"seed opaque replacement value")
fs.failTmp = true
ok, code = alpha:writeBytes(current, "binary/replace", "version-2")
fs.failTmp = false
T.check(not ok and code == "write_failed",
"opaque staging failure is reported")
T.eq(alpha:readBytes(current, "binary/replace"), "version-1",
"opaque staging failure leaves the prior value readable")
fs.failMain = true
ok, code = alpha:writeBytes(current, "binary/replace", "version-3")
fs.failMain = false
T.check(not ok and code == "write_failed",
"opaque replacement failure is reported")
T.eq(alpha:readBytes(current, "binary/replace"), "version-1",
"opaque replacement failure leaves the prior value readable")
local byteConflict, byteConflictCode =
alpha:write(current, "binary/replace", { version = 3 })
T.check(not byteConflict and byteConflictCode == "type_conflict",
"tables cannot replace a byte record without deletion")
T.check(alpha:writeBytes(current, "binary/delete", "delete-me"),
"seed opaque delete target")
T.check(alpha:delete(current, "binary/delete") == true,
"delete removes an opaque record")
missing, missingCode = alpha:readBytes(current, "binary/delete")
T.check(missing == nil and missingCode == "not_found",
"deleted opaque key is unavailable")
end
-- Delete is exact and idempotent-not-found is explicit.
T.check(alpha:write(current, "delete/me", { yes = true }), "seed delete target")
T.check(alpha:write(current, "delete/keep", { yes = true }), "seed delete neighbor")