ADR-0023: First-class regular expressions (literal-prefilter driven + compile limits; trigram still not adopted)
Date: 2026-06-15 / Status: Adopted
Decision
Promote regex search from hidden syntax (regex: typed by hand) to a first-class feature. Three parts:
- Whole-query mode as a contract flag. Add
regex_mode:u32(16→20B) toFmfQueryOptions. bit0 = interpret the entire query as a single regex, bit1 = scope (0=name / 1=full path), upper bits reserved 0. The UI switches via a gear-menu toggle plus a “target” submenu. Hand-typedregex:coexists as before. Expressing this via query rewriting is rejected (|/!/"/whitespace would be doubly interpreted as the parser’s AND/OR/NOT, making whole-query mode impossible to express safely). - literal-prefilter driven. Run the regex through
regex_syntaxprefix/suffix literal extraction, feed the required literal into the existing folded-pool linear sweep (Driver::Sub) to narrow candidates, and confirm with the regex body as residual. Name scope only. Cases where extraction fails (\d+, leading.*, alternations with no common factor) go full-scan + rayon. The single most recent compile is cached inside the engine (skips recompilation on USN re-query / RefreshInPlace). - Compile limits. Set
RegexBuildersize_limit/dfa_size_limit= 1MiB each. Overflow yieldsregex::Error::CompiledTooBig→ existingCompileError::Regex→FMF_E_QUERY_SYNTAX(5). No new error code is added.
Because this is an incompatible wire change, bump the pipe name fmf-engine-v1→v2 and raise ABI_VERSION/PROTOCOL_VERSION to 2 (per the rule “incompatible changes bump the name too”).
Rationale
- Consistency with ADR-0002 (most important): the prefilter is not a trigram inverted index. What ADR-0002 rejected was “maintaining n-gram postings as a resident index” (RAM +10–15B/file, diff maintenance per USN batch). This prefilter only extracts literals from the regex at query-compile time and linearly sweeps the existing pool — zero resident index, zero RAM increment, zero USN diff maintenance. It merely applies ADR-0002’s core “linear pool sweep” to regex too, and does not contradict the decision.
- Linear-time guarantee: the Rust
regexcrate uses finite automata (lazy-DFA/Pike VM, no backtracking), so match execution is linear in input length → ReDoS runtime exponential blowup is structurally absent (corroborated: docs/RESEARCH.md). The remaining attack surface is compile time/memory (expansion of huge patterns). The index is filenames only (p99 ≈110B), and legitimate name regexes are on the order of tens of bytes and never reach a 1MiB program = without catching legitimate users, this tips toward “politely reject” rather than compiling a malicious pattern inside the elevated service. Set stricter than the defaults (10/2 MiB). - Prefilter correctness: what prefix/suffix extraction returns is “the literal that every match has at its start (end).” Its longest common factor
Sexists contiguously in every match → exists in the name. FoldingSand sweeping the folded pool yields a superset in both case modes (the original matching implies folded matching, length-preserving), and the regex residual confirms exactly. Zero false negatives is the one inviolable correctness requirement, ensured by an oracle differential test (prefilter == full-scan, name/path × case3). - Reason for the v2 bump: in the past, “after a revert, an old-protocol service binary kept running and corrupted queries” occurred. 16→20B is incompatible, and bumping the pipe name makes old v1 services unreachable (no accident of misreading a 20B request as 16B+text), doubly guarded with Hello version matching.
Measurements (2026-06-15, real C: 1.6M entries / synthetic 1M criterion)
- Regexes where the prefilter works stay within the hard line:
regex:win.*\.dll(prefix “win”) = p99 9.1ms @1.6M. In micro too,win.*\.dll8.5ms /\.dll$(suffix) 7.1ms. Existing queries (substring/wildcard/ext/size) are all non-regressing. - literal-less regex goes full-scan: micro
regex:[0-9]{4}x@1M = 28.7ms (within the 50ms hard line). Real C:regex:[0-9]{6,}is p99 ~51ms at 1.6M entries — it meets spec scale (1M) but, being linear in count, exceeds it at over-spec volumes. Whereas memmem substring full-scan (single chara/e) is 6-8ms even at 1.6M, regex matching is ~9x heavier, so only the literal-less class grows linearly and crosses 50ms. - Bench policy: the gated set on real volume (
P99_BUDGET_US=50mshard) holds onlyregex:win.*\.dll, which the prefilter can guarantee. The literal-less worst case is measured and recorded in criterion micro (query/regex_scan, ungated) — gating it on a fixed 50ms line would fail merely because “the machine has more files than spec” (not concealment, but gating only the range we can guarantee and measuring the worst case separately + documenting it in this ADR). - Streaming-regex optimization over the whole pool is rejected as unsound for
^/$anchors, cross-entry-boundary spans, and greedy matches (full-scan of literal-less regex is the accepted filename-only/no-index tradeoff).
Consequences
- Contract evolution (ADR-0018 flow):
fmf-contract(pod/options/versions) →FMF_BLESS=1golden recapture →just contract-gen→ both-language tests green.contract/golden/query_req_*.binis recaptured at 20B. - One line in
docs/SECURITY.mdthreat #5 (regex compile compute DoS → reject via limit). - Kill switch
FMF_REGEX_PREFILTER=0(force fallback to full-scan; a field recovery valve of the same kind asFMF_QUERY_CACHE). - Observability: on prefilter success
QueryTrace.driverispool-scan; on extraction failure it isfull-scan. - C# side:
RegexMode/ScopeonSearchOptions, 20B encoding inPipeProtocol,AppSettingspersistence, gear-menu UI,RegexHighlighter(.NET re-match; if it drifts, do not highlight).
Re-examination triggers
- literal-less regex full-scan exceeds the 50ms hard line @1M (currently ~29ms, inside. If it breaks at 1M scale, consider a separate path for prefilter-incapable patterns — e.g., a sound subset of pool streaming or a required-byte-class prefilter). The 51ms at 1.6M is linear growth from over-spec volume and is not itself a trigger.
- Real demand for path-scope regex is high and full-scan breaks p99 → consider path prefilter via name-portion anchor extraction.
- Measurement shows typical whole-query queries skew literal-less → only then re-evaluate ADR-0002 trigram (AND’d with all triggers of that ADR).
- A real report of a legitimate user hitting the 1MiB compile limit.