src/derails/passive_resistance/core_ext/string/output_safety.rb
# frozen_string_literal: true

require "passive_resistance/core_ext/erb/util"
require "passive_resistance/multibyte/unicode"

class Object
  def html_safe?
    false
  end
end

class Numeric
  def html_safe?
    true
  end
end

module PassiveResistance # :nodoc:
  class SafeBuffer < String
    UNSAFE_STRING_METHODS = %w(
      capitalize chomp chop delete delete_prefix delete_suffix
      downcase lstrip next reverse rstrip scrub squeeze strip
      succ swapcase tr tr_s unicode_normalize upcase
    )

    UNSAFE_STRING_METHODS_WITH_BACKREF = %w(gsub sub)

    alias_method :original_concat, :concat
    private :original_concat

    # Raised when PassiveResistance::SafeBuffer#safe_concat is called on unsafe buffers.
    class SafeConcatError < StandardError
      def initialize
        super "Could not concatenate to the buffer because it is not HTML safe."
      end
    end

    def [](*args)
      if html_safe?
        new_string = super

        return unless new_string

        string_into_safe_buffer(new_string, true)
      else
        to_str[*args]
      end
    end
    alias_method :slice, :[]

    def slice!(*args)
      new_string = super

      return new_string if !html_safe? || new_string.nil?

      string_into_safe_buffer(new_string, true)
    end

    def chr
      return super unless html_safe?

      string_into_safe_buffer(super, true)
    end

    def safe_concat(value)
      raise SafeConcatError unless html_safe?
      original_concat(value)
    end

    def initialize(_str = "")
      super
    end

    def initialize_copy(other)
      super
      @html_unsafe = true unless other.html_safe?
    end

    def concat(value)
      unless value.nil?
        super(implicit_html_escape_interpolated_argument(value))
      end
      self
    end
    alias << concat

    def bytesplice(*args, value)
      super(*args, implicit_html_escape_interpolated_argument(value))
    end

    def insert(index, value)
      super(index, implicit_html_escape_interpolated_argument(value))
    end

    def prepend(value)
      super(implicit_html_escape_interpolated_argument(value))
    end

    def replace(value)
      super(implicit_html_escape_interpolated_argument(value))
    end

    def []=(arg1, arg2, arg3 = nil)
      if arg3
        super(arg1, arg2, implicit_html_escape_interpolated_argument(arg3))
      else
        super(arg1, implicit_html_escape_interpolated_argument(arg2))
      end
    end

    def +(other)
      dup.concat(other)
    end

    def *(_)
      new_string = super
      new_safe_buffer = new_string.is_a?(SafeBuffer) ? new_string : SafeBuffer.new(new_string)
      if @html_unsafe
        new_safe_buffer.instance_variable_set(:@html_unsafe, true)
      end
      new_safe_buffer
    end

    def %(args)
      case args
      when Hash
        escaped_args = args.transform_values { |arg| explicit_html_escape_interpolated_argument(arg) }
      else
        escaped_args = Array(args).map { |arg| explicit_html_escape_interpolated_argument(arg) }
      end

      self.class.new(super(escaped_args))
    end

    def html_safe?
      @html_unsafe.nil?
    end

    def to_s
      self
    end

    def as_json(*)
      to_str
    end

    def to_param
      to_str
    end

    def encode_with(coder)
      coder.represent_object nil, to_str
    end

    UNSAFE_STRING_METHODS.each do |unsafe_method|
      if unsafe_method.respond_to?(unsafe_method)
        class_eval <<-EOT, __FILE__, __LINE__ + 1
          def #{unsafe_method}(*args, &block)       # def capitalize(*args, &block)
            to_str.#{unsafe_method}(*args, &block)  #   to_str.capitalize(*args, &block)
          end                                       # end

          def #{unsafe_method}!(*args)              # def capitalize!(*args)
            @html_unsafe = true                     #   @html_unsafe = true
            super                                   #   super
          end                                       # end
        EOT
      end
    end

    UNSAFE_STRING_METHODS_WITH_BACKREF.each do |unsafe_method|
      class_eval <<-EOT, __FILE__, __LINE__ + 1
        def #{unsafe_method}(*args, &block)             # def gsub(*args, &block)
          if block                                      #   if block
            to_str.#{unsafe_method}(*args) { |*params|  #     to_str.gsub(*args) { |*params|
              set_block_back_references(block, $~)      #       set_block_back_references(block, $~)
              block.call(*params)                       #       block.call(*params)
            }                                           #     }
          else                                          #   else
            to_str.#{unsafe_method}(*args)              #     to_str.gsub(*args)
          end                                           #   end
        end                                             # end

        def #{unsafe_method}!(*args, &block)            # def gsub!(*args, &block)
          @html_unsafe = true                           #   @html_unsafe = true
          if block                                      #   if block
            super(*args) { |*params|                    #     super(*args) { |*params|
              set_block_back_references(block, $~)      #       set_block_back_references(block, $~)
              block.call(*params)                       #       block.call(*params)
            }                                           #     }
          else                                          #   else
            super                                       #     super
          end                                           #   end
        end                                             # end
      EOT
    end

    private
      def explicit_html_escape_interpolated_argument(arg)
        (!html_safe? || arg.html_safe?) ? arg : ERB::Util.unwrapped_html_escape(arg)
      end

      def implicit_html_escape_interpolated_argument(arg)
        if !html_safe? || arg.html_safe?
          arg
        else
          ERB::Util.unwrapped_html_escape(arg.to_str)
        end
      end

      def set_block_back_references(block, match_data)
        block.binding.eval("proc { |m| $~ = m }").call(match_data)
      rescue ArgumentError
        # Can't create binding from C level Proc
      end

      def string_into_safe_buffer(new_string, is_html_safe)
        new_safe_buffer = new_string.is_a?(SafeBuffer) ? new_string : SafeBuffer.new(new_string)
        unless is_html_safe
          new_safe_buffer.instance_variable_set :@html_unsafe, true
        end
        new_safe_buffer
      end
  end
end

class String
  # Marks a string as trusted safe. It will be inserted into HTML with no
  # additional escaping performed. It is your responsibility to ensure that the
  # string contains no malicious content. This method is equivalent to the
  # +raw+ helper in views. It is recommended that you use +sanitize+ instead of
  # this method. It should never be called on user input.
  def html_safe
    PassiveResistance::SafeBuffer.new(self)
  end
end
