diff --git a/docs/modding.md b/docs/modding.md index b369f504..4856b6c1 100644 --- a/docs/modding.md +++ b/docs/modding.md @@ -90,6 +90,7 @@ Every mod contains a root `manifest.json` defining its metadata, supported games | `optional_imports` | `array` | User-supplied files that unlock optional mod functionality. They use the same validation and private-copy flow but never block the mod from loading. | | `conflicts` / `incompatible` | `array` | List of mod IDs that cannot run concurrently with this mod. | | `permissions` | `array` | Requested privileges (e.g. `["engine_internals"]`, `["network"]`, `["filesystem"]`). | +| `log_url` | `string` | Optional https URL for `mod.postLog` log reporting (api 2; requires the `network` permission). | | `github` | `string` | GitHub repository (`"owner/repo"`) used for update checks and dependency download links. | ### Declaring Dependencies & Scoping @@ -864,6 +865,39 @@ with a full standard library that the sandbox cannot reach, so handing one to a mod would undo every other rule; `mod.fetch`'s workers run engine code, so a mod gets asynchrony without gaining any new reach. +## Log reporting + +`mod.postLog(body, opts)` is the one-way exception to the rule that a mod +decides where it talks. It reports a debug/crash log to the https URL the +manifest declares in `log_url`, and it is the only API that may not be +pointed at a caller-chosen address: + +```json +{ + "permissions": ["network"], + "log_url": "https://logs.example.com/receive" +} +``` + +The URL is validated at load: it must be `https://`, and declaring it +without the `network` permission is a load violation for api 2 mods. The +destination is reviewed when the mod ships, not chosen per call, so a mod +cannot aim this at arbitrary hosts or read back anything a server replies. + +```lua +-- fire and forget; poll() never blocks, same shape as mod.fetch +local job = mod:postLog("session crashed at 0x1f3a\n" .. logText) +``` + +`postLog(body, opts)` returns the same opaque handle as `mod.fetch:get`, +polled and released through `mod.fetch:poll` / `mod.fetch:release`. `opts` +is a closed list with one switch: `format`, either `"text"` (the default) +or `"json"`. `json` wraps the body in an envelope of `{ ts, mod, format, +body }` so a server can attribute and sort reports; any other key or value +is refused before a job is submitted. The body is capped at 64 KB, the +transfer is bounded by the same worker ceilings as `mod.fetch`, and the +response body is never returned to the mod. + ## Background jobs `mod.fetch` covers work waiting on a server. `mod.job` covers work waiting on diff --git a/src/core/HostShell.lua b/src/core/HostShell.lua index ced12063..7f4a28ed 100644 --- a/src/core/HostShell.lua +++ b/src/core/HostShell.lua @@ -417,4 +417,56 @@ function HostShell.httpGet(url, userAgent, accept, maxTime) return body end +-- POST returning success/failure. Strictly one-way: the response body is +-- discarded, only the HTTP status class is surfaced (postLog callers never +-- trust the reply). curl --data-binary reads the payload from a pipe, so a +-- large body never lands in the command line; the Android bridge has no POST +-- transport, and httpPost reports that instead of half-working through +-- httpDownload (a GET round-trip to a POST endpoint would be a lie). +function HostShell.httpPost(url, body, contentType, userAgent, maxTime) + if type(url) ~= "string" or url == "" then return nil, "missing url" end + if type(body) ~= "string" then return nil, "missing body" end + userAgent = userAgent or "gen1recomp" + if HostShell.haveCurl() then + -- --data-binary @- keeps the payload out of argv (command-line length + -- limits on Windows) and preserves every byte including trailing + -- newlines. No -f, matching httpGet: the response body is discarded + -- anyway, and curl's stderr carries the real diagnosis on failure. + local cmd = ("curl -sSL --proto =http,https --proto-redir =http,https " + .. "--connect-timeout 10 --max-time %d ") + :format(tonumber(maxTime) or 40) + .. "-X POST " + .. "-H " .. HostShell.quote("User-Agent: " .. userAgent) .. " " + if contentType then + cmd = cmd .. "-H " .. HostShell.quote("Content-Type: " .. contentType) .. " " + end + cmd = cmd .. "-H " .. HostShell.quote("Content-Length: " .. tostring(#body)) .. " " + .. "--data-binary @- " + .. "-w " .. HostShell.quote(HTTP_MARK_FMT) .. " " + .. HostShell.quote(url) .. " 2>&1" + local pipe = HostShell.popen(cmd, "rw") + if not pipe then return nil, "could not run curl" end + local writeOk, werr = pcall(pipe.write, pipe, body) + if not writeOk then + HostShell.pclose(pipe) + return nil, "could not write body: " .. tostring(werr) + end + local readOk, out = pcall(function() return pipe:read("*a") end) + HostShell.pclose(pipe) + if not readOk then + return nil, fetchError(url, nil, tostring(out)) + end + local _, status, noise = splitCurlOutput(out) + if not status then return nil, fetchError(url, nil, noise) end + if status < 200 or status >= 300 then + return nil, fetchError(url, status, "log post rejected") + end + return true + end + if not haveBridge() then + return nil, "no network transport on this platform" + end + return nil, "no POST transport on this platform" +end + return HostShell diff --git a/src/mods/Loader.lua b/src/mods/Loader.lua index d01ec45e..79fb87c5 100644 --- a/src/mods/Loader.lua +++ b/src/mods/Loader.lua @@ -1094,6 +1094,23 @@ function Loader:_api(mod) return { available = function() return false end, get = refuse, poll = refuse, release = refuse, cancel = refuse } end)(), + -- One-way crash-log reporting to the https URL the manifest declares in + -- log_url. The destination is reviewed at load, not chosen per call, so + -- a mod cannot aim this at arbitrary hosts; the response body is never + -- returned, and the worker pool bounds the transfer. Same handle/poll/ + -- release shape as mod.fetch, so mod.job's sibling patterns carry over. + postLog = (function() + if mod.manifest.permissionSet.network and mod.manifest.log_url then + return function(_, body, opts) + return Net.postLog(loader, modId, mod.manifest.log_url, body, opts) + end + end + local function refuse() + error(('[%s] mod.postLog needs the "network" permission and a ' + .. "log_url in manifest.json"):format(modId), 2) + end + return refuse + end)(), -- Background compute, behind the "background" permission. The worker -- rebuilds this mod's sandbox before loading the script, so a job is the -- one thing love.thread is not: off the main thread without a Lua state diff --git a/src/mods/Manifest.lua b/src/mods/Manifest.lua index ab53b6be..6893ff87 100644 --- a/src/mods/Manifest.lua +++ b/src/mods/Manifest.lua @@ -308,6 +308,24 @@ function Manifest.validate(raw, path) local github = Manifest.parseGithub(raw.github) + -- log_url: the mod's one-way crash-log reporting destination. https-only, + -- declared in the manifest so the engine reviews the target at load instead + -- of trusting per-call URLs from gameplay code, and gated on the `network` + -- permission the mod must also declare. api 1 mods never carry it: it is a + -- load violation, not a warning, because a postLog-capable mod that does not + -- opt in to networking is a bug in the manifest itself. + local logUrl = nil + if raw.log_url ~= nil then + if strict and not permissionSet.network then + violation(strict, raw.id, "log_url requires the network permission") + elseif strict and (type(raw.log_url) ~= "string" + or not raw.log_url:match("^https://")) then + violation(strict, raw.id, "log_url must be an https:// URL") + elseif strict then + logUrl = raw.log_url + end + end + assert(raw.experimental == nil or type(raw.experimental) == "boolean", "experimental must be a boolean") local experimental = raw.experimental == true @@ -414,6 +432,7 @@ function Manifest.validate(raw, path) affects_link = affectsLink, permissions = permissions, permissionSet = permissionSet, + log_url = logUrl, options_schema = optionalFile(raw.options_schema, "options_schema"), assets_transforms = optionalFile(raw.assets_transforms, "assets_transforms"), required_imports = requiredImports, diff --git a/src/mods/Net.lua b/src/mods/Net.lua index d691c7c2..c54f0a68 100644 --- a/src/mods/Net.lua +++ b/src/mods/Net.lua @@ -32,6 +32,9 @@ local Net = {} Net.MAX_INFLIGHT = 4 -- Clamp on the caller's timeout, so a mod cannot pin a worker indefinitely. Net.MAX_SECONDS = 30 +-- A log body ceiling. Debug logs are kilobytes, and a server operator has no +-- reason to accept a mod uploading arbitrary megabytes to its endpoint. +Net.MAX_BODY = 65536 local function fetch() return require("src.net.Fetch") @@ -99,6 +102,62 @@ function Net.get(loader, modId, url, opts) return handle end +-- The closed list of postLog format switches. Anything outside it is a +-- caller bug, rejected before a job is submitted, so the surface stays +-- exactly two shapes on the wire. +local POST_FORMATS = { text = true, json = true } + +-- A one-way log POST to the mod's manifest-declared log_url (https only, +-- validated in Manifest.lua). Same shape as get(): opaque handle, per-mod +-- in-flight ceiling, user agent naming the mod. The response body is never +-- returned -- a postLog is fire-and-forget reporting, and the engine has no +-- reason to hand a mod a server's reply. +function Net.postLog(loader, modId, logUrl, body, opts) + if type(body) ~= "string" or body == "" then + return nil, "log body must be a non-empty string" + end + if #body > Net.MAX_BODY then + return nil, ("log body too large (%d bytes, limit %d)"):format(#body, Net.MAX_BODY) + end + opts = type(opts) == "table" and opts or {} + for key in pairs(opts) do + if key ~= "format" then + return nil, ("unknown log option %q (format is the only switch)"):format(tostring(key)) + end + end + local format = opts.format or "text" + if not POST_FORMATS[format] then + return nil, ("unknown log format %q (text and json only)"):format(tostring(format)) + end + local denial = Net.urlDenial(logUrl) + if denial then return nil, denial end + local b = bucket(loader, modId) + if inflight(b) >= Net.MAX_INFLIGHT then + return nil, ("too many requests in flight (limit %d); poll and release " + .. "the ones you have"):format(Net.MAX_INFLIGHT) + end + local payload = body + local contentType = "text/plain" + if format == "json" then + local Json = require("src.link.Json") + payload = Json.encode({ + ts = os.time(), + mod = modId, + format = "json", + body = body, + }) + contentType = "application/json" + end + local id = fetch().post(logUrl, payload, { + userAgent = "gen1recomp-mod/" .. tostring(modId), + contentType = contentType, + maxSeconds = Net.MAX_SECONDS, + }) + local handle = {} + b[handle] = id + return handle +end + -- A copy of the job's state, never the engine's own table. An unknown or -- forged handle reads as an error rather than nil, so a mod that lost track of -- one cannot spin waiting on it forever. diff --git a/src/net/Fetch.lua b/src/net/Fetch.lua index 370ad1a4..3e7d961a 100644 --- a/src/net/Fetch.lua +++ b/src/net/Fetch.lua @@ -132,6 +132,17 @@ function Fetch.get(url, opts) accept = opts.accept, maxSeconds = opts.maxSeconds }) end +-- POST a body to a URL, one-way. The result carries no body: postLog +-- reporting never trusts a server's reply, so the worker surfaces only +-- ok/error and the transport's complaint. +-- opts: { userAgent, contentType, maxSeconds } +function Fetch.post(url, body, opts) + opts = opts or {} + return submit({ kind = "post", url = url, body = body, + userAgent = opts.userAgent or "gen1recomp", + contentType = opts.contentType, maxSeconds = opts.maxSeconds }) +end + -- Download a URL to `saveRel`, a path relative to the LOVE save directory. -- Progress is reported as a 0..1 fraction when `size` is known. function Fetch.download(url, saveRel, opts) diff --git a/src/net/fetch_worker.lua b/src/net/fetch_worker.lua index f86d78f1..f386d7f8 100644 --- a/src/net/fetch_worker.lua +++ b/src/net/fetch_worker.lua @@ -99,6 +99,20 @@ local function doDownload(job) post({ id = job.id, ok = true, path = rel, done = true }) end +local function doPost(job) + if not HostShell then + post({ id = job.id, ok = false, err = "no transport" }) + return + end + local ok, err = HostShell.httpPost(job.url, job.body, job.contentType, + job.userAgent, tonumber(job.maxSeconds) or GET_MAX_SECONDS) + if not ok then + post({ id = job.id, ok = false, err = err or "post failed" }) + return + end + post({ id = job.id, ok = true, done = true }) +end + while true do local job = cmdCh:demand() -- The flag is checked before the job's KIND, so a worker woken by a @@ -114,6 +128,9 @@ while true do elseif job.kind == "get" then local ok, err = pcall(doGet, job) if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end + elseif job.kind == "post" then + local ok, err = pcall(doPost, job) + if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end elseif job.kind == "download" then local ok, err = pcall(doDownload, job) if not ok then post({ id = job.id, ok = false, err = tostring(err) }) end diff --git a/tests/modkit/cases/mod_postlog.lua b/tests/modkit/cases/mod_postlog.lua new file mode 100644 index 00000000..a1089894 --- /dev/null +++ b/tests/modkit/cases/mod_postlog.lua @@ -0,0 +1,169 @@ +-- mod.postLog: one-way log reporting to the manifest-declared log_url. +-- The things this pins are the strict ones -- https-only destination that +-- lives in the manifest (not per-call), a closed list of format switches, +-- a body ceiling, opaque per-mod handles, and refusal without the network +-- permission. + +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.post = function(url, body, opts) + nextId = nextId + 1 + submitted[nextId] = { url = url, body = body, 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 LOGGER = { + ["mods/log_sender/manifest.json"] = [[{ + "id": "log_sender", + "name": "Log Sender", + "version": "1.0.0", + "entry": "main.lua", + "api": 2, + "permissions": ["network"], + "log_url": "https://logs.example.com/logs" + }]], + ["mods/log_sender/main.lua"] = [[ + local mod = ... + mod.exports.send = function(body, opts) + local handle, err = mod:postLog(body, opts) + if not handle then return nil, err end + return handle + end + mod.exports.poll = function(h) return mod.fetch:poll(h) end + mod.exports.release = function(h) return mod.fetch:release(h) end + ]], +} + +local function manifest(id, extra) + return ('{"id": "%s", "name": "T", "version": "1.0.0", "entry": "main.lua", ' + .. '"api": 2%s}'):format(id, extra or "") +end + +local NO_URL = { + ["mods/log_no_url/manifest.json"] = manifest("log_no_url", ', "permissions": ["network"]'), + ["mods/log_no_url/main.lua"] = [[ + local mod = ... + mod.exports.try = function() + local ok, err = pcall(function() return mod:postLog("body") end) + return ok, err + end + ]], +} + +-- ------------------------------------------------ the closed opts list +local run = T.sdk.loadMods({ "mods/log_sender" }, { fs = T.sdk.memfs(LOGGER) }) +T.eq(#run.errors, 0, "the logger mod loads clean (" .. tostring(run.errors[1]) .. ")") +local api = run.loader.exports.log_sender + +local bad, badErr = api.send("body", { format = "xml" }) +T.eq(bad, nil, "an unknown format is refused") +T.check(badErr and badErr:find("text and json only", 1, true), "and names the allowed ones") + +local badKey, keyErr = api.send("body", { envelope = true }) +T.eq(badKey, nil, "an unknown opt key is refused") +T.check(keyErr and keyErr:find("format is the only switch", 1, true), "and says so") + +-- ------------------------------------------------ body validation +local empty, emptyErr = api.send("") +T.eq(empty, nil, "an empty body is refused") +local big = string.rep("x", Net.MAX_BODY + 1) +local bigH, bigErr = api.send(big) +T.eq(bigH, nil, "an oversized body is refused") +T.check(bigErr and bigErr:find("limit", 1, true), "and names the limit") + +-- ------------------------------------------------- text format (default) +local handle, err = api.send("hello log") +T.check(handle ~= nil, "a plain text post 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 post polls as pending") + +local sent +for _, job in pairs(submitted) do + if job.url == "https://logs.example.com/logs" and job.body == "hello log" then sent = job end +end +T.check(sent ~= nil, "the post reached the pool with the manifest URL") +T.eq(sent.opts.contentType, "text/plain", "plain text posts as text/plain") +T.check(sent.opts.userAgent:find("log_sender", 1, true), + "the request identifies the calling mod") + +-- -------------------------------------------------- json format +local jh, jerr = api.send("line one", { format = "json" }) +T.check(jh ~= nil, "a json post returns a handle (" .. tostring(jerr) .. ")") +local jsent +for _, job in pairs(submitted) do + if job.opts.contentType == "application/json" then jsent = job end +end +T.check(jsent ~= nil, "json posts as application/json") +local decoded = require("src.link.Json").decode(jsent.body) +T.eq(type(decoded), "table", "the json body is a table") +T.eq(decoded.format, "json", "the envelope names its format") +T.eq(decoded.mod, "log_sender", "the envelope names the mod") +T.eq(decoded.body, "line one", "the payload survives the envelope") + +-- completion flows through poll, like get +states[1] = { status = "ok", progress = 1 } +local got = api.poll(handle) +T.eq(got.status, "ok", "a completed post polls ok") + +api.release(handle) +run.release() + +-- --------------------------------------- manifest without log_url refuses +local nurl = T.sdk.loadMods({ "mods/log_no_url" }, { fs = T.sdk.memfs(NO_URL) }) +T.eq(#nurl.errors, 0, "no log_url loads clean (" .. tostring(nurl.errors[1]) .. ")") +local okCall, callErr = nurl.loader.exports.log_no_url.try() +T.check(not okCall and callErr:find("log_url", 1, true), + "postLog without log_url names the missing manifest field") +nurl.release() + +-- ------------------------------------------- manifest validation: the gate +-- log_url without the network permission is a load violation in a strict +-- manifest: the mod declares a network capability it did not opt in to. The +-- violation fires inside manifest validation, so the mod never enters +-- loader.mods at all. +local badManifest = T.sdk.loadMods({ "mods/log_bad" }, { fs = T.sdk.memfs({ + ["mods/log_bad/manifest.json"] = manifest("log_bad", + ', "log_url": "https://logs.example.com/logs"'), + ["mods/log_bad/main.lua"] = "local mod = ...", +}) }) +T.eq(badManifest.mods.log_bad, nil, + "log_url without network: the mod is refused before load") + +-- a non-https log_url is refused even with the permission +local httpManifest = T.sdk.loadMods({ "mods/log_http" }, { fs = T.sdk.memfs({ + ["mods/log_http/manifest.json"] = manifest("log_http", + ', "permissions": ["network"], "log_url": "http://logs.example.com/logs"'), + ["mods/log_http/main.lua"] = "local mod = ...", +}) }) +T.eq(httpManifest.mods.log_http, nil, + "an http log_url: the mod is refused before load") + +-- an api 1 manifest carries no strict surface: log_url is ignored, and the +-- mod loads (its postLog call still refuses -- there is no log_url to use) +local api1 = T.sdk.loadMods({ "mods/log_api1" }, { fs = T.sdk.memfs({ + ["mods/log_api1/manifest.json"] = [[{ + "id": "log_api1", "name": "T", "version": "1.0.0", "entry": "main.lua", + "api": 1, "log_url": "https://logs.example.com/logs" + }]], + ["mods/log_api1/main.lua"] = "local mod = ...", +}) }) +T.eq(#api1.errors, 0, "an api 1 manifest ignores log_url (" + .. tostring(api1.errors[1]) .. ")") +T.check(api1.loader.mods.log_api1 ~= nil, "and the mod loads") + +T.finish("mod_postlog")