606 lines
18 KiB
Ruby
606 lines
18 KiB
Ruby
# 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
|