path read

This commit is contained in:
bryanthaboi
2026-08-14 17:51:55 -04:00
parent 6307bc92f6
commit fb738fa1ce
4 changed files with 78 additions and 8 deletions
+7 -4
View File
@@ -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
+37
View File
@@ -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
+4 -4
View File
@@ -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,
}
+30
View File
@@ -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