All checks were successful
PR Checks / plugin-checks (pull_request) Successful in 14s
R1's identity flow, in the shape the UO bridge already proved: the player asks in game, the plugin mints a one-time code and hands it to them, and the website confirms it back through the sidecar. The site is the author of record; this plugin holds no link at all. **`/link` is chat, not console, and the reply is private** (D21). Both frameworks consume a `/` command rather than broadcasting it and `SendReply` addresses one player, so neither the request nor the code reaches anybody else — a code read off a stream is a code somebody else can spend. **Pending codes live in memory and nowhere else** (D22, matching module-uo): six characters from an alphabet with no O/0/I/1, five-minute TTL, a thirty-second cooldown per player, one outstanding code each, and a purge timer because an unconfirmed code is never looked up. A plugin reload drops them — phase 7b's config editor will do that routinely — and the cost is typing `/link` again, which is cheaper than a credential living in a second process. **The code is deliberately NOT in `account.link.requested`.** That event exists so an operator can see linking being used; the code travels through the player, which is what makes typing it proof they are the one who asked. `/unlink` emits rather than deletes, because there is nothing here to delete. It is the escape hatch that makes the site's refusal to move an already-linked Steam id safe: a player who linked the wrong account walks to a computer they are already sitting at. `link.confirm` consumes a code on the FIRST lookup whether or not it turns out to be expired, so a code cannot be probed twice. `unknown` and `expired` stay separate in the reply — an operator wants to know whether codes are being guessed or going stale — and the website says the same sentence for both. `checkPlugin.js` gains a check for the new surface, because a chat command binds by reflection exactly like a hook and fails the same silent way. It asserts the signature, the void return and the absence of duplicates, and — like the hook parser before it — a test reads the real plugin so a regex that matches nothing cannot pass. Verified by breaking it five ways. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016wDDVXWMDz82WqE1i969r4
240 lines
9.3 KiB
JavaScript
240 lines
9.3 KiB
JavaScript
#!/usr/bin/env node
|
|
//
|
|
// Static checks on the bridge plugin, run on every pull request.
|
|
//
|
|
// This plugin has no unit tests and cannot have any in the ordinary sense: it is
|
|
// deployed as SOURCE and compiled by Oxide or Carbon against game assemblies that
|
|
// exist only on a Rust server. There is no way to build it here, and the nearest
|
|
// thing to a compiler this repository owns is a reader.
|
|
//
|
|
// So these checks ask the questions a compiler would not answer anyway. Both
|
|
// frameworks bind hooks **by name and arity, through reflection**, with no
|
|
// compile-time check and no warning when a name matches nothing — which makes
|
|
// three mistakes silent, and each has a cost bigger than it looks:
|
|
//
|
|
// 1. A hook method the plugin declares but never lists in `ExpectedHooks`.
|
|
// `rg.hooks` is how we answer "does this hook fire on this framework" —
|
|
// the standing answer to Facepunch renaming one and to Carbon's catalogue
|
|
// omitting thirteen names (CARBON.md §6). A hook missing from that list is
|
|
// invisible to the one instrument built to see it.
|
|
//
|
|
// 2. A hook that ANSWERS. Four of the hooks in the read path are documented as
|
|
// "returning a non-null value overrides default behavior" — a bridge that
|
|
// returned something would cancel a death, swallow a player's gathered
|
|
// wood, or refuse a login, on somebody's production server at 3am
|
|
// (PROTOCOL.md §8.7).
|
|
//
|
|
// The rule is inverted on purpose: EVERY hook must be `void`, rather than
|
|
// every *vetoable* hook. A list of vetoable hook names would have to be
|
|
// maintained here, against a catalogue in another repository, and the first
|
|
// hook somebody forgot to add to it would be the one that passed. There is
|
|
// nothing to forget this way — a hook that must genuinely answer is added
|
|
// to `ANSWERS_DELIBERATELY` below, with a reason, as a visible exception.
|
|
//
|
|
// 3. A protocol version that disagrees with `overlay.toml`. The game link has
|
|
// no version handshake (PROTOCOL.md §2), so a half-bumped pair does not
|
|
// refuse — it mis-parses. `overlay.toml` exists precisely so the installer
|
|
// can refuse the pairing BEFORE an operator deploys it, and it is worth
|
|
// exactly as much as its agreement with the code.
|
|
//
|
|
// Dependency-free by design, like every check script in this project: it runs on
|
|
// a bare Node with no install step, which is also how a contributor runs it.
|
|
//
|
|
// node scripts/checkPlugin.js
|
|
|
|
const fs = require('fs')
|
|
const path = require('path')
|
|
|
|
const ROOT = path.resolve(__dirname, '..')
|
|
const PLUGIN = path.join(ROOT, 'overlay', 'oxide', 'plugins', 'RunicGateway.cs')
|
|
const OVERLAY_TOML = path.join(ROOT, 'overlay.toml')
|
|
|
|
/**
|
|
* Hooks this plugin answers on purpose, and why.
|
|
*
|
|
* Empty, and it should stay empty for as long as the plugin is a read path. A
|
|
* name here is a deliberate decision to let the bridge change what the game
|
|
* does — reviewable because it is written down in one place rather than implied
|
|
* by a return type somewhere in 1,500 lines.
|
|
*/
|
|
const ANSWERS_DELIBERATELY = Object.create(null)
|
|
|
|
/** Anything shaped like this is a game hook, by both frameworks' own convention. */
|
|
const HOOK_NAME = /^(?:On|Can)[A-Z]\w*$/
|
|
|
|
/**
|
|
* The parameter list both frameworks bind a chat command on: the caller, the command word, and
|
|
* whatever followed it. Names are the author's; the types are not.
|
|
*/
|
|
const CHAT_SIGNATURE = /^BasePlayer\s+\w+,\s*string\s+\w+,\s*string\[\]\s+\w+$/
|
|
|
|
/**
|
|
* Method declarations, as this file cares about them: the return type and the
|
|
* name. Deliberately narrow — it matches the plugin's own single style
|
|
* (`private [static] <type> <Name>(`) rather than trying to parse C#. A method
|
|
* written some other way is not matched, which would let a hook through, so the
|
|
* shape is asserted by the self-test rather than assumed.
|
|
*/
|
|
const METHOD = /^\s*(?:private|public|protected|internal)\s+(?:static\s+)?([\w.<>[\],\s]+?)\s+(\w+)\s*\(/gm
|
|
|
|
function readExpectedHooks(source) {
|
|
const block = /ExpectedHooks\s*=\s*\{([\s\S]*?)\}\s*;/.exec(source)
|
|
if (!block) return null
|
|
|
|
return block[1]
|
|
.split(',')
|
|
.map((entry) => /"([^"]+)"/.exec(entry))
|
|
.filter(Boolean)
|
|
.map((m) => m[1])
|
|
}
|
|
|
|
/**
|
|
* Every `[ChatCommand("x")]` and the signature of the method under it.
|
|
*
|
|
* **A chat command binds by reflection, exactly like a hook**, and fails the same silent way: a
|
|
* method with the wrong parameter list is never called and neither framework says a word. The
|
|
* player types `/link`, nothing happens, and there is no log line to find. So the signature is
|
|
* checked here rather than discovered on a rig.
|
|
*/
|
|
function readChatCommands(source) {
|
|
const found = []
|
|
const attribute = /\[ChatCommand\("([^"]+)"\)\]\s*(?:\/\/[^\n]*\n\s*)*(?:private|public|protected|internal)\s+(?:static\s+)?([\w.<>[\],\s]+?)\s+(\w+)\s*\(([^)]*)\)/g
|
|
|
|
let match
|
|
while ((match = attribute.exec(source)) !== null) {
|
|
found.push({
|
|
command: match[1],
|
|
returns: match[2].trim(),
|
|
name: match[3],
|
|
params: match[4].replace(/\s+/g, ' ').trim(),
|
|
})
|
|
}
|
|
|
|
return found
|
|
}
|
|
|
|
function readMethods(source) {
|
|
const found = []
|
|
let m
|
|
METHOD.lastIndex = 0
|
|
while ((m = METHOD.exec(source)) !== null) {
|
|
found.push({ returns: m[1].trim(), name: m[2] })
|
|
}
|
|
return found
|
|
}
|
|
|
|
function check(source, toml) {
|
|
const problems = []
|
|
|
|
const expected = readExpectedHooks(source)
|
|
if (!expected) {
|
|
return ['could not find the ExpectedHooks array in the plugin source']
|
|
}
|
|
|
|
const methods = readMethods(source)
|
|
const hooks = methods.filter((x) => HOOK_NAME.test(x.name))
|
|
const hookNames = new Set(hooks.map((x) => x.name))
|
|
|
|
// 1. Every hook the plugin implements is one `rg.hooks` can report on.
|
|
for (const hook of hooks) {
|
|
if (!expected.includes(hook.name)) {
|
|
problems.push(
|
|
`${hook.name} is implemented but missing from ExpectedHooks, so rg.hooks cannot report it`
|
|
)
|
|
}
|
|
}
|
|
|
|
// 2. Every hook is void, unless answering is a decision somebody wrote down.
|
|
for (const hook of hooks) {
|
|
if (hook.returns === 'void') continue
|
|
if (hook.name in ANSWERS_DELIBERATELY) continue
|
|
|
|
problems.push(
|
|
`${hook.name} returns ${hook.returns}, not void — a read-path hook must not be able to ` +
|
|
'veto what the game was going to do (PROTOCOL.md §8.7). If it must answer, add it to ' +
|
|
'ANSWERS_DELIBERATELY with a reason.'
|
|
)
|
|
}
|
|
|
|
// 3. No phantom entries: a name listed but never implemented reports "silent"
|
|
// for ever, which reads exactly like a hook the framework does not fire.
|
|
for (const name of expected) {
|
|
if (!hookNames.has(name)) {
|
|
problems.push(`ExpectedHooks lists ${name}, but no method of that name is implemented`)
|
|
}
|
|
}
|
|
|
|
// 4. Every chat command is bound by reflection and must have the signature the
|
|
// frameworks call. A wrong one is never invoked and says nothing.
|
|
const chat = readChatCommands(source)
|
|
|
|
for (const cmd of chat) {
|
|
if (cmd.returns !== 'void') {
|
|
problems.push(
|
|
`/${cmd.command} (${cmd.name}) returns ${cmd.returns}, not void — a chat command's return ` +
|
|
'value is not read, and a non-void signature is the shape that silently binds nothing.'
|
|
)
|
|
}
|
|
|
|
if (!CHAT_SIGNATURE.test(cmd.params)) {
|
|
problems.push(
|
|
`/${cmd.command} (${cmd.name}) takes (${cmd.params}), not (BasePlayer, string, string[]). ` +
|
|
'Both frameworks bind a chat command by reflection on that exact signature, so this one ' +
|
|
'would never be called and neither would log it — the player types the command and ' +
|
|
'nothing happens at all.'
|
|
)
|
|
}
|
|
}
|
|
|
|
// Two commands answering to one name is one of them never running, and which one is
|
|
// whichever the framework happened to register second.
|
|
const seen = new Set()
|
|
for (const cmd of chat) {
|
|
if (seen.has(cmd.command)) {
|
|
problems.push(`/${cmd.command} is declared twice; only one of them can ever be bound`)
|
|
}
|
|
seen.add(cmd.command)
|
|
}
|
|
|
|
// 5. The two declaration sites this repository owns must agree.
|
|
const inCode = /ProtocolVersion\s*=\s*(\d+)\s*;/.exec(source)
|
|
const inToml = /^\s*protocol\s*=\s*(\d+)\s*$/m.exec(toml)
|
|
|
|
if (!inCode) problems.push('could not read ProtocolVersion from the plugin source')
|
|
if (!inToml) problems.push('could not read `protocol` from overlay.toml')
|
|
|
|
if (inCode && inToml && inCode[1] !== inToml[1]) {
|
|
problems.push(
|
|
`the plugin speaks protocol ${inCode[1]} and overlay.toml declares ${inToml[1]}. ` +
|
|
'The installer refuses to pair a sidecar and an overlay that disagree, so a bundle built ' +
|
|
'from this would not compose — and the game link itself has no version check to catch it.'
|
|
)
|
|
}
|
|
|
|
return problems
|
|
}
|
|
|
|
function main() {
|
|
const source = fs.readFileSync(PLUGIN, 'utf8')
|
|
const toml = fs.readFileSync(OVERLAY_TOML, 'utf8')
|
|
|
|
const problems = check(source, toml)
|
|
|
|
if (problems.length > 0) {
|
|
console.error('The bridge plugin failed its static checks:\n')
|
|
for (const p of problems) console.error(` • ${p}`)
|
|
console.error('')
|
|
process.exit(1)
|
|
}
|
|
|
|
const expected = readExpectedHooks(source)
|
|
const version = /ProtocolVersion\s*=\s*(\d+)\s*;/.exec(source)[1]
|
|
console.log(
|
|
`plugin ok — protocol ${version}, ${expected.length} hooks declared, every one void and listed`
|
|
)
|
|
}
|
|
|
|
module.exports = { check, readExpectedHooks, readMethods, readChatCommands, HOOK_NAME }
|
|
|
|
if (require.main === module) main()
|