Files
gbr-games/cmd/gbrgames/draftchecks.go
T
gregandClaude Opus 5.5 8b49895d5e
Validate game templates / validate (push) Successful in 19s
Validate game templates / boot-test (push) Skipped
Validate game templates / publish (push) Skipped
feat(builder): launch options must appear in the research sources
The Valheim draft in #13 passed -maxplayers, an option Valheim doesn't have.
The server ignores unknown options, so the draft booted and passed every
boot test while its MaxPlayers setting did nothing.

build now reads the pages research based the variant on, in full, and the
drafter rejects any launch option in the startup command that none of them,
nor the image's own scripts, mention. The model gets it back to remove or
spell correctly. Options of shell tools (mkdir -p, [ -n ...]) are skipped.
The check is off when there are no sources (draft run by hand, no reader).

Against #13's 11 real sources it flags -maxplayers and nothing else.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 17:47:33 -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 '...' (ARK's), 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
// (Valheim has no -maxplayers) 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
}