feat: add multi-engine regex flavour support

This commit is contained in:
2026-07-27 11:43:51 +02:00
parent 7079cde15f
commit 7f3a91ad37
340 changed files with 643286 additions and 483 deletions

37
engines/ruby/README.md Normal file
View File

@@ -0,0 +1,37 @@
# Ruby regular-expression engine
Regex Tools executes Ruby expressions in the actual CRuby 4.0.0 engine built
for `wasm32-wasi` by ruby.wasm. The reviewed runtime is the minimal
`ruby.wasm` from `@ruby/4.0-wasm-wasi@2.9.3-2.9.4`; the JavaScript host API is
`@ruby/wasm-wasi@2.9.3-2.9.4`.
The fixed `bridge.rb` module is evaluated once during worker start. Pattern,
flags, subject, limits and replacement travel through a bounded,
length-prefixed file in the worker-local WASI memory filesystem. Bounded inert
JSON returns metadata only; replacement output is written as UTF-8 to a
separate bounded worker-local file. No user-controlled value is evaluated or
interpolated as Ruby or JavaScript source, and the bridge does not use the
ruby.wasm `JS.eval` conversion path.
CRuby performs compilation and matching and reports character offsets, which
the adapter exposes as Unicode code-point native offsets and normalizes to
browser UTF-16 ranges. Native `String#gsub`/`String#sub` block iteration drives
replacement matching without materializing expanded output. A fixed streaming
implementation of CRuby 4.0.0's `rb_reg_regsub` grammar handles `\1` through
`\9`, `\k<name>`, `\0`, `\&`, ``\` ``, `\'`, `\+`, and `\\`; a load-time
bounded fixture verifies parity with CRuby. The expander stops on a valid UTF-8
boundary at the configured byte limit. Unpaired browser UTF-16 surrogates are
rejected before request encoding. A workbench timeout, cancellation,
supersession or crash terminates the dedicated worker and its complete
WebAssembly heap.
The deterministic pack contains only:
- `ruby.wasm`;
- `engine-metadata.json`;
- `SHA256SUMS`;
- ruby.wasm's exact `LICENSE.txt`;
- ruby.wasm's complete `NOTICE.txt`, including CRuby and bundled third-party
notices; and
- the exact browser WASI shim and `tslib` license texts required by the
JavaScript host bundle.

605
engines/ruby/bridge.rb Normal file
View File

