Files
gbr-games/cmd/gbrgames/draftchecks.go
T
gregandClaude Opus 5.5 929cbc31cf
Validate game templates / validate (push) Successful in 19s
Validate game templates / boot-test (push) Skipped
Validate game templates / publish (push) Skipped
fix(builder): keep every game's name and code out of the builder
The builder must be game-agnostic, but its prompts named games: AUTHORING.md
(the model's rules) used three games as examples, the research prompt listed
two games' editions, the schema described containerUser by one game's image,
and every draft prompt included two real templates from the repo, so the
model saw, and could copy, other games' commands and options.

- AUTHORING.md, the research prompt and the schema describe the rules without
  naming a game.
- The draft prompt's format example is now one invented server (no real
  command, options, ports or app id), checked by
  TestExampleTemplateFollowsTheRules against validation and the draft checks.
- Comments, help texts, BUILDER.md and test fixtures use invented names and
  values.
- TestBuilderIsGameAgnostic reads every game's name and id from games/ and
  fails if any appears in the builder's code, tests, rules, schemas or docs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:58:27 -04:00

326 lines
11 KiB
Go

package main
import (
"encoding/json"
"fmt"
"path"
"regexp"
"sort"
"strconv"
"strings"
)
// Checks a booting draft can still fail: things that only go wrong once an owner changes a setting, so
// neither the validator nor a boot test with the defaults sees them.
// splitSettings finds text settings that reach the server's command line unquoted, so a value with spaces
// ("GBRHosting - My Server") arrives as several arguments and the game only sees the first word. Covers
// direct use (-name ${SERVER_NAME}) and the common ARGS="-name ${SERVER_NAME}"; ./server $ARGS, where the
// quotes in the assignment don't survive the unquoted $ARGS.
func splitSettings(game map[string]any) []string {
startup, _ := game["defaultStartupCommand"].(string)
if startup == "" {
return nil
}
textVars := map[string]string{} // env name -> setting key
for key, p := range schemaProperties(game) {
if prop, _ := p.(map[string]any); prop["type"] == "string" {
textVars[envName(key)] = key
}
}
if len(textVars) == 0 {
return nil
}
s := scanShell(unwrapShell(startup))
split := map[string]string{} // setting key -> how it gets split
var reach func(holder string, seen map[string]bool) []string
reach = func(holder string, seen map[string]bool) []string {
if seen[holder] {
return nil
}
seen[holder] = true
var out []string
for _, v := range s.held[holder] {
if _, ok := textVars[v]; ok {
out = append(out, v)
}
out = append(out, reach(v, seen)...)
}
return out
}
for _, e := range s.uses {
if e.quoted {
continue
}
if key, ok := textVars[e.name]; ok {
split[key] = "${" + e.name + "} is not in double quotes"
}
if len(s.held[e.name]) > 0 {
for _, v := range reach(e.name, map[string]bool{}) {
if _, done := split[textVars[v]]; !done {
split[textVars[v]] = fmt.Sprintf("${%s} is inside $%s, which is used without double quotes", v, e.name)
}
}
}
}
var problems []string
for _, key := range sortedKeysString(split) {
problems = append(problems, fmt.Sprintf("setting %s is split into words on the command line (%s): a value with spaces like "+
"\"GBRHosting - My Server\" reaches the game as several arguments. Quote it where it is used (-name \"${%s}\"), or build "+
"optional arguments in a bash array: args=(-name \"$%s\"); [ -n \"$X\" ] && args+=(-x \"$X\"); ./server \"${args[@]}\"",
key, split[key], envName(key), envName(key)))
}
return problems
}
var shellWrap = regexp.MustCompile(`^\s*(?:ba)?sh\s+-c\s+'(.*)'\s*$`)
// unwrapShell looks inside a startup command that is one bash -c '...', which would otherwise be
// all single-quoted text.
func unwrapShell(cmd string) string {
if m := shellWrap.FindStringSubmatch(cmd); m != nil && !strings.Contains(m[1], "'") {
return m[1]
}
return cmd
}
type shellUse struct {
name string
quoted bool
}
type shellScan struct {
uses []shellUse // expansions where the value is used: words, arguments
held map[string][]string // variable -> variables its assigned value contains
}
var (
assignWord = regexp.MustCompile(`^([A-Za-z_][A-Za-z0-9_]*)\+?=`)
varName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*`)
nestedVar = regexp.MustCompile(`\$\{?([A-Za-z_][A-Za-z0-9_]*)`)
)
// scanShell is just enough of a shell parser for splitSettings: quotes, $VAR / ${VAR...} expansions, and
// assignments (NAME=..., NAME+=..., NAME=(...) arrays).
func scanShell(cmd string) *shellScan {
s := &shellScan{held: map[string][]string{}}
var single, double, wordStart = false, false, true
assign, inArray := "", false
record := func(name string, quoted bool) {
switch {
case assign != "" && !inArray:
// NAME="...$X..." : no splitting yet, but $NAME carries X along
s.held[assign] = append(s.held[assign], name)
case inArray && quoted:
s.held[assign] = append(s.held[assign], name) // safe unless the array is expanded unquoted
default:
s.uses = append(s.uses, shellUse{name, quoted})
}
}
for i := 0; i < len(cmd); i++ {
c := cmd[i]
if wordStart && !single && !double {
if m := assignWord.FindString(cmd[i:]); m != "" {
assign = strings.TrimRight(strings.TrimSuffix(m, "="), "+")
i += len(m)
if i < len(cmd) && cmd[i] == '(' {
inArray = true
} else {
i--
}
wordStart = false
continue
}
}
wordStart = false
switch {
case c == '\\' && !single:
i++
case c == '\'' && !double:
single = !single
case c == '"' && !single:
double = !double
case c == '$' && !single && i+1 < len(cmd):
if cmd[i+1] == '{' {
end := matchingBrace(cmd, i+1)
body := cmd[i+2 : end]
name := varName.FindString(strings.TrimLeft(body, "#!"))
if name != "" {
record(name, double)
}
// Defaults can hold more expansions: ${A:-${B}}
for _, m := range nestedVar.FindAllStringSubmatch(body[len(name):], -1) {
record(m[1], double)
}
i = end
} else if name := varName.FindString(cmd[i+1:]); name != "" {
record(name, double)
i += len(name)
}
case single || double:
case inArray && c == ')':
inArray, assign = false, ""
case inArray:
case c == ' ' || c == '\t' || c == '\n' || c == ';' || c == '&' || c == '|' || c == '(' || c == ')':
assign, wordStart = "", true
}
}
return s
}
func matchingBrace(s string, open int) int {
depth := 0
for i := open; i < len(s); i++ {
switch s[i] {
case '{':
depth++
case '}':
depth--
if depth == 0 {
return i
}
}
}
return len(s) - 1
}
var (
portSetting = regexp.MustCompile(`(?i)(port$|(^|_)port(_|$))`)
notAPort = regexp.MustCompile(`(?i)(sup|re|ex|im|trans)port$`)
// Reached over the Docker network, never published (AUTHORING.md)
internalPort = regexp.MustCompile(`(?i)rcon|rest|api|admin|telnet|web|http|metrics`)
)
// portSettings checks settings that set a port players connect to against defaultPorts. The node always
// publishes defaultPorts' container ports (moving only the host side when servers share a node), so a
// game listening anywhere else can't be reached: a default that isn't published is broken from the
// start, and an editable one breaks the moment an owner changes it.
func portSettings(game map[string]any) []string {
published := map[int]bool{}
ports, _ := game["defaultPorts"].([]any)
for _, p := range ports {
n, _ := strconv.Atoi(strings.Split(containerPort(fmt.Sprint(p)), "/")[0])
published[n] = true
}
defaults, _ := game["defaultConfig"].(map[string]any)
props := schemaProperties(game)
var problems []string
keys := make([]string, 0, len(defaults))
for k := range defaults {
keys = append(keys, k)
}
sort.Strings(keys)
for _, key := range keys {
if !portSetting.MatchString(key) || notAPort.MatchString(key) || internalPort.MatchString(key) {
continue
}
port, ok := portNumber(defaults[key])
if !ok {
continue // {PUBLIC_PORT_n} and other placeholders are filled per server
}
_, editable := props[key]
switch {
case !published[port]:
problems = append(problems, fmt.Sprintf("setting %s defaults to port %d, which defaultPorts does not publish: players can't reach it. "+
"Use one of the published ports, or add this port to defaultPorts if players connect to it", key, port))
case editable:
problems = append(problems, fmt.Sprintf("setting %s lets owners change the port, but the node always publishes container port %d "+
"(defaultPorts), so any other value makes the server unreachable. Don't make it a setting: remove %s from "+
"defaultConfig and configSchema and write %d directly in the startup command or config file. If the game must "+
"announce the port players use, give it {PUBLIC_PORT_%d} instead", key, port, key, port, port))
}
}
return problems
}
func portNumber(v any) (int, bool) {
var s string
switch n := v.(type) {
case json.Number:
s = n.String()
case string:
s = n
case int, int64, float64:
s = fmt.Sprint(n)
default:
return 0, false
}
p, err := strconv.Atoi(strings.TrimSpace(s))
return p, err == nil && p > 0 && p < 65536
}
var (
optionToken = regexp.MustCompile(`(?:^|[\s("'])(-{1,2}[A-Za-z][A-Za-z0-9_.]*(?:-[A-Za-z0-9_.]+)*)`)
segmentSep = regexp.MustCompile(`;|&&|\|\||\||\n`)
testOps = map[string]bool{"eq": true, "ne": true, "lt": true, "gt": true, "le": true, "ge": true, "nt": true, "ot": true, "ef": true}
// Options of these are the shell's and its tools', not the game's
shellTools = map[string]bool{"mkdir": true, "cp": true, "mv": true, "ln": true, "rm": true, "chmod": true, "chown": true,
"wget": true, "curl": true, "tar": true, "unzip": true, "test": true, "[": true, "[[": true, "echo": true, "printf": true,
"sleep": true, "find": true, "sed": true, "grep": true, "cd": true, "export": true, "touch": true, "bash": true, "sh": true,
"cat": true, "head": true, "tail": true, "ls": true, "set": true, "ulimit": true, "env": true, "id": true}
)
// undocumentedOptions finds launch options in the startup command that none of the researched pages (nor
// the image's own scripts) mention. Servers silently ignore options they don't know, so a made-up one
// boots fine and passes every boot test while its setting does nothing.
func undocumentedOptions(game map[string]any, docs string, facts *imageFacts) []string {
if strings.TrimSpace(docs) == "" {
return nil
}
startup, _ := game["defaultStartupCommand"].(string)
corpus := docs
if facts != nil {
for _, s := range facts.Scripts {
corpus += "\n" + s
}
}
seen := map[string]bool{}
var missing []string
for _, opt := range launchOptions(unwrapShell(startup)) {
name := strings.TrimLeft(opt, "-")
key := strings.ToLower(name)
if seen[key] {
continue
}
seen[key] = true
documented := regexp.MustCompile(`(?i)(^|[^A-Za-z0-9_])-{1,2}` + regexp.QuoteMeta(name) + `($|[^A-Za-z0-9_])`)
if !documented.MatchString(corpus) {
missing = append(missing, opt)
}
}
if len(missing) == 0 {
return nil
}
return []string{fmt.Sprintf("the startup command passes %s, which none of the pages researched for this game mention. "+
"Servers silently ignore options they don't know, so it boots fine but does nothing: remove it (and any setting that "+
"only feeds it), or spell it exactly as the documentation does", strings.Join(missing, ", "))}
}
// launchOptions lists the -options passed to the game, skipping the shell's own (mkdir -p, [ -n ... ]).
func launchOptions(cmd string) []string {
var out []string
for _, seg := range segmentSep.Split(cmd, -1) {
fields := strings.Fields(strings.TrimLeft(strings.TrimSpace(seg), "({"))
i := 0
for i < len(fields) && (fields[i] == "exec" || fields[i] == "then" || fields[i] == "do" || fields[i] == "else" ||
(assignWord.MatchString(fields[i]) && !strings.Contains(fields[i], "=("))) {
i++
}
if i == len(fields) {
continue
}
if word := path.Base(fields[i]); shellTools[word] {
continue
}
for _, m := range optionToken.FindAllStringSubmatch(strings.Join(fields[i:], " "), -1) {
name := strings.TrimLeft(m[1], "-")
if len(name) < 2 || testOps[name] {
continue
}
out = append(out, m[1])
}
}
return out
}