From 6b6a06754594fd20e9d6eecaa5fe8dddd15408f0 Mon Sep 17 00:00:00 2001 From: Erik Berlin Date: Thu, 5 Nov 2026 11:00:34 -0800 Subject: [PATCH 1/2] Call as_json only once for invalid strings in Java and Ruby generators When a string with invalid encoding is generated in strict mode, the Java and pure-Ruby generators call as_json on it. If as_json returned a Symbol, the Symbol was turned back into the same invalid string, so as_json was called again, forever. JRuby died with a StackOverflowError and TruffleRuby with a SystemStackError. Do what convert_invalid_encoding does in generator.c: if as_json returned something other than the original string, turn a Symbol into its String, and raise "source sequence is illegal/malformed utf-8" if that string is still invalid instead of trying again or transcoding it. The comparison happens before the Symbol is converted, because TruffleRuby's Symbol#name returns the same cached string each time. This lets test_json_coder_symbol_invalid_encoding run on JRuby and TruffleRuby. Part of https://github.com/ruby/json/issues/1123 --- CHANGES.md | 1 + java/src/json/ext/Generator.java | 19 ++++++++++++++----- lib/json/truffle_ruby/generator.rb | 15 ++++++++++++--- test/json/json_coder_test.rb | 10 ++++------ 4 files changed, 31 insertions(+), 14 deletions(-) diff --git a/CHANGES.md b/CHANGES.md index 2bad52aed..d4fa1d417 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -5,6 +5,7 @@ * Fix `JSON::ParserError#json_path` raising `NoMethodError` when the document was parsed with `symbolize_names: true`. * Fix `JSON.generate` not detecting duplicate keys such as `{ "1" => 1, 1 => 2 }`. * JRuby: raise `TypeError` when a String subclass used as a Hash key has a `to_s` that doesn't return a String, like the C extension. +* JRuby and TruffleRuby: fix a stack overflow in `JSON::Coder` when `as_json` returns a Symbol for a string with invalid encoding. ### 2026-10-13 (3.1.0) diff --git a/java/src/json/ext/Generator.java b/java/src/json/ext/Generator.java index 750be6bf6..f71f6b690 100644 --- a/java/src/json/ext/Generator.java +++ b/java/src/json/ext/Generator.java @@ -713,12 +713,21 @@ void generate(ThreadContext context, Session session, RubyString object, OutputS if (state.strict() && !encoder.hasValidEncoding(object) && state.getAsJSON() != null) { IRubyObject value = state.getAsJSON().call(context, object, context.getRuntime().getFalse()); - if (value instanceof RubyString) { + if (value != object) { + // C: convert_invalid_encoding. as_json is only called once, so a new + // string that is still invalid raises instead of calling it again. + if (value instanceof RubySymbol) { + value = ((RubySymbol) value).asString(); + } + if (!(value instanceof RubyString)) { + Handler handler = getHandlerFor(context.runtime, value); + handler.generate(context, session, value, buffer); + return; + } + if (!encoder.hasValidEncoding((RubyString) value)) { + throw Utils.buildGeneratorError(context, object, "source sequence is illegal/malformed utf-8").toThrowable(); + } object = (RubyString)value; - } else { - Handler handler = getHandlerFor(context.runtime, value); - handler.generate(context, session, value, buffer); - return; } } generateString(context, session, object, buffer); diff --git a/lib/json/truffle_ruby/generator.rb b/lib/json/truffle_ruby/generator.rb index f73708f2d..fc65e3b0d 100644 --- a/lib/json/truffle_ruby/generator.rb +++ b/lib/json/truffle_ruby/generator.rb @@ -791,9 +791,18 @@ def to_json(state = nil, *args) if state.strict? && state.as_json unless Generator.valid_encoding?(string) - string = state.as_json.call(string, false) - unless string.is_a?(::String) - return string.to_json(state, *args) + coerced = state.as_json.call(string, false) + unless coerced.equal?(string) + # as_json is only called once, so a new string that is still + # invalid raises instead of calling it again. + coerced = coerced.name if coerced.is_a?(::Symbol) + unless coerced.is_a?(::String) + return coerced.to_json(state, *args) + end + unless Generator.valid_encoding?(coerced) + raise GeneratorError.new("source sequence is illegal/malformed utf-8", self) + end + string = coerced end end end diff --git a/test/json/json_coder_test.rb b/test/json/json_coder_test.rb index b91431821..64b0bce6d 100755 --- a/test/json/json_coder_test.rb +++ b/test/json/json_coder_test.rb @@ -238,13 +238,11 @@ def test_json_coder_symbol_invalid_encoding coder = JSON::Coder.new { |object, is_key| Object.new } assert_raise(JSON::GeneratorError) { coder.dump([symbol]) } - if RUBY_ENGINE == "ruby" - coder = JSON::Coder.new { |object, is_key| symbol } - error = assert_raise JSON::GeneratorError do - coder.dump([symbol]) - end - assert_equal "source sequence is illegal/malformed utf-8", error.message + coder = JSON::Coder.new { |object, is_key| symbol } + error = assert_raise JSON::GeneratorError do + coder.dump([symbol]) end + assert_equal "source sequence is illegal/malformed utf-8", error.message end def test_depth From 71c93cee42acce073b0d6ea836b9b7efa5458061 Mon Sep 17 00:00:00 2001 From: Erik Berlin Date: Thu, 5 Nov 2026 11:00:34 -0800 Subject: [PATCH 2/2] Run the duplicate key test with a non-String key on JRuby The JSON.load case in test_parse_duplicate_key with an on_load proc that returns a non-String key was skipped on JRuby, but it passes there now. Part of https://github.com/ruby/json/issues/1123 --- test/json/json_parser_test.rb | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/test/json/json_parser_test.rb b/test/json/json_parser_test.rb index 0549f6f4c..39dd62f26 100644 --- a/test/json/json_parser_test.rb +++ b/test/json/json_parser_test.rb @@ -481,11 +481,9 @@ def test_parse_duplicate_key assert_raise(ParserError) { parse('{"a": 1, "a": 2}', allow_duplicate_key: false) } assert_raise(ParserError) { parse('{"a": 1, "a": 2}', allow_duplicate_key: false, symbolize_names: true) } - unless RUBY_ENGINE == 'jruby' - assert_raise(ParserError) do - fake_key = Object.new - JSON.load('{"a": 1, "a": 2}', -> (obj) { obj == "a" ? fake_key : obj }, allow_duplicate_key: false) - end + assert_raise(ParserError) do + fake_key = Object.new + JSON.load('{"a": 1, "a": 2}', -> (obj) { obj == "a" ? fake_key : obj }, allow_duplicate_key: false) end end