diff --git a/src/core/SaveData.lua b/src/core/SaveData.lua index c12115fd..f68a10b4 100644 --- a/src/core/SaveData.lua +++ b/src/core/SaveData.lua @@ -216,6 +216,13 @@ local function persistFs(fs) return SaveData.portableFs() or fs or (love and love.filesystem) end +-- Engine-owned persistence routing for subsystems that must follow the same +-- standard/portable root as saves without exposing raw filesystem access to a +-- mod. An explicitly injected headless filesystem still wins for tests. +function SaveData.persistenceFs(fs) + return persistFs(fs) +end + -- Port + original Options menu defaults. Missing keys on load are filled -- from this table so old options.lua files stay compatible. function SaveData.defaultOptions() diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index aa6b803f..0ce9c314 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -569,6 +569,8 @@ end function Loader:_api(mod) local loader = self local modId = mod.manifest.id + local Storage = engineRequire("src.mods.Storage") + local storage = Storage and Storage.new(modId, loader.fs) local api = { id = modId, version = mod.manifest.version, @@ -658,6 +660,16 @@ function Loader:_api(mod) bucket[key] = value end, }, + -- Data-only state independent of the vanilla progress checkpoint. The + -- engine binds version/playthrough/mod scope and portable persistence; + -- callers never receive paths or a raw filesystem handle. + storage = { + context = function(_, game) return storage:context(game) end, + write = function(_, game, key, value) return storage:write(game, key, value) end, + read = function(_, game, key) return storage:read(game, key) end, + list = function(_, game, prefix) return storage:list(game, prefix) end, + delete = function(_, game, key) return storage:delete(game, key) end, + }, options = { define = function(_, schema) assert(type(schema) == "table", "options schema must be a table of rows") diff --git a/src/mods/Storage.lua b/src/mods/Storage.lua new file mode 100644 index 00000000..9b80c360 --- /dev/null +++ b/src/mods/Storage.lua @@ -0,0 +1,208 @@ +-- Data-only per-mod persistence, scoped by game version and opaque playthrough. +-- This module is engine-private; Loader exposes only the bound facade methods. + +local SaveData = require("src.core.SaveData") +local SaveSerializer = require("src.core.SaveSerializer") + +local Storage = {} +Storage.__index = Storage + +local ROOT = "mod_storage" + +local function failure(code, message) + return nil, code, message +end + +local function validSegment(value) + return type(value) == "string" and value ~= "" + and value:match("^[%w_-]+$") ~= nil +end + +local function validKey(key, allowEmpty) + if type(key) ~= "string" or (key == "" and not allowEmpty) then return false end + if key == "" then return true end + if key:sub(1, 1) == "/" or key:sub(-1) == "/" or key:find("//", 1, true) then + return false + end + for segment in key:gmatch("[^/]+") do + if not validSegment(segment) then return false end + end + return true +end + +local function ensureParent(fs, path) + local dir = path:match("^(.*)/[^/]+$") + if dir and fs.createDirectory then fs.createDirectory(dir) end +end + +local function remove(fs, path) + if fs.remove then fs.remove(path) end +end + +local function decodeAt(fs, path) + if not (fs.getInfo and fs.getInfo(path)) then return nil end + local body = fs.read and fs.read(path) + if type(body) ~= "string" then return nil end + local data = SaveSerializer.decode(body) + if not data then return nil end + return data, body +end + +function Storage.new(modId, fs) + assert(validSegment(modId), "Storage.new needs a safe mod id") + return setmetatable({ modId = modId, injectedFs = fs }, Storage) +end + +function Storage:_scope(game) + local save = game and game.save + local meta = save and save.meta + local version = save and save.version + local playthroughId = meta and meta.playthroughId + if not (save and validSegment(version) and validSegment(playthroughId)) then + return failure("not_in_playthrough", + "Storage is available only inside an identified playthrough.") + end + local fs = SaveData.persistenceFs(self.injectedFs) + if not (fs and fs.read and fs.write and fs.getInfo) then + return failure("storage_unavailable", "The persistence backend is unavailable.") + end + local base = table.concat({ ROOT, version, playthroughId, self.modId }, "/") + return { gameVersion = version, playthroughId = playthroughId, + base = base, fs = fs } +end + +function Storage:context(game) + local scope, code, message = self:_scope(game) + if not scope then return nil, code, message end + return { gameVersion = scope.gameVersion, playthroughId = scope.playthroughId } +end + +function Storage:_names(game, key, allowEmpty) + if not validKey(key, allowEmpty) then + return failure("invalid_key", + "Storage keys use nonempty letters, numbers, underscore, dash and slash segments.") + end + local scope, code, message = self:_scope(game) + if not scope then return nil, code, message end + local path = scope.base .. (key ~= "" and ("/" .. key) or "") + return scope, path .. ".lua", path .. ".lua.bak", path .. ".lua.tmp" +end + +function Storage:write(game, key, value) + local scope, main, bak, tmp = self:_names(game, key, false) + if not scope then return false, main, bak end + if type(value) ~= "table" then + return false, "encode_failed", "Storage values must be data-only tables." + end + local encodedOk, encoded = pcall(SaveSerializer.encode, value) + if not encodedOk then + return false, "encode_failed", "Storage value is not serializable data: " + .. tostring(encoded) + end + + local fs = scope.fs + ensureParent(fs, main) + local _, previous = decodeAt(fs, main) + if not previous then _, previous = decodeAt(fs, bak) end + + local ok, err = fs.write(tmp, encoded) + if not ok then + return false, "write_failed", "Could not stage storage data: " .. tostring(err) + end + local staged = decodeAt(fs, tmp) + if not staged then + remove(fs, tmp) + return false, "verify_failed", "Staged storage data could not be verified." + end + + if previous then fs.write(bak, previous) end + ok, err = fs.write(main, encoded) + if not ok then + remove(fs, tmp) + return false, "write_failed", "Could not replace storage data: " .. tostring(err) + end + local verified = decodeAt(fs, main) + if not verified then + remove(fs, main) + remove(fs, tmp) + return false, "verify_failed", "Replacement storage data could not be verified." + end + + -- At rest both main and backup hold the newest verified record. If a later + -- write dies after rolling this copy aside, one verified generation remains. + fs.write(bak, encoded) + remove(fs, tmp) + return true +end + +function Storage:read(game, key) + local scope, main, bak, tmp = self:_names(game, key, false) + if not scope then return nil, main, bak end + local fs = scope.fs + local data, body = decodeAt(fs, main) + if data then return data end + + data, body = decodeAt(fs, tmp) + if not data then data, body = decodeAt(fs, bak) end + if not data then + return nil, "not_found", "No valid stored value exists for this key." + end + + -- Best-effort healing. The recovered copy remains in tmp/bak if promotion + -- cannot land, so returning it is still safe and the next read can retry. + ensureParent(fs, main) + if fs.write(main, body) then fs.write(bak, body) end + remove(fs, tmp) + return data +end + +function Storage:list(game, prefix) + prefix = prefix or "" + local scope, main, codeOrBak = self:_names(game, prefix, true) + if not scope then return nil, main, codeOrBak end + local fs = scope.fs + if not fs.getDirectoryItems then + return nil, "storage_unavailable", "The persistence backend cannot enumerate keys." + end + + local base = scope.base + local start = prefix == "" and base or (base .. "/" .. prefix) + local out = {} + + local function walk(path, logical) + local info = fs.getInfo(path) + if not info then return end + if info.type == "file" then + if path:sub(-4) == ".lua" then out[#out + 1] = logical:sub(1, -5) end + return + end + for _, child in ipairs(fs.getDirectoryItems(path) or {}) do + local childLogical = logical == "" and child or (logical .. "/" .. child) + walk(path .. "/" .. child, childLogical) + end + end + + -- A prefix may identify one exact key or a directory of keys. + if fs.getInfo(start .. ".lua") then + out[#out + 1] = prefix + else + walk(start, prefix) + end + table.sort(out) + return out +end + +function Storage:delete(game, key) + local scope, main, bak, tmp = self:_names(game, key, false) + if not scope then return false, main, bak end + local fs = scope.fs + if not (fs.getInfo(main) or fs.getInfo(bak) or fs.getInfo(tmp)) then + return false, "not_found", "No stored value exists for this key." + end + remove(fs, main) + remove(fs, bak) + remove(fs, tmp) + return true +end + +return Storage diff --git a/tests/mod_storage_tests.lua b/tests/mod_storage_tests.lua new file mode 100644 index 00000000..52b0f886 --- /dev/null +++ b/tests/mod_storage_tests.lua @@ -0,0 +1,177 @@ +-- Public mod.storage contract: data-only transactions, namespace isolation, +-- deterministic listing, recovery, and failure retention. + +package.path = "./?.lua;./?/init.lua;" .. package.path +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 savedEvents, savedHooks = Runtime.events, Runtime.hooks + +local function manifest(id) + return ('{"id":"%s","name":"%s","version":"1.0.0",') + :format(id, id) .. '"entry":"main.lua","api":2,"profile":"content"}' +end + +local function memfs(files) + local fs = { files = files, failTmp = false, failMain = false } + + 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 + files[path] = body + return true + end + function fs.remove(path) files[path] = nil return true end + function fs.createDirectory() return true end + function fs.getInfo(path) + if files[path] then return { type = "file" } end + local prefix = path .. "/" + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then return { type = "directory" } end + end + return nil + end + function fs.load(path) + if not files[path] then return nil, "no file: " .. path end + return load(files[path], path) + end + function fs.getDirectoryItems(path) + local prefix, seen, out = path .. "/", {}, {} + for key in pairs(files) do + if key:sub(1, #prefix) == prefix then + local child = key:sub(#prefix + 1):match("^[^/]+") + if child and not seen[child] then + seen[child] = true + out[#out + 1] = child + end + end + end + table.sort(out) + return out + end + return fs +end + +local function game(version, playthroughId) + return { save = { + version = version, + meta = { format = 4, mods = {}, playthroughId = playthroughId }, + } } +end + +local files = { + ["mods/alpha/manifest.json"] = manifest("alpha"), + ["mods/alpha/main.lua"] = [[ +return function(mod) _G.MOD_STORAGE_ALPHA = mod.storage end +]], + ["mods/beta/manifest.json"] = manifest("beta"), + ["mods/beta/main.lua"] = [[ +return function(mod) _G.MOD_STORAGE_BETA = mod.storage end +]], +} +local fs = memfs(files) +local loader = Loader.new({ fs = fs }) +local current = game("red", "play-a") +loader.game = current +T.check(loader:load({}) == true, "storage fixture mods load") + +local alpha, beta = _G.MOD_STORAGE_ALPHA, _G.MOD_STORAGE_BETA +T.check(type(alpha) == "table" and type(beta) == "table", + "Loader exposes mod.storage through the public mod object") +if type(alpha) ~= "table" or type(beta) ~= "table" then + Runtime.events, Runtime.hooks = savedEvents, savedHooks + _G.MOD_STORAGE_ALPHA, _G.MOD_STORAGE_BETA = nil, nil + T.finish() +end + +-- Removing scope identity or exposing a mutable private slot id breaks this. +local context = alpha:context(current) +T.same(context, { gameVersion = "red", playthroughId = "play-a" }, + "context exposes stable game/playthrough identity only") + +-- Data-only write/read. The literal expected table is independent of storage. +local payload = { format = 1, nested = { money = 1234 }, flags = { a = true } } +local ok, code, message = alpha:write(current, "states/quick/q1", payload) +T.check(ok == true, "data-only payload writes: " .. tostring(code or message)) +local loaded = alpha:read(current, "states/quick/q1") +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") + +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") + +local escaped, escapedCode = alpha:write(current, "../escape", {}) +T.check(not escaped and escapedCode == "invalid_key", + "path traversal is rejected before persistence") + +-- Logical enumeration is deterministic and prefix-scoped. +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" }, + "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") +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") + +-- Find the implementation-owned file only to inject corruption; assertions stay +-- on public read behavior, not the path shape. +local function mainFor(fragment) + for path in pairs(files) do + if path:find(fragment, 1, true) and path:sub(-4) == ".lua" 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" +loaded, code = alpha:read(current, "states/quick/q1") +T.same(loaded, payload, "corrupt main recovers the last verified payload") +T.eq(code, nil, "successful recovery is a normal read") + +-- A failed replacement cannot destroy the prior verified value. +T.check(alpha:write(current, "replace", { version = 1 }), "seed replace value") +fs.failTmp = true +ok, code = alpha:write(current, "replace", { version = 2 }) +fs.failTmp = false +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") + +-- 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") +T.check(alpha:delete(current, "delete/me") == true, "delete removes its target") +missing, missingCode = alpha:read(current, "delete/me") +T.check(missing == nil and missingCode == "not_found", "deleted key is unavailable") +T.same(alpha:read(current, "delete/keep"), { yes = true }, + "delete leaves neighboring keys untouched") + +-- No-mod parity: constructing/loading an empty loader creates no storage bytes. +local emptyFiles, emptyFs = {}, nil +emptyFs = memfs(emptyFiles) +local emptyLoader = Loader.new({ fs = emptyFs }) +emptyLoader.game = current +T.check(emptyLoader:load({}) == true, "no-mod loader still boots") +T.eq(next(emptyFiles), nil, "no-mod boot creates no storage paths or files") + +Runtime.events, Runtime.hooks = savedEvents, savedHooks +Runtime.currentMod = nil +_G.MOD_STORAGE_ALPHA, _G.MOD_STORAGE_BETA = nil, nil + +T.finish()