mirror of
https://github.com/bryanthaboi/gen1recomp.git
synced 2026-08-16 00:02:23 +02:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d87f6b8ad1 | |||
| 24cf367758 | |||
| 829d398a94 | |||
| 3ee50a27c5 | |||
| 871087a16b | |||
| 180ce6b2e7 | |||
| cf335f67de | |||
| b8ec4fe6b5 |
@@ -262,7 +262,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- name: Setup .NET 8
|
||||
uses: actions/setup-dotnet@v4
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: "8.0.x"
|
||||
- name: Publish gen1tls (win-x64 Native AOT)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -12,48 +12,6 @@
|
||||
"tintColor": "3b5ca8",
|
||||
"category": "games",
|
||||
"versions": [
|
||||
{
|
||||
"version": "0.1.92",
|
||||
"date": "2026-08-15",
|
||||
"size": 11364274,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.92/gen1recomp++-0.1.92-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.91",
|
||||
"date": "2026-08-15",
|
||||
"size": 11351477,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.91/gen1recomp++-0.1.91-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.90",
|
||||
"date": "2026-08-15",
|
||||
"size": 11344338,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.90/gen1recomp++-0.1.90-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @1Jamie\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.89",
|
||||
"date": "2026-08-15",
|
||||
"size": 11343841,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.89/gen1recomp++-0.1.89-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @1Jamie\n- @anxiousintrovert\n- @AverageConsumer\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.88",
|
||||
"date": "2026-08-14",
|
||||
"size": 11311237,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.88/gen1recomp++-0.1.88-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @bryanthaboi"
|
||||
},
|
||||
{
|
||||
"version": "0.1.87",
|
||||
"date": "2026-08-14",
|
||||
"size": 11310910,
|
||||
"downloadURL": "https://github.com/bryanthaboi/gen1recomp/releases/download/v0.1.87/gen1recomp++-0.1.87-ios.ipa",
|
||||
"localizedDescription": "Download the correct version for your computer below.\n\n## Contributors\n\n- @AverageConsumer\n- @bryanthaboi\n- @mleo2003\n- @ShaneMcGovernIE\n- MaxTomahawk"
|
||||
},
|
||||
{
|
||||
"version": "0.1.86",
|
||||
"date": "2026-08-14",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
/Users/bryanbassett/Documents/development/pokemon-gen1-recomp-project/.bazinga/mods/timekeepers_hut
|
||||
@@ -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
|
||||
|
||||
@@ -215,12 +215,19 @@ function LauncherMods.checkDependencies(manifest, options, version, installedMan
|
||||
return m and not m.experimental
|
||||
end
|
||||
|
||||
local function conflictApplies(spec, other)
|
||||
return not spec.range or (other and other.version
|
||||
and Semver.satisfies(other.version, spec.range))
|
||||
end
|
||||
|
||||
-- (a) Conflicts declared by target manifest
|
||||
if type(manifest.conflictSpecs) == "table" then
|
||||
for _, spec in ipairs(manifest.conflictSpecs) do
|
||||
local conflictId = spec.id
|
||||
local installedOther = installedMap[conflictId]
|
||||
if installedOther and isEnabled(conflictId) and not conflictIdsSeen[conflictId] then
|
||||
if installedOther and isEnabled(conflictId)
|
||||
and conflictApplies(spec, installedOther)
|
||||
and not conflictIdsSeen[conflictId] then
|
||||
conflictIdsSeen[conflictId] = true
|
||||
hasIssues = true
|
||||
depsResult[#depsResult + 1] = {
|
||||
@@ -238,11 +245,12 @@ function LauncherMods.checkDependencies(manifest, options, version, installedMan
|
||||
|
||||
-- (b) Reverse conflicts declared by installed mods against target manifest
|
||||
if manifest.id then
|
||||
local installedTarget = installedMap[manifest.id] or manifest
|
||||
for _, other in ipairs(manifests) do
|
||||
if other.id ~= manifest.id and isEnabled(other.id) and not conflictIdsSeen[other.id] then
|
||||
local conflicts = other.conflictSpecs or {}
|
||||
for _, spec in ipairs(conflicts) do
|
||||
if spec.id == manifest.id then
|
||||
if spec.id == manifest.id and conflictApplies(spec, installedTarget) then
|
||||
conflictIdsSeen[other.id] = true
|
||||
hasIssues = true
|
||||
depsResult[#depsResult + 1] = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -550,6 +550,51 @@ local installedColorlib = Manifest.validate({
|
||||
version = "1.0.0",
|
||||
entry = "main.lua",
|
||||
}, "mods/colorlib")
|
||||
local unconditionalConflict = LauncherMods.checkDependencies(testTargetManifest,
|
||||
nil, nil, { testTargetManifest, installedColorlib })
|
||||
check(unconditionalConflict.hasIssues == true,
|
||||
"dependency resolver reports an unversioned conflict")
|
||||
|
||||
local function rangeTarget(version, conflicts)
|
||||
return Manifest.validate({
|
||||
id = "range_target",
|
||||
name = "Range Target",
|
||||
version = version,
|
||||
entry = "main.lua",
|
||||
conflicts = conflicts or {},
|
||||
}, "mods/range_target")
|
||||
end
|
||||
local function rangeSource(conflicts)
|
||||
return Manifest.validate({
|
||||
id = "range_source",
|
||||
name = "Range Source",
|
||||
version = "1.0.0",
|
||||
entry = "main.lua",
|
||||
conflicts = conflicts or {},
|
||||
}, "mods/range_source")
|
||||
end
|
||||
|
||||
local forwardSource = rangeSource({ "range_target@<2.0.0" })
|
||||
local matchingTarget = rangeTarget("1.4.0")
|
||||
local nonmatchingTarget = rangeTarget("2.0.0")
|
||||
local forwardMatching = LauncherMods.checkDependencies(forwardSource,
|
||||
nil, nil, { forwardSource, matchingTarget })
|
||||
check(forwardMatching.hasIssues == true and #forwardMatching.deps == 1,
|
||||
"dependency resolver applies a matching forward conflict range")
|
||||
local forwardNonmatching = LauncherMods.checkDependencies(forwardSource,
|
||||
nil, nil, { forwardSource, nonmatchingTarget })
|
||||
check(forwardNonmatching.hasIssues == false and #forwardNonmatching.deps == 0,
|
||||
"dependency resolver ignores a nonmatching forward conflict range")
|
||||
|
||||
local reverseSource = rangeSource({ "range_target@<2.0.0" })
|
||||
local reverseMatching = LauncherMods.checkDependencies(matchingTarget,
|
||||
nil, nil, { reverseSource, matchingTarget })
|
||||
check(reverseMatching.hasIssues == true and #reverseMatching.deps == 1,
|
||||
"dependency resolver applies a matching reverse conflict range")
|
||||
local reverseNonmatching = LauncherMods.checkDependencies(nonmatchingTarget,
|
||||
nil, nil, { reverseSource, nonmatchingTarget })
|
||||
check(reverseNonmatching.hasIssues == false and #reverseNonmatching.deps == 0,
|
||||
"dependency resolver ignores a nonmatching reverse conflict range")
|
||||
-- ------- scoped dependency tests
|
||||
local Json = require("src.link.Json")
|
||||
local scopedDepManifest = Manifest.validate({
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user