summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTor Andersson <tor@ccxvii.net>2025-04-24 22:08:53 +0200
committerTor Andersson <tor@ccxvii.net>2025-04-25 16:06:05 +0200
commite0b1a9b67b3430402f9fdccc4d0cc757ac085d2b (patch)
tree69d7796b0ba56b5deacaf1b247cda15ae0fe149f
parentb431c667423dc9f3717610170b65d6193a66f614 (diff)
downloadserver-e0b1a9b67b3430402f9fdccc4d0cc757ac085d2b.tar.gz
Add simplified fuzzer tool.
Thanks to Mischa for writing the original RTT fuzzer!
-rw-r--r--.gitignore4
-rw-r--r--package.json3
-rwxr-xr-xtools/fuzz.sh16
-rwxr-xr-xtools/rtt-fuzz.js204
4 files changed, 227 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
index f4518cb..f288f6b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,3 +15,7 @@ db-wal
node_modules
package-lock.json
tags
+
+# Fuzzer work files
+coverage
+fuzzer
diff --git a/package.json b/package.json
index aa1885a..c752c02 100644
--- a/package.json
+++ b/package.json
@@ -11,5 +11,8 @@
"pug": "^3.0.2",
"utf-8-validate": "^6.0.3",
"ws": "^8.16.0"
+ },
+ "devDependencies": {
+ "@jazzer.js/core": "^2.1.0"
}
}
diff --git a/tools/fuzz.sh b/tools/fuzz.sh
new file mode 100755
index 0000000..fd2391b
--- /dev/null
+++ b/tools/fuzz.sh
@@ -0,0 +1,16 @@
+#!/bin/bash
+
+TITLE=$1
+shift
+
+if [ ! -f ./public/$TITLE/rules.js ]
+then
+ echo usage: bash tools/fuzz.sh title_id
+ exit 1
+fi
+
+mkdir -p fuzzer/corpus-$TITLE
+
+RULES=../public/$TITLE/rules.js \
+ npx jazzer tools/rtt-fuzz.js --sync fuzzer/corpus-$TITLE "$@" -- -exact_artifact_path=/dev/null | \
+ tee fuzzer/log-$TITLE.txt
diff --git a/tools/rtt-fuzz.js b/tools/rtt-fuzz.js
new file mode 100755
index 0000000..0690697
--- /dev/null
+++ b/tools/rtt-fuzz.js
@@ -0,0 +1,204 @@
+"use strict"
+
+const crypto = require('crypto')
+const fs = require("fs")
+const path = require("path")
+const { FuzzedDataProvider } = require("@jazzer.js/core")
+
+const RULES_JS_FILE = process.env.RULES || "rules.js"
+const MAX_ERRORS = parseInt(process.env.MAX_ERRORS || 100)
+const MAX_STEPS = parseInt(process.env.MAX_STEPS || 10000)
+const TIMEOUT = parseInt(process.env.TIMEOUT || 250)
+
+console.log(`Loading fuzzer ${RULES_JS_FILE}`)
+
+const rules = require(RULES_JS_FILE)
+const title_id = path.basename(path.dirname(RULES_JS_FILE))
+
+var error_count = 0
+
+globalThis.RTT_FUZZER = true
+
+exports.fuzz = function (fuzzerInputData) {
+ var data = new FuzzedDataProvider(fuzzerInputData)
+ if (data.remainingBytes < 16) {
+ // insufficient bytes to start
+ return
+ }
+
+ var scenarios = Array.isArray(rules.scenarios) ? rules.scenarios : Object.values(rules.scenarios).flat()
+ var scenario = data.pickValue(scenarios)
+ if (scenario.startsWith("Random"))
+ return
+
+ var timeout = Date.now() + TIMEOUT
+
+ var options = {} // TODO: randomize options
+
+ var roles = rules.roles
+ if (typeof roles === "function")
+ roles = roles(scenario, options)
+
+ var seed = data.consumeIntegralInRange(1, 2 ** 35 - 31)
+
+ var ctx = {
+ player_count: roles.length,
+ players: roles.map((r, ix) => ({ role: r, name: "rtt-fuzzer-" + (ix+1) })),
+ scenario,
+ options,
+ replay: [],
+ state: {},
+ active: null,
+ step: 0,
+ }
+
+ ctx.replay.push([ null, ".setup", [ seed, scenario, options ] ])
+ ctx.state = rules.setup(seed, scenario, options)
+
+ while (ctx.state.active && ctx.state.active !== "None") {
+
+ // insufficient bytes to continue
+ if (data.remainingBytes < 16)
+ return
+
+ ctx.active = ctx.state.active
+
+ // If multiple players can act, we'll pick a random player to go first.
+ if (Array.isArray(ctx.active))
+ ctx.active = data.pickValue(ctx.active)
+ if (ctx.active === "Both")
+ ctx.active = data.pickValue(roles)
+
+ try {
+ ctx.view = rules.view(ctx.state, ctx.active)
+ } catch (e) {
+ return log_crash(e, ctx)
+ }
+
+ if (ctx.step > MAX_STEPS)
+ return log_crash("MaxSteps", ctx)
+ if (Date.now() > timeout)
+ return log_crash("Timeout", ctx)
+
+ if (ctx.view.prompt && ctx.view.prompt.startsWith("TODO:"))
+ return log_crash(ctx.view.prompt, ctx)
+
+ if (!ctx.view.actions)
+ return log_crash("NoMoreActions", ctx)
+
+ var actions = Object.entries(ctx.view.actions).filter(([ action, args ]) => {
+ // remove undo from action list (useful to test for dead-ends)
+ if (action === "undo")
+ return false
+ // remove disabled buttons from action list
+ if (args === 0 || args === false)
+ return false
+ return true
+ })
+
+ if (actions.length === 0)
+ return log_crash("NoMoreActions", ctx)
+
+ var [ action, args ] = data.pickValue(actions)
+ var arg = undefined
+ if (Array.isArray(args)) {
+ for (arg of args) {
+ if (typeof arg !== "number")
+ return log_crash(`BadActionArgs: ${action} ${JSON.stringify(args)}`, ctx)
+ }
+ arg = data.pickValue(args)
+ } else if (args !== 1 && args !== true) {
+ return log_crash(`BadActionArgs: ${action} ${JSON.stringify(args)}`, ctx)
+ }
+
+ var prev_state = object_copy(ctx.state)
+
+ try {
+ ctx.state = rules.action(ctx.state, ctx.active, action, arg)
+ if (typeof rules.assert_state === "function")
+ rules.assert_state(ctx.state)
+ } catch (e) {
+ ctx.state = prev_state
+ return log_crash(e, ctx, action, arg)
+ }
+
+ ctx.replay.push([ ctx.active, action, arg ])
+
+ if (ctx.state.undo.length > 0) {
+ if (String(prev_state.active) !== String(ctx.state.active))
+ return log_crash("UndoAfterActiveChange", ctx, action, arg)
+ if (prev_state.seed !== ctx.state.seed)
+ return log_crash("UndoAfterRandom", ctx, action, arg)
+ }
+
+ ctx.step += 1
+ }
+}
+
+function log_crash(message, ctx, action = undefined, arg = undefined) {
+ if (message instanceof Error)
+ message = message.stack
+
+ var line = `ERROR=${message}`
+ line += `\n\tTITLE=${title_id} ACTIVE=${ctx.active} STATE=${ctx.state?.state ?? ctx.state?.L?.P} STEP=${ctx.step}`
+ line += "SETUP=" + JSON.stringify(ctx.replay[0][2])
+ if (action !== undefined) {
+ line += `\n\t\tACTION=${action}`
+ if (arg !== undefined)
+ line += JSON.stringify(arg)
+ }
+
+ var game = {
+ setup: {
+ title_id,
+ scenario: ctx.scenario,
+ options: ctx.options,
+ player_count: ctx.player_count,
+ },
+ players: ctx.players,
+ state: ctx.state,
+ replay: ctx.replay,
+ }
+
+ var json = JSON.stringify(game)
+ var hash = crypto.createHash("sha1").update(json).digest("hex")
+ var dump = `fuzzer/dump-${title_id}-${hash}.json`
+
+ line += "\n\tDUMP=" + dump
+
+ if (!fs.existsSync(dump)) {
+ console.log(line)
+ fs.writeFileSync(dump, json)
+ } else {
+ console.log(line)
+ }
+
+ if (++error_count >= MAX_ERRORS)
+ throw new Error("too many errors")
+}
+
+function object_copy(original) {
+ var copy, i, n, v
+ if (Array.isArray(original)) {
+ n = original.length
+ copy = new Array(n)
+ for (i = 0; i < n; ++i) {
+ v = original[i]
+ if (typeof v === "object" && v !== null)
+ copy[i] = object_copy(v)
+ else
+ copy[i] = v
+ }
+ return copy
+ } else {
+ copy = {}
+ for (i in original) {
+ v = original[i]
+ if (typeof v === "object" && v !== null)
+ copy[i] = object_copy(v)
+ else
+ copy[i] = v
+ }
+ return copy
+ }
+}