From 6372bac60a5c4d6dbcea12e7b4eb82e08aa57087 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 11 Aug 2023 12:04:56 +1200 Subject: [PATCH] Introduce new "Safe" JSON serialization implementation. --- lib/console/output/encoder.rb | 1 + lib/console/output/json.rb | 92 ++++++++++++++++++++++++++++++++-- test/console/output.rb | 93 +++++++++++++++++++++-------------- 3 files changed, 145 insertions(+), 41 deletions(-) diff --git a/lib/console/output/encoder.rb b/lib/console/output/encoder.rb index ba49953..a9bb5e4 100644 --- a/lib/console/output/encoder.rb +++ b/lib/console/output/encoder.rb @@ -5,6 +5,7 @@ module Console module Output + # @deprecated With no replacement. class Encoder def initialize(output, encoding = ::Encoding::UTF_8) @output = output diff --git a/lib/console/output/json.rb b/lib/console/output/json.rb index b9a9043..3891084 100644 --- a/lib/console/output/json.rb +++ b/lib/console/output/json.rb @@ -4,16 +4,98 @@ # Copyright, 2021-2022, by Samuel Williams. require_relative '../serialized/logger' -require_relative 'encoder' module Console module Output module JSON + # This is a safe JSON serializer that will not raise exceptions. + class Safe + def initialize(limit: 8, encoding: ::Encoding::UTF_8) + @limit = limit + @encoding = encoding + end + + def dump(object) + ::JSON.dump(object, @limit) + rescue => error + ::JSON.dump(safe_dump(object, error)) + end + + private + + def default_objects + Hash.new.compare_by_identity + end + + def safe_dump(object, error) + object = safe_dump_recurse(object) + + object[:truncated] = true + object[:error] = { + class: safe_dump_recurse(error.class.name), + message: safe_dump_recurse(error.message), + } + + return object + end + + def replacement_for(object) + case object + when Array + "[...]" + when Hash + "{...}" + else + "..." + end + end + + # This will recursively generate a safe version of the object. + # Nested hashes and arrays will be transformed recursively. + # Strings will be encoded with the given encoding. + # Primitive values will be returned as-is. + # Other values will be converted using `as_json` if available, otherwise `to_s`. + def safe_dump_recurse(object, limit = @limit, objects = default_objects) + if limit <= 0 || objects[object] + return replacement_for(object) + end + + case object + when Hash + objects[object] = true + + object.to_h do |key, value| + [ + String(key).encode(@encoding, invalid: :replace, undef: :replace), + safe_dump_recurse(value, limit - 1, objects) + ] + end + when Array + objects[object] = true + + object.map do |value| + safe_dump_recurse(value, limit - 1, objects) + end + when String + object.encode(@encoding, invalid: :replace, undef: :replace) + when Numeric, TrueClass, FalseClass, NilClass + object + else + objects[object] = true + + # We could do something like this but the chance `as_json` will blow up. + # We'd need to be extremely careful about it. + # if object.respond_to?(:as_json) + # safe_dump_recurse(object.as_json, limit - 1, objects) + # else + + safe_dump_recurse(object.to_s, limit - 1, objects) + end + end + end + def self.new(output, **options) - # The output encoder can prevent encoding issues (e.g. invalid UTF-8): - Output::Encoder.new( - Serialized::Logger.new(output, format: ::JSON, **options) - ) + Serialized::Logger.new(output, format: Safe.new, **options) end end end diff --git a/test/console/output.rb b/test/console/output.rb index 6acaf94..f956321 100644 --- a/test/console/output.rb +++ b/test/console/output.rb @@ -8,71 +8,68 @@ require 'my_custom_output' describe Console::Output do + let(:capture) {StringIO.new} + let(:env) {Hash.new} + let(:output) {Console::Output.new(capture, env)} + describe '.new' do with 'output to a file' do - let(:env) {Hash.new} - let(:output) {File.open('/tmp/console.log', 'w')} + let(:capture) {File.open('/tmp/console.log', 'w')} it 'should use a serialized format' do - expect(Console::Output.new(output, env).output).to be_a(Console::Serialized::Logger) + expect(output).to be_a(Console::Serialized::Logger) end end with 'output to $stderr' do - let(:env) {Hash.new} - let(:output) {$stderr} + let(:capture) {$stderr} it 'should use a terminal format' do expect($stderr).to receive(:tty?).and_return(true) - expect(Console::Output.new($stderr, env)).to be_a Console::Terminal::Logger + expect(output).to be_a Console::Terminal::Logger end end - it 'can set output to Serialized and format to JSON by ENV' do - output = Console::Output.new(StringIO.new, {'CONSOLE_OUTPUT' => 'JSON'}) - expect(output).to be_a(Console::Output::Encoder) - - output = output.output - expect(output).to be_a Console::Serialized::Logger - expect(output.format).to be == JSON + with env: {'CONSOLE_OUTPUT' => 'JSON'} do + it 'can set output to Serialized and format to JSON' do + expect(output).to be_a Console::Serialized::Logger + expect(output.format).to be_a(Console::Output::JSON::Safe) + end end - it 'can set output to Serialized using custom format by ENV' do - output = Console::Output.new(StringIO.new, {'CONSOLE_OUTPUT' => 'MyCustomOutput'}) - - expect(output).to be_a MyCustomOutput + with env: {'CONSOLE_OUTPUT' => 'MyCustomOutput'} do + it 'can set output to Serialized using custom format by ENV' do + expect(output).to be_a MyCustomOutput + end end - it 'raises error until the given format class is available' do - expect { - Console::Output.new(nil, {'CONSOLE_OUTPUT' => 'InvalidOutput'}) - }.to raise_exception(NameError, message: be =~ /Console::Output::InvalidOutput/) + with env: {'CONSOLE_OUTPUT' => 'InvalidOutput'} do + it 'raises error until the given format class is available' do + expect{output}.to raise_exception(NameError, message: be =~ /Console::Output::InvalidOutput/) + end end - it 'can force format to XTerm for non tty output by ENV' do - io = StringIO.new - expect(Console::Terminal).not.to receive(:for) - output = Console::Output.new(io, {'CONSOLE_OUTPUT' => 'XTerm'}) - expect(output).to be_a Console::Terminal::Logger - expect(output.terminal).to be_a Console::Terminal::XTerm + with env: {'CONSOLE_OUTPUT' => 'XTerm'} do + it 'can force format to XTerm for non tty output by ENV' do + expect(Console::Terminal).not.to receive(:for) + expect(output).to be_a Console::Terminal::Logger + expect(output.terminal).to be_a Console::Terminal::XTerm + end end - it 'can force format to text for tty output by ENV using Text' do - io = StringIO.new - expect(Console::Terminal).not.to receive(:for) - output = Console::Output.new(io, {'CONSOLE_OUTPUT' => 'Text'}) - expect(output).to be_a Console::Terminal::Logger - expect(output.terminal).to be_a Console::Terminal::Text + with env: {'CONSOLE_OUTPUT' => 'Text'} do + it 'can force format to text for tty output by ENV using Text' do + expect(Console::Terminal).not.to receive(:for) + expect(output).to be_a Console::Terminal::Logger + expect(output.terminal).to be_a Console::Terminal::Text + end end end with "invalid UTF-8" do - let(:capture) {StringIO.new} - it "should replace invalid characters" do expect(capture).to receive(:tty?).and_return(false) - output = Console::Output.new(capture, {}) output.call("Hello \xFF") @@ -80,4 +77,28 @@ expect(message['subject']).to be == "Hello \uFFFD" end end + + with "recursive arguments" do + it "should replace top level recursion" do + arguments = [] + arguments << arguments + + output.call("Hello", arguments) + + message = JSON.parse(capture.string) + expect(message['truncated']).to be == true + expect(message['message']).to be == ["[...]"] + end + + it "should replace nested recursion" do + arguments = {} + arguments[:arguments] = arguments + + output.call("Hello", arguments) + + message = JSON.parse(capture.string) + expect(message['truncated']).to be == true + expect(message['message']).to be == {"arguments"=>"{...}"} + end + end end