Skip to content

fix(panel): a rule that reads as the strictest line in a template now enforces something - #68

Merged
aaldersondev merged 1 commit into
mainfrom
fix/regex-rules
Aug 8, 2026
Merged

fix(panel): a rule that reads as the strictest line in a template now enforces something#68
aaldersondev merged 1 commit into
mainfrom
fix/regex-rules

Conversation

@aaldersondev

Copy link
Copy Markdown
Contributor

parseRules split the rule string on | before it looked for regex:. So a Pterodactyl-shaped rule:

required|string|regex:/^(paper|purpur)$/

was torn into regex:/^(paper and purpur)$/. The first will not compile, and the catch around it returned true — accepting every value. The second became an unrecognised rule name and was dropped.

The strictest-looking line in a template enforced nothing, and said nothing about it. Pterodactyl eggs write alternations constantly, so every imported template carrying one has been running with that variable unvalidated.

The file's own comment said regex: "keeps its argument whole: it contains commas, pipes and colons that must not be split" — true of commas and colons, false of the pipe, which is the character an alternation is made of.

Both halves, because either alone leaves a hole

Scan, don't split. A regex:/ is consumed through its matching closing delimiter and its flags, honouring backslash escapes and character classes — a / or a | inside [...] is an ordinary character. Everything else still cuts on the pipe.

An expression that will not compile refuses the value, blaming the template rather than the value, with an error log naming the variable and the rule. The old fail-open is what turned the tearing into silence.

The refusal fires even when there is no value to refuse: validateValue short-circuits on an empty value when no required rule stands beside it, so a variable normally left empty would otherwise keep its broken rule secret until the day somebody finally filled it in. The check now runs on that path for its log line alone.

Consequences, stated rather than discovered

A delimited regex containing a pipe now works — and that is a tightening. Anyone who imported an egg with an alternation has had that variable unvalidated; a value they saved earlier may be refused on the next save. Intended direction, and the one visible break.

Rules written after a delimited regex now apply. regex:/^(a|b)$/|max:10 used to lose max:10 entirely.

An uppercase flag is no longer swallowed. Taking it looks more forgiving and is worse: it keeps the rule whole, and the check then reads /^(a|b)$/I as a bare pattern — which compiles, matches almost nothing, and refuses every value while blaming the value. Stopping at the delimiter lets the fragment fail to compile, which is the loud answer.

One case knowingly left open

An unterminated regex:/ that borrows a / from a later rule, where the region between them happens to compile, is indistinguishable from a correctly delimited expression — regex:/^(a|b)$|in:x/|max:5 reads as one valid regex and quietly swallows in:x.

Catching it would mean refusing any expression that spans a | followed by something resembling a rule name, which breaks the legitimate regex:/^(a|max:5)$/ in order to rescue a template that is already malformed. Trading a malformed-only widening for a well-formed-only breakage is the wrong way round. It stays, and it is written down in the code.

Verification

  • Backwards compatibility checked differentially, not by eye. The pre-fix parser was reconstructed and both were run over a corpus of the bundled catalogue plus realistic eggs (1,600 cases), then a 480,000-case seeded fuzz. The catalogue is bit-identical, and every single verdict change is a narrowing — zero widenings that are not the torn-alternation parse this exists to fix.
  • Mutation-tested. The reviewer found five tests that passed for the wrong reason — the fallback guard rescued the mutant onto the same answer — and supplied a distinguishing input for each; all are now covered. 21 of 26 scanner mutants killed, survivors listed with a reason (two provably equivalent).
  • No second parser. split('|') appears nowhere else: the importer passes the rule string through verbatim, the front end only prints it, and validateValue has one production caller.
  • lint, typecheck, test, format:check green — 1307 tests, the rules spec alone going from 24 to 85.

Also

Factorio's FACTORIO_VERSION comment explained at length that its expression is a character class because an alternation would be torn and would then accept everything. That is no longer true, and left standing it would keep future template authors working around a bug that is gone. The rule string is unchanged; only the reasoning is.

Found, not fixed

ServersService.resolveVariables validates nothing at all — server creation takes the caller's value for every editable variable and writes it straight through without reading the rule string. Admin-only, and every later edit does validate, so it is a follow-up rather than part of this.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QL3QL3ReEa9Sk68mxJW6Fu

… enforces something

`parseRules` split the rule string on `|` before it looked for `regex:`,
so `required|regex:/^(paper|purpur)$/` was torn into `regex:/^(paper` and
`purpur)$/`. The first would not compile, and the catch around it returned
**true** — accepting every value. The second became an unrecognised rule
name and was dropped.

The strictest-looking line in a template therefore enforced nothing, and
said nothing about it. Pterodactyl eggs write alternations constantly, so
every imported template carrying one has been running unvalidated.

The file's own comment said `regex:` "keeps its argument whole: it
contains commas, pipes and colons that must not be split" — true of commas
and colons, false of the pipe, which is the character an alternation is
made of.

Both halves are fixed, because either alone leaves a hole. The rule string
is now scanned rather than split: a `regex:/` is consumed through its
matching closing delimiter and its flags, honouring backslash escapes and
character classes, where a `/` or a `|` inside `[...]` is an ordinary
character. And an expression that will not compile refuses the value
instead of accepting it, blaming the template rather than the value, with
an error log naming the variable and the rule.

The refusal fires even when there is no value to refuse. A variable that
is normally left empty would otherwise keep its broken rule secret until
the day somebody finally filled it in.

Two consequences worth stating rather than discovering:

A delimited regex containing a pipe now *works*, which is a tightening —
anyone who imported an egg with an alternation has had that variable
unvalidated, and a value saved earlier may be refused on the next save.
That is the intended direction and the one visible break.

An uppercase flag is no longer swallowed as a flag. Taking it would keep
the rule whole and have the check read `/^(a|b)$/I` as a bare pattern —
which compiles, matches almost nothing, and refuses every value while
blaming the value. Stopping at the delimiter lets the fragment fail to
compile, which is the loud answer.

One case is knowingly left: an unterminated `regex:/` that borrows a `/`
from a later rule, where the region between them happens to compile, is
indistinguishable from a correctly delimited expression. Catching it would
mean refusing any expression spanning a `|` followed by something that
looks like a rule name, which breaks the legitimate `regex:/^(a|max:5)$/`
to rescue a template that is already malformed.

The bundled catalogue is unaffected: every regex it ships is a pipe-free
character class, written that way to work around this very bug. Factorio's
comment explaining the workaround is corrected, so nobody keeps coding
around a bug that is gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QL3QL3ReEa9Sk68mxJW6Fu
@aaldersondev
aaldersondev merged commit f6c22bf into main Aug 8, 2026
3 checks passed
@aaldersondev
aaldersondev deleted the fix/regex-rules branch August 8, 2026 10:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant