Files
gen1recomp/tests/modkit/cases/mod_fetch.lua
T
bryanthaboi 5198b35945 bazinga
2026-08-15 10:44:20 -04:00

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")