mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-17 19:24:01 +02:00
bazinga
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
-- mod.fetch: background HTTP for sandboxed mods, behind the "network"
|
||||
-- permission. The sandbox blocks love.thread because newThread's Lua state
|
||||
-- escapes every rule in it; this is the replacement, so the things that make
|
||||
-- it NOT an escape are what this file pins -- http/https only, handles that
|
||||
-- are opaque and per-mod, a ceiling on jobs in flight, and release on unload.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Net = require("src.mods.Net")
|
||||
local Fetch = require("src.net.Fetch")
|
||||
|
||||
-- Stand in for the worker pool: jobs resolve when the test says so, so no
|
||||
-- test here touches a socket.
|
||||
local submitted, nextId, states = {}, 0, {}
|
||||
Fetch.get = function(url, opts)
|
||||
nextId = nextId + 1
|
||||
submitted[nextId] = { url = url, opts = opts }
|
||||
states[nextId] = { status = "pending", progress = 0 }
|
||||
return nextId
|
||||
end
|
||||
Fetch.poll = function(id) return states[id] or { status = "error", err = "unknown job" } end
|
||||
Fetch.isPending = function(id) return (states[id] or {}).status == "pending" end
|
||||
Fetch.release = function(id) states[id] = nil end
|
||||
Fetch.cancel = function(id)
|
||||
if states[id] and states[id].status == "pending" then states[id].status = "cancelled" end
|
||||
end
|
||||
Fetch.available = function() return true end
|
||||
|
||||
local FETCHER = {
|
||||
["mods/net_fetcher/manifest.json"] = [[{
|
||||
"id": "net_fetcher",
|
||||
"name": "Net Fetcher",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2,
|
||||
"permissions": ["network"]
|
||||
}]],
|
||||
["mods/net_fetcher/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.exports.available = mod.fetch:available()
|
||||
mod.exports.get = function(url, opts) return mod.fetch:get(url, opts) end
|
||||
mod.exports.poll = function(h) return mod.fetch:poll(h) end
|
||||
mod.exports.release = function(h) return mod.fetch:release(h) end
|
||||
mod.exports.cancel = function(h) return mod.fetch:cancel(h) end
|
||||
]],
|
||||
}
|
||||
|
||||
local OTHER = {
|
||||
["mods/net_other/manifest.json"] = [[{
|
||||
"id": "net_other",
|
||||
"name": "Net Other",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2,
|
||||
"permissions": ["network"]
|
||||
}]],
|
||||
["mods/net_other/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.exports.poll = function(h) return mod.fetch:poll(h) end
|
||||
mod.exports.cancel = function(h) return mod.fetch:cancel(h) end
|
||||
mod.exports.get = function(url) return mod.fetch:get(url) end
|
||||
]],
|
||||
}
|
||||
|
||||
local UNPERMISSIONED = {
|
||||
["mods/net_probe/manifest.json"] = [[{
|
||||
"id": "net_probe",
|
||||
"name": "Net Probe",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2
|
||||
}]],
|
||||
["mods/net_probe/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.exports.available = mod.fetch:available()
|
||||
local ok, err = pcall(function() return mod.fetch:get("https://example.com") end)
|
||||
mod.exports.refused = not ok and tostring(err) or false
|
||||
]],
|
||||
}
|
||||
|
||||
local function merged(...)
|
||||
local out = {}
|
||||
for _, fixture in ipairs({ ... }) do
|
||||
for path, body in pairs(fixture) do out[path] = body end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------- scheme restriction
|
||||
-- curl also speaks file://, scp:// and ftp://. Without this check mod.fetch
|
||||
-- would be a filesystem read and the sandbox would be pointless.
|
||||
T.eq(Net.urlDenial("https://example.com/i.json"), nil, "https is allowed")
|
||||
T.eq(Net.urlDenial("http://example.com/i.json"), nil, "http is allowed")
|
||||
T.check(Net.urlDenial("file:///etc/passwd"), "file:// is refused")
|
||||
T.check(Net.urlDenial("FILE:///etc/passwd"), "file:// is refused case-insensitively")
|
||||
T.check(Net.urlDenial("scp://host/secret"), "scp:// is refused")
|
||||
T.check(Net.urlDenial("ftp://host/x"), "ftp:// is refused")
|
||||
T.check(Net.urlDenial("/etc/passwd"), "a bare path is refused")
|
||||
T.check(Net.urlDenial("https://"), "a url with no host is refused")
|
||||
T.check(Net.urlDenial(nil), "a non-string url is refused")
|
||||
T.check(Net.urlDenial("file:///x"):find("http", 1, true),
|
||||
"the refusal says what is allowed")
|
||||
|
||||
-- ------------------------------------------------------- permissioned use
|
||||
local run = T.sdk.loadMods({ "mods/net_fetcher", "mods/net_other" },
|
||||
{ fs = T.sdk.memfs(merged(FETCHER, OTHER)) })
|
||||
T.eq(#run.errors, 0,
|
||||
"the permissioned mods load clean (" .. tostring(run.errors[1]) .. ")")
|
||||
local api = run.loader.exports.net_fetcher
|
||||
T.eq(api.available, true, "available() is true with the permission")
|
||||
|
||||
local handle, err = api.get("https://example.com/index.json")
|
||||
T.check(handle ~= nil, "get() returns a handle (" .. tostring(err) .. ")")
|
||||
T.eq(type(handle), "table", "the handle is opaque, not the engine's job id")
|
||||
T.eq(api.poll(handle).status, "pending", "a fresh job polls as pending")
|
||||
|
||||
-- the mod is named to the server, and cannot pose as the launcher
|
||||
local sent
|
||||
for _, job in pairs(submitted) do
|
||||
if job.url == "https://example.com/index.json" then sent = job end
|
||||
end
|
||||
T.check(sent and sent.opts.userAgent:find("net_fetcher", 1, true),
|
||||
"the request identifies the calling mod")
|
||||
|
||||
-- a refused url never reaches the pool
|
||||
local before = nextId
|
||||
local bad, badErr = api.get("file:///etc/passwd")
|
||||
T.eq(bad, nil, "a file:// url returns no handle")
|
||||
T.check(badErr and badErr:find("http", 1, true), "and says why")
|
||||
T.eq(nextId, before, "and never reaches the fetch pool")
|
||||
|
||||
-- the body arrives through poll, as a copy
|
||||
states[sent and 1 or 1] = { status = "ok", body = "{\"mods\":[]}", progress = 1 }
|
||||
local got = api.poll(handle)
|
||||
T.eq(got.status, "ok", "a completed job polls ok")
|
||||
T.eq(got.body, "{\"mods\":[]}", "and hands over the body")
|
||||
got.body = "tampered"
|
||||
T.eq(api.poll(handle).body, "{\"mods\":[]}",
|
||||
"poll returns a copy; a mod cannot edit the engine's job table")
|
||||
|
||||
-- --------------------------------------------- handles do not cross mods
|
||||
-- Fetch keys jobs by integer and the launcher's own downloads live in the
|
||||
-- same table, so this is the property that matters most.
|
||||
local other = run.loader.exports.net_other
|
||||
T.eq(other.poll(handle).status, "error",
|
||||
"another mod cannot poll a handle it does not own")
|
||||
T.eq(other.cancel(handle), false,
|
||||
"another mod cannot cancel a handle it does not own")
|
||||
T.eq(api.poll({}).status, "error", "a forged handle reads as an error")
|
||||
T.eq(api.poll(1).status, "error", "a guessed integer id reads as an error")
|
||||
|
||||
-- ------------------------------------------------------ in-flight ceiling
|
||||
-- One mod must not be able to fill the shared three-worker pool.
|
||||
local held = {}
|
||||
for i = 1, Net.MAX_INFLIGHT + 2 do
|
||||
held[i] = select(1, api.get("https://example.com/" .. i))
|
||||
end
|
||||
local live = 0
|
||||
for _, h in ipairs(held) do if h then live = live + 1 end end
|
||||
T.check(live <= Net.MAX_INFLIGHT,
|
||||
"a mod is capped at " .. Net.MAX_INFLIGHT .. " requests in flight")
|
||||
local _, capErr = api.get("https://example.com/overflow")
|
||||
T.check(capErr and capErr:find("in flight", 1, true),
|
||||
"the refusal explains the cap")
|
||||
-- releasing frees a slot
|
||||
api.release(held[1])
|
||||
local after = api.get("https://example.com/after-release")
|
||||
T.check(after ~= nil, "releasing a handle frees a slot")
|
||||
|
||||
-- the timeout is clamped, so a mod cannot pin a worker
|
||||
for _, h in ipairs(held) do if h then api.release(h) end end
|
||||
if after then api.release(after) end
|
||||
local slow, slowErr = api.get("https://example.com/slow", { maxSeconds = 99999 })
|
||||
T.check(slow ~= nil, "a slot is free again (" .. tostring(slowErr) .. ")")
|
||||
local slowJob
|
||||
for _, job in pairs(submitted) do
|
||||
if job.url == "https://example.com/slow" then slowJob = job end
|
||||
end
|
||||
T.check(slow and slowJob.opts.maxSeconds <= Net.MAX_SECONDS,
|
||||
"a caller's timeout is clamped to " .. Net.MAX_SECONDS .. "s")
|
||||
|
||||
-- ------------------------------------------------------ release on unload
|
||||
local loader = run.loader
|
||||
T.check(loader.netJobs and loader.netJobs.net_fetcher,
|
||||
"the loader tracks the mod's jobs")
|
||||
Net.releaseAll(loader, "net_fetcher")
|
||||
T.eq(loader.netJobs.net_fetcher, nil,
|
||||
"unloading a mod drops every job it still held")
|
||||
run.release()
|
||||
|
||||
-- --------------------------------------------- without the permission
|
||||
local probe = T.sdk.loadMods({ "mods/net_probe" },
|
||||
{ fs = T.sdk.memfs(UNPERMISSIONED) })
|
||||
T.eq(#probe.errors, 0,
|
||||
"the unpermissioned mod loads clean (" .. tostring(probe.errors[1]) .. ")")
|
||||
local out = probe.loader.exports.net_probe
|
||||
T.eq(out.available, false, "available() is quietly false without the permission")
|
||||
T.check(out.refused and out.refused:find('"network" permission', 1, true),
|
||||
"get() without the permission names it")
|
||||
probe.release()
|
||||
|
||||
T.finish("mod_fetch")
|
||||
@@ -0,0 +1,176 @@
|
||||
-- mod.job: background compute for sandboxed mods, behind the "background"
|
||||
-- permission. The worker rebuilds the mod's sandbox before loading its
|
||||
-- script, so what this file pins is the contract that keeps a job from being
|
||||
-- love.thread by another name -- plain data only, paths that cannot climb,
|
||||
-- handles that do not cross mods, and ceilings on how much a mod can start.
|
||||
|
||||
package.path = "./?.lua;./?/init.lua;" .. package.path
|
||||
|
||||
local T = require("tests.modkit")
|
||||
local Job = require("src.mods.Job")
|
||||
|
||||
local WORKER = {
|
||||
["mods/job_worker_mod/manifest.json"] = [[{
|
||||
"id": "job_worker_mod",
|
||||
"name": "Job Worker",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2,
|
||||
"permissions": ["background"]
|
||||
}]],
|
||||
["mods/job_worker_mod/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.exports.available = mod.job:available()
|
||||
mod.exports.run = function(script, arg, opts)
|
||||
return mod.job:run(script, arg, opts)
|
||||
end
|
||||
mod.exports.poll = function(h) return mod.job:poll(h) end
|
||||
mod.exports.release = function(h) return mod.job:release(h) end
|
||||
mod.exports.cancel = function(h) return mod.job:cancel(h) end
|
||||
]],
|
||||
["mods/job_worker_mod/jobs/crunch.lua"] = [[
|
||||
local arg = ...
|
||||
local total = 0
|
||||
for i = 1, (arg and arg.n or 0) do total = total + i end
|
||||
return { total = total }
|
||||
]],
|
||||
}
|
||||
|
||||
local OTHER = {
|
||||
["mods/job_other/manifest.json"] = [[{
|
||||
"id": "job_other",
|
||||
"name": "Job Other",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2,
|
||||
"permissions": ["background"]
|
||||
}]],
|
||||
["mods/job_other/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.exports.poll = function(h) return mod.job:poll(h) end
|
||||
mod.exports.cancel = function(h) return mod.job:cancel(h) end
|
||||
]],
|
||||
}
|
||||
|
||||
local UNPERMISSIONED = {
|
||||
["mods/job_probe/manifest.json"] = [[{
|
||||
"id": "job_probe",
|
||||
"name": "Job Probe",
|
||||
"version": "1.0.0",
|
||||
"entry": "main.lua",
|
||||
"api": 2
|
||||
}]],
|
||||
["mods/job_probe/main.lua"] = [[
|
||||
local mod = ...
|
||||
mod.exports.available = mod.job:available()
|
||||
local ok, err = pcall(function() return mod.job:run("jobs/x.lua") end)
|
||||
mod.exports.refused = not ok and tostring(err) or false
|
||||
]],
|
||||
}
|
||||
|
||||
local function merged(...)
|
||||
local out = {}
|
||||
for _, fixture in ipairs({ ... }) do
|
||||
for path, body in pairs(fixture) do out[path] = body end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- ------------------------------------------------- plain-data enforcement
|
||||
-- Nothing but plain data can cross a thread boundary; a function reaching
|
||||
-- LÖVE's serialiser fails deep inside it instead of at the mod's own call.
|
||||
local okData, dataErr = Job.plain({ a = 1, b = "two", c = { d = true } })
|
||||
T.check(okData and okData.c.d == true, "plain data survives the copy")
|
||||
T.check(select(2, Job.plain({ f = function() end })),
|
||||
"a function is refused")
|
||||
T.check(select(2, Job.plain(function() end)), "a bare function is refused")
|
||||
T.check(select(2, Job.plain({ [{}] = 1 })), "a table key is refused")
|
||||
local cycle = {}; cycle.self = cycle
|
||||
T.check(select(2, Job.plain(cycle)), "a cycle is refused, not hung on")
|
||||
local deep = {}
|
||||
local cur = deep
|
||||
for _ = 1, Job.MAX_DEPTH + 4 do cur.next = {}; cur = cur.next end
|
||||
T.check(select(2, Job.plain(deep)), "absurd nesting is refused")
|
||||
T.eq(dataErr, nil, "a clean table reports no error")
|
||||
|
||||
-- the copy is a copy: mutating the source does not reach the copy
|
||||
local src = { n = 1 }
|
||||
local copied = Job.plain(src)
|
||||
src.n = 99
|
||||
T.eq(copied.n, 1, "the payload is snapshotted, not referenced")
|
||||
|
||||
-- ------------------------------------------------------- permissioned use
|
||||
local run = T.sdk.loadMods({ "mods/job_worker_mod", "mods/job_other" },
|
||||
{ fs = T.sdk.memfs(merged(WORKER, OTHER)) })
|
||||
T.eq(#run.errors, 0,
|
||||
"the permissioned mods load clean (" .. tostring(run.errors[1]) .. ")")
|
||||
local api = run.loader.exports.job_worker_mod
|
||||
|
||||
-- The test harness has no love.thread, so run() reports that rather than
|
||||
-- pretending; the gating, path and data rules above it still apply and are
|
||||
-- what this suite exists to pin.
|
||||
local handle, why = api.run("jobs/crunch.lua", { n = 10 })
|
||||
if Job.available() then
|
||||
T.check(handle ~= nil, "run() returns a handle (" .. tostring(why) .. ")")
|
||||
T.eq(type(handle), "table", "the handle is opaque")
|
||||
else
|
||||
T.eq(handle, nil, "run() reports when the host has no threads")
|
||||
T.check(why and why:find("unavailable", 1, true), "and says so plainly")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------ paths cannot climb
|
||||
-- A job script is named inside the mod's own folder, the same rule mod:read
|
||||
-- follows. A job is not a way to name a path.
|
||||
-- These assert the PATH message specifically: "unavailable" is also truthy,
|
||||
-- so a loose check here would pass even with the path rules removed.
|
||||
local _, climbErr = api.run("../../../etc/passwd", {})
|
||||
T.check(climbErr and climbErr:find("inside its root", 1, true),
|
||||
"a climbing path is refused (" .. tostring(climbErr) .. ")")
|
||||
local _, absErr = api.run("/etc/passwd", {})
|
||||
T.check(absErr and absErr:find("inside its root", 1, true),
|
||||
"an absolute path is refused")
|
||||
local _, driveErr = api.run("C:/windows/system32/x.lua", {})
|
||||
T.check(driveErr and driveErr:find("inside its root", 1, true),
|
||||
"a drive-relative path is refused")
|
||||
local _, emptyErr = api.run("", {})
|
||||
T.check(emptyErr and emptyErr:find("script path", 1, true),
|
||||
"an empty path is refused")
|
||||
local _, typeErr = api.run(nil, {})
|
||||
T.check(typeErr and typeErr:find("script path", 1, true),
|
||||
"a non-string path is refused")
|
||||
|
||||
-- a function in the argument is caught at the mod's call, not in LÖVE
|
||||
local _, argErr = api.run("jobs/crunch.lua", { cb = function() end })
|
||||
T.check(argErr and argErr:find("plain data", 1, true),
|
||||
"a non-serialisable argument is refused with a reason")
|
||||
|
||||
-- ------------------------------------------- handles do not cross mods
|
||||
local other = run.loader.exports.job_other
|
||||
T.eq(api.poll({}).status, "error", "a forged handle reads as an error")
|
||||
T.eq(api.poll(1).status, "error", "a guessed id reads as an error")
|
||||
if handle then
|
||||
T.eq(other.poll(handle).status, "error",
|
||||
"another mod cannot poll a handle it does not own")
|
||||
T.eq(other.cancel(handle), false,
|
||||
"another mod cannot cancel a handle it does not own")
|
||||
end
|
||||
|
||||
-- ------------------------------------------------------------- unload
|
||||
local loader = run.loader
|
||||
Job.releaseAll(loader, "job_worker_mod")
|
||||
T.eq(loader.jobs and loader.jobs.job_worker_mod, nil,
|
||||
"unloading a mod drops every job it still held")
|
||||
run.release()
|
||||
|
||||
-- --------------------------------------------- without the permission
|
||||
local probe = T.sdk.loadMods({ "mods/job_probe" },
|
||||
{ fs = T.sdk.memfs(UNPERMISSIONED) })
|
||||
T.eq(#probe.errors, 0,
|
||||
"the unpermissioned mod loads clean (" .. tostring(probe.errors[1]) .. ")")
|
||||
local out = probe.loader.exports.job_probe
|
||||
T.eq(out.available, false, "available() is quietly false without the permission")
|
||||
T.check(out.refused and out.refused:find('"background" permission', 1, true),
|
||||
"run() without the permission names it")
|
||||
probe.release()
|
||||
|
||||
T.finish("mod_job")
|
||||
Reference in New Issue
Block a user