diff --git a/CONTRIBUTING-mods.md b/CONTRIBUTING-mods.md index cd5930b8..295934f9 100644 --- a/CONTRIBUTING-mods.md +++ b/CONTRIBUTING-mods.md @@ -254,7 +254,7 @@ engine's globals. Every chunk you author gets it: `main.lua`, your | `os.getenv`, `os.execute`, `os.remove`, `os.rename`, `os.exit` | nothing; `os.time`/`os.date`/`os.clock` still work | | `package`, `dofile`, `loadfile`, `debug`, `getfenv`, `setfenv` | `require` for the supported engine modules | | `require("ffi")`, `require("love.*")` | the `love` table you are given | -| `love.filesystem` | `mod.storage` (per-mod, per-playthrough) and `mod:read` | +| `love.filesystem` | `mod.storage` (per-mod, per-playthrough), `mod:read` for a known file, `mod:list` / `mod:info` to iterate your own directory | | `love.thread`, `love.event` | `mod.events`, `mod.hooks` | | `love.system` | `mod.device:powerInfo()` for battery information; `mod.steps` (with the `steps` permission) for the step bridge | @@ -269,9 +269,12 @@ Three consequences worth knowing before you write against it: `mod.find("your_id").exports` — the channel that was always the intended one. The same goes for the standard library: `string`, `table` and `math` are per-mod copies, so patching one is a local decision. -- **Paths cannot climb.** `mod:read`, `mod.assets:path` and `mod.assets:image` - join to your own directory, and `..`, absolute paths and drive letters are - refused. So are `entry` and `options_schema` in your manifest. +- **Paths cannot climb.** `mod:read`, `mod:list`, `mod:info`, `mod.assets:path` + and `mod.assets:image` join to your own directory, and `..`, absolute paths + and drive letters are refused. So are `entry` and `options_schema` in your + manifest. `mod:list("assets")` is the sandboxed `getDirectoryItems` for a + folder you shipped; `mod:info` tells file from directory so a walk can + recurse. - **Ship source, not bytecode.** A precompiled entry file is refused. `permissions` in the manifest is still a disclosure the manager shows the diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index caf92dee..8854b9bb 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1163,6 +1163,39 @@ function Loader:_api(mod) api.content[alias] = self:_contentApi(mod, self.content[canonical], ("the %s registry is deprecated; use %s"):format(alias, canonical)) end + -- A relative path inside this mod, or the mod root when relative is + -- omitted. Empty is the one listing case SafePath.safe rejects on + -- purpose (it is not a file), so it is special-cased here. + local function ownPath(relative, what) + if relative == nil or relative == "" then return mod.path end + return SafePath.join(mod.path, relative, what) + end + + -- Shallow directory listing, the sandboxed stand-in for + -- love.filesystem.getDirectoryItems. Names only, sorted, never a host + -- path. A missing directory is an empty list, not an error. + local function listOwn(_, relative) + local dir = ownPath(relative, "mod:list") + local fs = loader.fs + if not (fs and fs.getDirectoryItems) then return {} end + local items = fs.getDirectoryItems(dir) or {} + local out = {} + for i = 1, #items do out[i] = items[i] end + table.sort(out) + return out + end + + -- love.filesystem.getInfo for a path inside this mod. type is "file" or + -- "directory"; size is set for files. nil when the path does not exist. + local function infoOwn(_, relative) + local path = ownPath(relative, "mod:info") + local fs = loader.fs + if not (fs and fs.getInfo) then return nil end + local info = fs.getInfo(path) + if not info then return nil end + return { type = info.type, size = info.size } + end + -- assets keeps the v1 alias to the content accessors and adds the file -- helpers on top, so mod.assets.pokemon and mod.assets:image both resolve api.assets = setmetatable({ @@ -1179,12 +1212,16 @@ function Loader:_api(mod) loader.imageCache[full] = image return image end, + list = listOwn, + info = infoOwn, }, { __index = api.content }) -- the mod's own directory and nothing above it: PhysFS already refuses a -- climb, but loader.fs is injectable and has no such floor function api:read(relative) return loader.fs.read(SafePath.join(self.path, relative, "mod:read")) end + api.list = listOwn + api.info = infoOwn -- mod.world materializes on first touch, like the image helper above: a -- headless load must not drag the world stack in, and the Game the facade -- acts on is still being wired when the entry chunk runs diff --git a/src/mods/Sandbox.lua b/src/mods/Sandbox.lua index 195ad3d2..b6cb61dd 100644 --- a/src/mods/Sandbox.lua +++ b/src/mods/Sandbox.lua @@ -46,11 +46,11 @@ function Sandbox.moduleDenial(name, permissionSet) local reason = DENIED[root] if reason then return ("%s is not available to mods (it grants %s); use mod.storage, " - .. "mod:read and the engine API instead"):format(name, reason) + .. "mod:read, mod:list and the engine API instead"):format(name, reason) end if DENIED_PREFIX[root] and name ~= root then - return ("%s is not available to mods; use mod.storage, mod:read and the " - .. "engine API instead"):format(name) + return ("%s is not available to mods; use mod.storage, mod:read, mod:list " + .. "and the engine API instead"):format(name) end if NETWORK[root] and not (permissionSet or {}).network then return ("%s needs the \"network\" permission in manifest.json"):format(name) @@ -68,7 +68,7 @@ end -- without an edit here. -- value is the replacement to name in the error, or true when there is none local BLOCKED_LOVE = { - filesystem = "mod.storage and mod:read", thread = true, + filesystem = "mod.storage, mod:read and mod:list", thread = true, system = "mod.device:powerInfo() for battery information, mod.steps for " .. "the step bridge", event = true, } diff --git a/tests/modkit/cases/sandbox.lua b/tests/modkit/cases/sandbox.lua index d9f629ea..e021e1bb 100644 --- a/tests/modkit/cases/sandbox.lua +++ b/tests/modkit/cases/sandbox.lua @@ -69,6 +69,16 @@ local PROBE = [[ out.readBackslash = attempt(function() return mod:read("..\\secret.txt") end) out.assetsEscape = attempt(function() return mod.assets:path("../../x.png") end) out.readOwn = mod:read("data/note.txt") + out.listAssets = mod:list("assets") + out.listSprites = mod:list("assets/sprites") + out.listRoot = mod:list() + out.assetsList = mod.assets:list("assets") + out.infoAssets = mod:info("assets") + out.infoNote = mod:info("data/note.txt") + out.infoMissing = mod:info("nope") + out.listMissing = mod:list("nope") + out.listEscape = attempt(function() return mod:list("../secret") end) + out.infoEscape = attempt(function() return mod:info("../../x") end) _G.SANDBOX_LEAK = "escaped" out.globalsAreOwn = _G ~= nil and _G.SANDBOX_LEAK == "escaped" @@ -81,6 +91,8 @@ local FILES = { ["mods/fix_sandbox/manifest.json"] = manifest("fix_sandbox"), ["mods/fix_sandbox/main.lua"] = PROBE, ["mods/fix_sandbox/data/note.txt"] = "own file", + ["mods/fix_sandbox/assets/front.png"] = "png", + ["mods/fix_sandbox/assets/sprites/walk.png"] = "png", } local run = T.sdk.loadMods({ "mods/fix_sandbox" }, { fs = T.sdk.memfs(FILES) }) @@ -155,6 +167,24 @@ T.check(out.readAbsolute ~= false, "mod:read refuses an absolute path") T.check(out.readBackslash ~= false, "mod:read refuses a backslash climb") T.check(out.assetsEscape ~= false, "mod.assets:path refuses a climb") T.eq(out.readOwn, "own file", "and the mod's own files still read") +T.same(out.listAssets, { "front.png", "sprites" }, + "mod:list names the children of a directory inside the mod") +T.same(out.listSprites, { "walk.png" }, + "and a nested directory") +T.check(out.listRoot and out.listRoot[1] ~= nil, + "mod:list() with no path lists the mod root") +T.same(out.assetsList, out.listAssets, + "mod.assets:list is the same listing") +T.eq(out.infoAssets and out.infoAssets.type, "directory", + "mod:info reports a directory") +T.eq(out.infoNote and out.infoNote.type, "file", + "and a file") +T.eq(out.infoMissing, nil, "mod:info is nil for a missing path") +T.same(out.listMissing, {}, "mod:list of a missing path is empty, not an error") +T.check(out.listEscape and out.listEscape:find("must stay inside", 1, true), + "mod:list cannot climb out of the mod directory: " .. tostring(out.listEscape)) +T.check(out.infoEscape and out.infoEscape:find("must stay inside", 1, true), + "mod:info cannot climb either") run.release() -- ------- the grammar itself