@@ -0,0 +1,605 @@
# frozen_string_literal: true
# Fixed bridge evaluated once inside the bundled CRuby WebAssembly runtime.
#
# JavaScript writes one bounded, length-prefixed request to the worker-local
# WASI memory filesystem. User-controlled pattern, subject and replacement
# strings are never interpolated into Ruby or JavaScript source. Replacement
# matching is driven by CRuby String#gsub/String#sub block iteration, while a
# fixed streaming implementation of CRuby 4.0.0's rb_reg_regsub grammar writes
# only the requested UTF-8 prefix to a separate worker-local output file.
module RegexToolsRubyBridge
ABI_VERSION = 2
MAXIMUM_CAPTURE_GROUPS = 1_000
MAXIMUM_ERROR_CHARACTERS = 1_024
MAXIMUM_REQUEST_BYTES = 18 * 1024 * 1024
MAXIMUM_RESPONSE_BYTES = 16 * 1024 * 1024
MAXIMUM_MATCHES = 10_000
MAXIMUM_CAPTURE_ROWS = 100_000
MAXIMUM_OUTPUT_BYTES = 64 * 1024 * 1024
REQUEST_FIELD_COUNT = 8
REQUEST_FILE = "/regex-tools-request.bin"
OUTPUT_FILE = "/regex-tools-output.bin"
REQUEST_MAGIC = "RGXRUBY1".b
REPLACEMENT_STOP = :regex_tools_replacement_stop
HEX_DIGITS = "0123456789abcdef"
class ReplacementFailure < StandardError
attr_reader :original
def initialize(original)
@original = original
super(original.message)
set_backtrace(original.backtrace)
end
end
class BoundedUtf8Output
attr_reader :bytes
def initialize(maximum_bytes)
@maximum_bytes = maximum_bytes
@value = String.new(capacity: [maximum_bytes, 64 * 1024].min)
@value.force_encoding(Encoding::UTF_8)
@bytes = 0
@truncated = false
end
def append(value, byte_start = 0, byte_length = nil)
byte_length ||= value.bytesize - byte_start
return true if byte_length <= 0
return false if @truncated
remaining = @maximum_bytes - @bytes
if byte_length <= remaining
@value << value.byteslice(byte_start, byte_length)
@bytes += byte_length
return true
end
prefix = value.byteslice(byte_start, remaining)
prefix = prefix.byteslice(0, prefix.bytesize - 1) until prefix.valid_encoding?
@value << prefix
@bytes += prefix.bytesize
@truncated = true
false
end
def truncated?
@truncated
end
def value
@value
end
end
class << self
def run
reset_output
encode_json(run_record(*read_request))
rescue StandardError => error
begin
reset_output
rescue StandardError
# The original bridge error is more useful than a secondary cleanup
# failure. The adapter will still reject a stale output byte count.
end
encode_json(error_record("bridge", "bridge-error", error))
end
private
def reset_output
File.binwrite(OUTPUT_FILE, "".b)
end
def read_request
data = File.binread(REQUEST_FILE)
unless data.bytesize <= MAXIMUM_REQUEST_BYTES &&
data.byteslice(0, REQUEST_MAGIC.bytesize) == REQUEST_MAGIC
raise ArgumentError, "Invalid Ruby bridge request."
end
offset = REQUEST_MAGIC.bytesize
fields = REQUEST_FIELD_COUNT.times.map do
raise ArgumentError, "Truncated Ruby bridge request." if offset + 4 > data.bytesize
length = data.getbyte(offset) << 24 |
data.getbyte(offset + 1) << 16 |
data.getbyte(offset + 2) << 8 |
data.getbyte(offset + 3)
offset += 4
raise ArgumentError, "Truncated Ruby bridge field." if offset + length > data.bytesize
field = data.byteslice(offset, length)
offset += length
field.force_encoding(Encoding::UTF_8)
raise ArgumentError, "Invalid UTF-8 Ruby bridge field." unless field.valid_encoding?
field
end
raise ArgumentError, "Trailing Ruby bridge request data." unless offset == data.bytesize
fields
end
def bounded_integer(value, label, maximum)
text = value.to_s
unless /\A[1-9][0-9]*\z/.match?(text)
raise ArgumentError, "#{label} must be a positive decimal integer."
end
number = text.to_i
unless number <= maximum
raise ArgumentError, "#{label} exceeds the Ruby bridge limit."
end
number
end
def run_record(
operation_value,
pattern_value,
flags_value,
subject_value,
maximum_matches_value,
maximum_capture_rows_value,
replacement_value,
maximum_output_bytes_value
)
operation = operation_value.to_s
return identity if operation == "identity"
unless operation == "execute" || operation == "replace"
return error_record(
"bridge",
"invalid-operation",
"Unsupported Ruby bridge operation."
)
end
pattern = pattern_value.to_s
flags = flags_value.to_s
subject = subject_value.to_s
replacement = replacement_value.to_s
maximum_matches = bounded_integer(
maximum_matches_value,
"maximumMatches",
MAXIMUM_MATCHES
)
maximum_capture_rows = bounded_integer(
maximum_capture_rows_value,
"maximumCaptureRows",
MAXIMUM_CAPTURE_ROWS
)
maximum_output_bytes = bounded_integer(
maximum_output_bytes_value,
"maximumOutputBytes",
MAXIMUM_OUTPUT_BYTES
)
unless /\A[gimx]*\z/.match?(flags) && flags.each_char.uniq.length == flags.length
return error_record(
"bridge",
"invalid-flags",
"The Ruby bridge received unsupported or duplicate flags."
)
end
regexp = compile(pattern, flags)
return regexp if regexp.is_a?(Hash)
group_count = capture_group_count(regexp)
if group_count > MAXIMUM_CAPTURE_GROUPS
return error_record(
"compile",
"capture-group-limit",
"The pattern declares #{group_count} capture groups; " \
"the bridge limit is #{MAXIMUM_CAPTURE_GROUPS}."
)
end
begin
collected = collect_matches(
regexp,
subject,
flags.include?("g"),
group_count,
maximum_matches,
maximum_capture_rows,
operation == "replace" ? replacement : nil,
maximum_output_bytes
)
rescue ReplacementFailure => error
return error_record(
"replacement",
"replacement-error",
error.original
)
rescue StandardError => error
return error_record("match", "match-error", error)
end
result = {
"abi" => ABI_VERSION,
"ok" => true,
"groupCount" => group_count,
"groupNames" => capture_group_names(regexp),
"matches" => collected["matches"],
"resultsTruncated" => collected["resultsTruncated"]
}
return result if operation == "execute"
File.binwrite(OUTPUT_FILE, collected["output"])
result.merge(
"outputBytes" => collected["outputBytes"],
"outputTruncated" => collected["outputTruncated"]
)
rescue StandardError => error
error_record("bridge", "bridge-error", error)
end
# RbValue#toJS delegates Hash/Array conversion to the ruby.wasm `js` gem,
# whose generic conversion path constructs objects through JS.eval. The
# Toolbox CSP deliberately disallows that capability. Return one inert,
# metadata-only JSON string, assembled incrementally without per-value
# arrays or the potentially 6x JSON escaping amplification of output text.
def encode_json(value)
output = String.new(capacity: 4 * 1024)
output.force_encoding(Encoding::UTF_8)
append_json(output, value)
if output.bytesize > MAXIMUM_RESPONSE_BYTES
raise RangeError, "Ruby bridge metadata exceeds its response limit."
end
output
end
def append_json(output, value)
case value
when Hash
output << "{"
first = true
value.each do |key, item|
output << "," unless first
first = false
append_json_string(output, key.to_s)
output << ":"
append_json(output, item)
end
output << "}"
when Array
output << "["
value.each_with_index do |item, index|
output << "," unless index.zero?
append_json(output, item)
end
output << "]"
when String
append_json_string(output, value)
when Integer
output << value.to_s
when true
output << "true"
when false
output << "false"
when nil
output << "null"
else
append_json_string(output, value.to_s)
end
if output.bytesize > MAXIMUM_RESPONSE_BYTES
raise RangeError, "Ruby bridge metadata exceeds its response limit."
end
end
def append_json_string(output, value)
text = value.valid_encoding? \
? value \
: value.encode(
Encoding::UTF_8,
invalid: :replace,
undef: :replace,
replace: "\uFFFD"
)
output << "\""
text.each_codepoint do |codepoint|
case codepoint
when 0x08 then output << "\\b"
when 0x09 then output << "\\t"
when 0x0A then output << "\\n"
when 0x0C then output << "\\f"
when 0x0D then output << "\\r"
when 0x22 then output << "\\\""
when 0x5C then output << "\\\\"
when 0x00..0x1F
output << "\\u00"
output << HEX_DIGITS.getbyte(codepoint >> 4)
output << HEX_DIGITS.getbyte(codepoint & 0x0F)
else
output << codepoint
end
end
output << "\""
end
def identity
verify_replacement_parity
{
"abi" => ABI_VERSION,
"ok" => true,
"identity" => {
"implementation" => RUBY_ENGINE,
"rubyVersion" => RUBY_VERSION,
"platform" => RUBY_PLATFORM,
"defaultExternalEncoding" => Encoding.default_external.name
}
}
end
def compile(pattern, flags)
options = 0
options |= Regexp::IGNORECASE if flags.include?("i")
options |= Regexp::MULTILINE if flags.include?("m")
options |= Regexp::EXTENDED if flags.include?("x")
Regexp.new(pattern, options)
rescue RegexpError => error
error_record("compile", "compile-error", error)
rescue StandardError => error
error_record("compile", "compile-error", error)
end
def capture_group_count(regexp)
# Alternating with an empty non-capturing branch makes a match available
# without changing the original capture numbering.
Regexp.union(regexp, /(?:)/).match("").length - 1
end
def capture_group_names(regexp)
regexp.named_captures.flat_map do |name, numbers|
numbers.map { |number| [number, name] }
end.sort_by { |number, name| [number, name] }
end
def match_record(match, group_count)
captures = (1..group_count).map do |group_number|
start_offset = match.begin(group_number)
start_offset.nil? \
? nil \
: [start_offset, match.end(group_number)]
end
{
"span" => [match.begin(0), match.end(0)],
"captures" => captures
}
end
def collect_matches(
regexp,
subject,
global,
group_count,
maximum_matches,
maximum_capture_rows,
replacement = nil,
maximum_output_bytes = 1
)
records = []
capture_rows = 0
results_truncated = false
output = replacement.nil? \
? nil \
: BoundedUtf8Output.new(maximum_output_bytes)
last_replacement_end = 0
callback = proc do
match = Regexp.last_match
rows_for_match = [1, group_count].max
if records.length >= maximum_matches ||
capture_rows + rows_for_match > maximum_capture_rows
results_truncated = true
throw REPLACEMENT_STOP
end
records << match_record(match, group_count)
capture_rows += rows_for_match
unless output.nil? || output.truncated?
match_start = match.bytebegin(0)
output.append(
subject,
last_replacement_end,
match_start - last_replacement_end
)
unless output.truncated?
begin
append_replacement(
output,
replacement,
subject,
match,
regexp.names.empty?
)
rescue StandardError => error
raise ReplacementFailure, error
end
end
end
last_replacement_end = match.byteend(0)
""
end
catch(REPLACEMENT_STOP) do
global ? subject.gsub(regexp, &callback) : subject.sub(regexp, &callback)
end
unless output.nil? || output.truncated?
output.append(
subject,
last_replacement_end,
subject.bytesize - last_replacement_end
)
end
result = {
"matches" => records,
"resultsTruncated" => results_truncated
}
unless output.nil?
result["output"] = output.value
result["outputBytes"] = output.bytes
result["outputTruncated"] = output.truncated?
end
result
end
# Mirrors CRuby 4.0.0 rb_reg_regsub: \1..\9, \k<name>, \0, \&, \`,
# \', \+, \\ and literal preservation for every other escaped character.
# Match selection and byte offsets remain native MatchData results.
def append_replacement(
output,
replacement,
subject,
match,
numeric_backrefs_active
)
bytes = replacement.b
cursor = 0
while cursor < bytes.bytesize && !output.truncated?
slash = bytes.index("\\", cursor)
if slash.nil?
output.append(replacement, cursor, bytes.bytesize - cursor)
break
end
output.append(replacement, cursor, slash - cursor) if slash > cursor
break if output.truncated?
if slash + 1 >= bytes.bytesize
output.append(replacement, slash, 1)
break
end
marker = bytes.getbyte(slash + 1)
cursor = slash + 2
case marker
when 0x31..0x39
append_capture(
output,
subject,
match,
marker - 0x30
) if numeric_backrefs_active
when 0x6B # k
if cursor < bytes.bytesize && bytes.getbyte(cursor) == 0x3C # <
name_start = cursor + 1
name_end = bytes.index(">", name_start)
if name_end.nil?
raise RuntimeError, "invalid group name reference format"
end
name = replacement.byteslice(name_start, name_end - name_start)
append_capture(output, subject, match, name)
cursor = name_end + 1
else
output.append(replacement, slash, 2)
end
when 0x30, 0x26 # 0, &
append_capture(output, subject, match, 0)
when 0x60 # `
output.append(subject, 0, match.bytebegin(0))
when 0x27 # '
match_end = match.byteend(0)
output.append(subject, match_end, subject.bytesize - match_end)
when 0x2B # +
group_number = match.length - 1
group_number -= 1 while group_number > 0 && match.bytebegin(group_number).nil?
append_capture(output, subject, match, group_number) if group_number > 0
when 0x5C # \
output.append(replacement, slash + 1, 1)
else
if marker >= 0x80
cursor += 1 while cursor < bytes.bytesize &&
(bytes.getbyte(cursor) & 0xC0) == 0x80
end
output.append(replacement, slash, cursor - slash)
end
end
end
def append_capture(output, subject, match, group)
if group.is_a?(Integer) && group >= match.length
return
end
byte_start, byte_end = match.byteoffset(group)
return if byte_start.nil?
output.append(subject, byte_start, byte_end - byte_start)
end
def verify_replacement_parity
return if @replacement_parity_verified
slash = 92.chr
fixtures = [
[
/(a)(b)?/,
"ab a",
[
"<",
"#{slash}0",
"|#{slash}&",
"|#{slash}1",
"|#{slash}2",
"|#{slash}9",
"|#{slash}+",
"|#{slash}#{slash}",
"|#{slash}q",
"|#{slash}`",
"|#{slash}'",
">"
].join,
true
],
[
/(?<left>a)(?<right>b)?/,
"ab a",
"<#{slash}k<left>|#{slash}k<right>|#{slash}1>",
true
],
[/(é)/, "xéy", "#{slash}é/#{slash}1", false]
]
fixtures.each do |regexp, subject, replacement, global|
native = global \
? subject.gsub(regexp, replacement) \
: subject.sub(regexp, replacement)
bounded = collect_matches(
regexp,
subject,
global,
capture_group_count(regexp),
MAXIMUM_MATCHES,
MAXIMUM_CAPTURE_ROWS,
replacement,
1024 * 1024
)
unless !bounded["resultsTruncated"] &&
!bounded["outputTruncated"] &&
bounded["output"] == native
raise "Bounded Ruby replacement semantics failed the CRuby parity test."
end
end
@replacement_parity_verified = true
end
def error_record(phase, code, error)
message = error.is_a?(Exception) ? error.message : error.to_s
if message.length > MAXIMUM_ERROR_CHARACTERS
message = "#{message[0, MAXIMUM_ERROR_CHARACTERS - 1]}"
end
{
"abi" => ABI_VERSION,
"ok" => false,
"phase" => phase,
"code" => code,
"message" => message
}
end
end
end
RegexToolsRubyBridge