# 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, \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 ], [ /(?a)(?b)?/, "ab a", "<#{slash}k|#{slash}k|#{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