mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 00:02:23 +02:00
204 lines
8.1 KiB
Lua
204 lines
8.1 KiB
Lua
-- 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")
|