lib/net/imap/config.rb



# frozen_string_literal: true

require_relative "config/attr_accessors"
require_relative "config/attr_inheritance"
require_relative "config/attr_type_coercion"

module Net
  class IMAP

    # Net::IMAP::Config <em>(available since +v0.4.13+)</em> stores
    # configuration options for Net::IMAP clients.  The global configuration can
    # be seen at either Net::IMAP.config or Net::IMAP::Config.global, and the
    # client-specific configuration can be seen at Net::IMAP#config.
    #
    # When creating a new client, all unhandled keyword arguments to
    # Net::IMAP.new are delegated to Config.new.  Every client has its own
    # config.
    #
    #   debug_client = Net::IMAP.new(hostname, debug: true)
    #   quiet_client = Net::IMAP.new(hostname, debug: false)
    #   debug_client.config.debug?  # => true
    #   quiet_client.config.debug?  # => false
    #
    # == Inheritance
    #
    # Configs have a parent[rdoc-ref:Config::AttrInheritance#parent] config, and
    # any attributes which have not been set locally will inherit the parent's
    # value.  Every client creates its own specific config.  By default, client
    # configs inherit from Config.global.
    #
    #   plain_client = Net::IMAP.new(hostname)
    #   debug_client = Net::IMAP.new(hostname, debug: true)
    #   quiet_client = Net::IMAP.new(hostname, debug: false)
    #
    #   plain_client.config.inherited?(:debug)  # => true
    #   debug_client.config.inherited?(:debug)  # => false
    #   quiet_client.config.inherited?(:debug)  # => false
    #
    #   plain_client.config.debug?  # => false
    #   debug_client.config.debug?  # => true
    #   quiet_client.config.debug?  # => false
    #
    #   # Net::IMAP.debug is delegated to Net::IMAP::Config.global.debug
    #   Net::IMAP.debug = true
    #   plain_client.config.debug?  # => true
    #   debug_client.config.debug?  # => true
    #   quiet_client.config.debug?  # => false
    #
    #   Net::IMAP.debug = false
    #   plain_client.config.debug = true
    #   plain_client.config.inherited?(:debug)  # => false
    #   plain_client.config.debug?  # => true
    #   plain_client.config.reset(:debug)
    #   plain_client.config.inherited?(:debug)  # => true
    #   plain_client.config.debug?  # => false
    #
    # == Versioned defaults
    #
    # The effective default configuration for a specific +x.y+ version of
    # +net-imap+ can be loaded with the +config+ keyword argument to
    # Net::IMAP.new.  Requesting default configurations for previous versions
    # enables extra backward compatibility with those versions:
    #
    #   client = Net::IMAP.new(hostname, config: 0.3)
    #   client.config.sasl_ir                  # => false
    #   client.config.responses_without_block  # => :silence_deprecation_warning
    #
    #   client = Net::IMAP.new(hostname, config: 0.4)
    #   client.config.sasl_ir                  # => true
    #   client.config.responses_without_block  # => :silence_deprecation_warning
    #
    #   client = Net::IMAP.new(hostname, config: 0.5)
    #   client.config.sasl_ir                  # => true
    #   client.config.responses_without_block  # => :warn
    #
    #   client = Net::IMAP.new(hostname, config: :future)
    #   client.config.sasl_ir                  # => true
    #   client.config.responses_without_block  # => :frozen_dup
    #
    # The versioned default configs inherit certain specific config options from
    # Config.global, for example #debug:
    #
    #   client = Net::IMAP.new(hostname, config: 0.4)
    #   Net::IMAP.debug = false
    #   client.config.debug?  # => false
    #
    #   Net::IMAP.debug = true
    #   client.config.debug?  # => true
    #
    # Use #load_defaults to globally behave like a specific version:
    #   client = Net::IMAP.new(hostname)
    #   client.config.sasl_ir              # => true
    #   Net::IMAP.config.load_defaults 0.3
    #   client.config.sasl_ir              # => false
    #
    # === Named defaults
    # In addition to +x.y+ version numbers, the following aliases are supported:
    #
    # [+:default+]
    #   An alias for +:current+.
    #
    #   >>>
    #   *NOTE*: This is _not_ the same as Config.default.  It inherits some
    #   attributes from Config.global, for example: #debug.
    # [+:current+]
    #   An alias for the current +x.y+ version's defaults.
    # [+:next+]
    #   The _planned_ config for the next +x.y+ version.
    # [+:future+]
    #   The _planned_ eventual config for some future +x.y+ version.
    #
    # For example, to disable all currently deprecated behavior:
    #   client = Net::IMAP.new(hostname, config: :future)
    #   client.config.response_without_args     # => :frozen_dup
    #   client.responses.frozen?                # => true
    #   client.responses.values.all?(&:frozen?) # => true
    #
    # == Thread Safety
    #
    # *NOTE:* Updates to config objects are not synchronized for thread-safety.
    #
    class Config
      # Array of attribute names that are _not_ loaded by #load_defaults.
      DEFAULT_TO_INHERIT = %i[debug].freeze
      private_constant :DEFAULT_TO_INHERIT

      # The default config, which is hardcoded and frozen.
      def self.default; @default end

      # The global config object.  Also available from Net::IMAP.config.
      def self.global; @global if defined?(@global) end

      # A hash of hard-coded configurations, indexed by version number or name.
      # Values can be accessed with any object that responds to +to_sym+ or
      # +to_r+/+to_f+ with a non-zero number.
      #
      # Config::[] gets named or numbered versions from this hash.
      #
      # For example:
      #     Net::IMAP::Config.version_defaults[0.5] == Net::IMAP::Config[0.5]
      #     Net::IMAP::Config[0.5]       == Net::IMAP::Config[0.5r]     # => true
      #     Net::IMAP::Config["current"] == Net::IMAP::Config[:current] # => true
      #     Net::IMAP::Config["0.5.6"]   == Net::IMAP::Config[0.5r]     # => true
      def self.version_defaults; @version_defaults end
      @version_defaults = Hash.new {|h, k|
        # NOTE: String responds to both so the order is significant.
        # And ignore non-numeric conversion to zero, because: "wat!?".to_r == 0
        (h.fetch(k.to_r, nil) || h.fetch(k.to_f, nil) if k.is_a?(Numeric)) ||
          (h.fetch(k.to_sym, nil) if k.respond_to?(:to_sym)) ||
          (h.fetch(k.to_r,   nil) if k.respond_to?(:to_r) && k.to_r != 0r) ||
          (h.fetch(k.to_f,   nil) if k.respond_to?(:to_f) && k.to_f != 0.0)
      }

      # :call-seq:
      #  Net::IMAP::Config[number] -> versioned config
      #  Net::IMAP::Config[symbol] -> named config
      #  Net::IMAP::Config[hash]   -> new frozen config
      #  Net::IMAP::Config[config] -> same config
      #
      # Given a version number, returns the default configuration for the target
      # version.  See Config@Versioned+defaults.
      #
      # Given a version name, returns the default configuration for the target
      # version.  See Config@Named+defaults.
      #
      # Given a Hash, creates a new _frozen_ config which inherits from
      # Config.global.  Use Config.new for an unfrozen config.
      #
      # Given a config, returns that same config.
      def self.[](config)
        if    config.is_a?(Config)         then config
        elsif config.nil? && global.nil?   then nil
        elsif config.respond_to?(:to_hash) then new(global, **config).freeze
        else
          version_defaults[config] or
            case config
            when Numeric
              raise RangeError, "unknown config version: %p" % [config]
            when String, Symbol
              raise KeyError, "unknown config name: %p" % [config]
            else
              raise TypeError, "no implicit conversion of %s to %s" % [
                config.class, Config
              ]
            end
        end
      end

      include AttrAccessors
      include AttrInheritance
      include AttrTypeCoercion

      # The debug mode (boolean).  The default value is +false+.
      #
      # When #debug is +true+:
      # * Data sent to and received from the server will be logged.
      # * ResponseParser will print warnings with extra detail for parse
      #   errors.  _This may include recoverable errors._
      # * ResponseParser makes extra assertions.
      #
      # *NOTE:* Versioned default configs inherit #debug from Config.global, and
      # #load_defaults will not override #debug.
      attr_accessor :debug, type: :boolean

      # method: debug?
      # :call-seq: debug? -> boolean
      #
      # Alias for #debug

      # Seconds to wait until a connection is opened.
      #
      # Applied separately for establishing TCP connection and starting a TLS
      # connection.
      #
      # If the IMAP object cannot open a connection within this time,
      # it raises a Net::OpenTimeout exception.
      #
      # See Net::IMAP.new and Net::IMAP#starttls.
      #
      # The default value is +30+ seconds.
      attr_accessor :open_timeout, type: Integer

      # Seconds to wait until an IDLE response is received, after
      # the client asks to leave the IDLE state.
      #
      # See Net::IMAP#idle and Net::IMAP#idle_done.
      #
      # The default value is +5+ seconds.
      attr_accessor :idle_response_timeout, type: Integer

      # Whether to use the +SASL-IR+ extension when the server and \SASL
      # mechanism both support it.  Can be overridden by the +sasl_ir+ keyword
      # parameter to Net::IMAP#authenticate.
      #
      # <em>(Support for +SASL-IR+ was added in +v0.4.0+.)</em>
      #
      # ==== Valid options
      #
      # [+false+ <em>(original behavior, before support was added)</em>]
      #   Do not use +SASL-IR+, even when it is supported by the server and the
      #   mechanism.
      #
      # [+true+ <em>(default since +v0.4+)</em>]
      #   Use +SASL-IR+ when it is supported by the server and the mechanism.
      attr_accessor :sasl_ir, type: :boolean

      # Controls the behavior of Net::IMAP#login when the +LOGINDISABLED+
      # capability is present.  When enforced, Net::IMAP will raise a
      # LoginDisabledError when that capability is present.
      #
      # <em>(Support for +LOGINDISABLED+ was added in +v0.5.0+.)</em>
      #
      # ==== Valid options
      #
      # [+false+ <em>(original behavior, before support was added)</em>]
      #   Send the +LOGIN+ command without checking for +LOGINDISABLED+.
      #
      # [+:when_capabilities_cached+]
      #   Enforce the requirement when Net::IMAP#capabilities_cached? is true,
      #   but do not send a +CAPABILITY+ command to discover the capabilities.
      #
      # [+true+ <em>(default since +v0.5+)</em>]
      #   Only send the +LOGIN+ command if the +LOGINDISABLED+ capability is not
      #   present.  When capabilities are unknown, Net::IMAP will automatically
      #   send a +CAPABILITY+ command first before sending +LOGIN+.
      #
      attr_accessor :enforce_logindisabled, type: Enum[
        false, :when_capabilities_cached, true
      ]

      # The maximum allowed server response size.  When +nil+, there is no limit
      # on response size.
      #
      # The default value (512 MiB, since +v0.5.7+) is <em>very high</em> and
      # unlikely to be reached.  A _much_ lower value should be used with
      # untrusted servers (for example, when connecting to a user-provided
      # hostname).  When using a lower limit, message bodies should be fetched
      # in chunks rather than all at once.
      #
      # <em>Please Note:</em> this only limits the size per response.  It does
      # not prevent a flood of individual responses and it does not limit how
      # many unhandled responses may be stored on the responses hash.  See
      # Net::IMAP@Unbounded+memory+use.
      #
      # Socket reads are limited to the maximum remaining bytes for the current
      # response: max_response_size minus the bytes that have already been read.
      # When the limit is reached, or reading a +literal+ _would_ go over the
      # limit, ResponseTooLargeError is raised and the connection is closed.
      #
      # Note that changes will not take effect immediately, because the receiver
      # thread may already be waiting for the next response using the previous
      # value.  Net::IMAP#noop can force a response and enforce the new setting
      # immediately.
      #
      # ==== Versioned Defaults
      #
      # Net::IMAP#max_response_size <em>was added in +v0.2.5+ and +v0.3.9+ as an
      # attr_accessor, and in +v0.4.20+ and +v0.5.7+ as a delegator to this
      # config attribute.</em>
      #
      # * original: +nil+ <em>(no limit)</em>
      # * +0.5+: 512 MiB
      attr_accessor :max_response_size, type: Integer?

      # Controls the behavior of Net::IMAP#responses when called without any
      # arguments (+type+ or +block+).
      #
      # ==== Valid options
      #
      # [+:silence_deprecation_warning+ <em>(original behavior)</em>]
      #   Returns the mutable responses hash (without any warnings).
      #   <em>This is not thread-safe.</em>
      #
      # [+:warn+ <em>(default since +v0.5+)</em>]
      #   Prints a warning and returns the mutable responses hash.
      #   <em>This is not thread-safe.</em>
      #
      # [+:frozen_dup+ <em>(planned default for +v0.6+)</em>]
      #   Returns a frozen copy of the unhandled responses hash, with frozen
      #   array values.
      #
      #   Note that calling IMAP#responses with a +type+ and without a block is
      #   not configurable and always behaves like +:frozen_dup+.
      #
      #   <em>(+:frozen_dup+ config option was added in +v0.4.17+)</em>
      #
      # [+:raise+]
      #   Raise an ArgumentError with the deprecation warning.
      #
      # Note: #responses_without_args is an alias for #responses_without_block.
      attr_accessor :responses_without_block, type: Enum[
        :silence_deprecation_warning, :warn, :frozen_dup, :raise,
      ]

      alias responses_without_args  responses_without_block  # :nodoc:
      alias responses_without_args= responses_without_block= # :nodoc:

      ##
      # :attr_accessor: responses_without_args
      #
      # Alias for responses_without_block

      # Whether ResponseParser should use the deprecated UIDPlusData or
      # CopyUIDData for +COPYUID+ response codes, and UIDPlusData or
      # AppendUIDData for +APPENDUID+ response codes.
      #
      # UIDPlusData stores its data in arrays of numbers, which is vulnerable to
      # a memory exhaustion denial of service attack from an untrusted or
      # compromised server.  Set this option to +false+ to completely block this
      # vulnerability.  Otherwise, parser_max_deprecated_uidplus_data_size
      # mitigates this vulnerability.
      #
      # AppendUIDData and CopyUIDData are _mostly_ backward-compatible with
      # UIDPlusData.  Most applications should be able to upgrade with little
      # or no changes.
      #
      # <em>(Parser support for +UIDPLUS+ added in +v0.3.2+.)</em>
      #
      # <em>(Config option added in +v0.4.19+ and +v0.5.6+.)</em>
      #
      # <em>UIDPlusData will be removed in +v0.6+ and this config setting will
      # be ignored.</em>
      #
      # ==== Valid options
      #
      # [+true+ <em>(original default)</em>]
      #    ResponseParser only uses UIDPlusData.
      #
      # [+:up_to_max_size+ <em>(default since +v0.5.6+)</em>]
      #    ResponseParser uses UIDPlusData when the +uid-set+ size is below
      #    parser_max_deprecated_uidplus_data_size.  Above that size,
      #    ResponseParser uses AppendUIDData or CopyUIDData.
      #
      # [+false+ <em>(planned default for +v0.6+)</em>]
      #    ResponseParser _only_ uses AppendUIDData and CopyUIDData.
      attr_accessor :parser_use_deprecated_uidplus_data, type: Enum[
        true, :up_to_max_size, false
      ]

      # The maximum +uid-set+ size that ResponseParser will parse into
      # deprecated UIDPlusData.  This limit only applies when
      # parser_use_deprecated_uidplus_data is not +false+.
      #
      # <em>(Parser support for +UIDPLUS+ added in +v0.3.2+.)</em>
      #
      # <em>Support for limiting UIDPlusData to a maximum size was added in
      # +v0.3.8+, +v0.4.19+, and +v0.5.6+.</em>
      #
      # <em>UIDPlusData will be removed in +v0.6+.</em>
      #
      # ==== Versioned Defaults
      #
      # Because this limit guards against a remote server causing catastrophic
      # memory exhaustion, the versioned default (used by #load_defaults) also
      # applies to versions without the feature.
      #
      # * +0.3+ and prior: <tt>10,000</tt>
      # * +0.4+: <tt>1,000</tt>
      # * +0.5+: <tt>100</tt>
      # * +0.6+: <tt>0</tt>
      #
      attr_accessor :parser_max_deprecated_uidplus_data_size, type: Integer

      # Creates a new config object and initialize its attribute with +attrs+.
      #
      # If +parent+ is not given, the global config is used by default.
      #
      # If a block is given, the new config object is yielded to it.
      def initialize(parent = Config.global, **attrs)
        super(parent)
        update(**attrs)
        yield self if block_given?
      end

      # :call-seq: update(**attrs) -> self
      #
      # Assigns all of the provided +attrs+ to this config, and returns +self+.
      #
      # An ArgumentError is raised unless every key in +attrs+ matches an
      # assignment method on Config.
      #
      # >>>
      #   *NOTE:*  #update is not atomic.  If an exception is raised due to an
      #   invalid attribute value, +attrs+ may be partially applied.
      def update(**attrs)
        unless (bad = attrs.keys.reject { respond_to?(:"#{_1}=") }).empty?
          raise ArgumentError, "invalid config options: #{bad.join(", ")}"
        end
        attrs.each do send(:"#{_1}=", _2) end
        self
      end

      # :call-seq:
      #   with(**attrs) -> config
      #   with(**attrs) {|config| } -> result
      #
      # Without a block, returns a new config which inherits from self.  With a
      # block, yields the new config and returns the block's result.
      #
      # If no keyword arguments are given, an ArgumentError will be raised.
      #
      # If +self+ is frozen, the copy will also be frozen.
      def with(**attrs)
        attrs.empty? and
          raise ArgumentError, "expected keyword arguments, none given"
        copy = new(**attrs)
        copy.freeze if frozen?
        block_given? ? yield(copy) : copy
      end

      # :call-seq: load_defaults(version) -> self
      #
      # Resets the current config to behave like the versioned default
      # configuration for +version+.  #parent will not be changed.
      #
      # Some config attributes default to inheriting from their #parent (which
      # is usually Config.global) and are left unchanged, for example: #debug.
      #
      # See Config@Versioned+defaults and Config@Named+defaults.
      def load_defaults(version)
        [Numeric, Symbol, String].any? { _1 === version } or
          raise ArgumentError, "expected number or symbol, got %p" % [version]
        update(**Config[version].defaults_hash)
      end

      # :call-seq: to_h -> hash
      #
      # Returns all config attributes in a hash.
      def to_h; data.members.to_h { [_1, send(_1)] } end

      protected

      def defaults_hash
        to_h.reject {|k,v| DEFAULT_TO_INHERIT.include?(k) }
      end

      @default = new(
        debug: false,
        open_timeout: 30,
        idle_response_timeout: 5,
        sasl_ir: true,
        enforce_logindisabled: true,
        max_response_size: 512 << 20, # 512 MiB
        responses_without_block: :warn,
        parser_use_deprecated_uidplus_data: :up_to_max_size,
        parser_max_deprecated_uidplus_data_size: 100,
      ).freeze

      @global = default.new

      version_defaults[:default] = Config[default.send(:defaults_hash)]

      version_defaults[0r] = Config[:default].dup.update(
        sasl_ir: false,
        responses_without_block: :silence_deprecation_warning,
        enforce_logindisabled: false,
        max_response_size: nil,
        parser_use_deprecated_uidplus_data: true,
        parser_max_deprecated_uidplus_data_size: 10_000,
      ).freeze
      version_defaults[0.0r] = Config[0r]
      version_defaults[0.1r] = Config[0r]
      version_defaults[0.2r] = Config[0r]
      version_defaults[0.3r] = Config[0r]

      version_defaults[0.4r] = Config[0.3r].dup.update(
        sasl_ir: true,
        parser_max_deprecated_uidplus_data_size: 1000,
      ).freeze

      version_defaults[0.5r] = Config[0.4r].dup.update(
        enforce_logindisabled: true,
        max_response_size: 512 << 20, # 512 MiB
        responses_without_block: :warn,
        parser_use_deprecated_uidplus_data: :up_to_max_size,
        parser_max_deprecated_uidplus_data_size: 100,
      ).freeze

      version_defaults[0.6r] = Config[0.5r].dup.update(
        responses_without_block: :frozen_dup,
        parser_use_deprecated_uidplus_data: false,
        parser_max_deprecated_uidplus_data_size: 0,
      ).freeze

      version_defaults[0.7r] = Config[0.6r].dup.update(
      ).freeze

      # Safe conversions one way only:
      #   0.6r.to_f == 0.6  # => true
      #   0.6 .to_r == 0.6r # => false
      version_defaults.to_a.each do |k, v|
        next unless k in Rational
        version_defaults[k.to_f] = v
      end

      current = VERSION.to_r
      version_defaults[:original] = Config[0]
      version_defaults[:current]  = Config[current]
      version_defaults[:next]     = Config[current + 0.1r]
      version_defaults[:future]   = Config[current + 0.2r]

      version_defaults.freeze

      if ($VERBOSE || $DEBUG) && self[:current].to_h != self[:default].to_h
        warn "Misconfigured Net::IMAP::Config[:current] => %p,\n" \
             " not equal to Net::IMAP::Config[:default] => %p" % [
                self[:current].to_h, self[:default].to_h
              ]
      end
    end
  end
end