diff --git a/lib/net/imap.rb b/lib/net/imap.rb index 96ec3a50..232b3728 100644 --- a/lib/net/imap.rb +++ b/lib/net/imap.rb @@ -725,6 +725,8 @@ class IMAP < Protocol "UTF8=ONLY" => "UTF8=ACCEPT", }.freeze + autoload :Config, File.expand_path("imap/config", __dir__) + autoload :SASL, File.expand_path("imap/sasl", __dir__) autoload :SASLAdapter, File.expand_path("imap/sasl_adapter", __dir__) autoload :StringPrep, File.expand_path("imap/stringprep", __dir__) @@ -735,14 +737,15 @@ class IMAP < Protocol include SSL end - # Returns the debug mode. - def self.debug - return @@debug - end + # Returns the global Config object + def self.config; Config.global end - # Sets the debug mode. + # Returns the global debug mode. + def self.debug; config.debug end + + # Sets the global debug mode. def self.debug=(val) - return @@debug = val + config.debug = val end # The default port for IMAP connections, port 143 @@ -764,13 +767,18 @@ class << self # Returns the initial greeting the server, an UntaggedResponse. attr_reader :greeting + # The client configuration. See Net::IMAP::Config. + # + # By default, config inherits from the global Net::IMAP.config. + attr_reader :config + # Seconds to wait until a connection is opened. # If the IMAP object cannot open a connection within this time, # it raises a Net::OpenTimeout exception. The default value is 30 seconds. - attr_reader :open_timeout + def open_timeout; config.open_timeout end # Seconds to wait until an IDLE response is received. - attr_reader :idle_response_timeout + def idle_response_timeout; config.idle_response_timeout end # The hostname this client connected to attr_reader :host @@ -811,6 +819,15 @@ class << self # the keys are names of attribute assignment methods on # SSLContext[https://docs.ruby-lang.org/en/master/OpenSSL/SSL/SSLContext.html]. # + # [config] + # A Net::IMAP::Config object to base the client #config on. By default + # the global Net::IMAP.config is used. Note that this sets the _parent_ + # config object for inheritance. Every Net::IMAP client has its own + # unique #config for overrides. + # + # Any other keyword arguments will be forwarded to Config.new, to create the + # client's #config. For example: + # # [open_timeout] # Seconds to wait until a connection is opened # [idle_response_timeout] @@ -872,13 +889,12 @@ class << self # Connected to the host successfully, but it immediately said goodbye. # def initialize(host, port: nil, ssl: nil, - open_timeout: 30, idle_response_timeout: 5) + config: Config.global, **config_options) super() # Config options @host = host + @config = Config.new(config, **config_options) @port = port || (ssl ? SSL_PORT : PORT) - @open_timeout = Integer(open_timeout) - @idle_response_timeout = Integer(idle_response_timeout) @ssl_ctx_params, @ssl_ctx = build_ssl_ctx(ssl) # Basic Client State @@ -889,7 +905,7 @@ def initialize(host, port: nil, ssl: nil, @capabilities = nil # Client Protocol Receiver - @parser = ResponseParser.new + @parser = ResponseParser.new(config: @config) @responses = Hash.new {|h, k| h[k] = [] } @response_handlers = [] @receiver_thread = nil @@ -2434,7 +2450,7 @@ def idle(timeout = nil, &response_handler) unless @receiver_thread_terminating remove_response_handler(response_handler) put_string("DONE#{CRLF}") - response = get_tagged_response(tag, "IDLE", @idle_response_timeout) + response = get_tagged_response(tag, "IDLE", idle_response_timeout) end end end @@ -2590,8 +2606,6 @@ def remove_response_handler(handler) PORT = 143 # :nodoc: SSL_PORT = 993 # :nodoc: - @@debug = false - def start_imap_connection @greeting = get_server_greeting @capabilities = capabilities_from_resp_code @greeting @@ -2619,12 +2633,12 @@ def start_receiver_thread end def tcp_socket(host, port) - s = Socket.tcp(host, port, :connect_timeout => @open_timeout) + s = Socket.tcp(host, port, :connect_timeout => open_timeout) s.setsockopt(:SOL_SOCKET, :SO_KEEPALIVE, true) s rescue Errno::ETIMEDOUT raise Net::OpenTimeout, "Timeout to open TCP connection to " + - "#{host}:#{port} (exceeds #{@open_timeout} seconds)" + "#{host}:#{port} (exceeds #{open_timeout} seconds)" end def receive_responses @@ -2736,7 +2750,7 @@ def get_response end end return nil if buff.length == 0 - if @@debug + if config.debug? $stderr.print(buff.gsub(/^/n, "S: ")) end return @parser.parse(buff) @@ -2815,7 +2829,7 @@ def generate_tag def put_string(str) @sock.print(str) - if @@debug + if config.debug? if @debug_output_bol $stderr.print("C: ") end @@ -2942,7 +2956,7 @@ def start_tls_session @sock = SSLSocket.new(@sock, ssl_ctx) @sock.sync_close = true @sock.hostname = @host if @sock.respond_to? :hostname= - ssl_socket_connect(@sock, @open_timeout) + ssl_socket_connect(@sock, open_timeout) if ssl_ctx.verify_mode != VERIFY_NONE @sock.post_connection_check(@host) @tls_verified = true diff --git a/lib/net/imap/config.rb b/lib/net/imap/config.rb new file mode 100644 index 00000000..f8201c5c --- /dev/null +++ b/lib/net/imap/config.rb @@ -0,0 +1,109 @@ +# frozen_string_literal: true +# :markup: markdown + +require_relative "config/attr_accessors" +require_relative "config/attr_inheritance" +require_relative "config/attr_type_coercion" + +module Net + class IMAP + + # Net::IMAP::Config 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. + # + # ## 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 which inherits from Config.default. + # + # See the following methods, defined by Config::AttrInheritance: + # - {#new}[rdoc-ref:Config::AttrInheritance#reset] -- create a new config + # which inherits from the receiver. + # - {#inherited?}[rdoc-ref:Config::AttrInheritance#inherited?] -- return + # whether a particular attribute is inherited. + # - {#reset}[rdoc-ref:Config::AttrInheritance#reset] -- reset attributes to + # be inherited. + # + # ## Thread Safety + # + # *NOTE:* Updates to config objects are not synchronized for thread-safety. + # + class Config + # The default config, which is hardcoded and frozen. + def self.default; @default end + + # The global config object. + def self.global; @global end + + def self.[](config) # :nodoc: unfinished API + if config.is_a?(Config) || config.nil? && global.nil? + config + else + raise TypeError, "no implicit conversion of %s to %s" % [ + config.class, Config + ] + end + end + + include AttrAccessors + include AttrInheritance + include AttrTypeCoercion + + # The debug mode (boolean) + # + # | Starting with version | The default value is | + # |-----------------------|----------------------| + # | _original_ | +false+ | + attr_accessor :debug, type: :boolean + + # method: debug? + # :call-seq: debug? -> boolean + # + # Alias for #debug + + # Seconds to wait until a connection is opened. + # + # If the IMAP object cannot open a connection within this time, + # it raises a Net::OpenTimeout exception. See Net::IMAP.new. + # + # | Starting with version | The default value is | + # |-----------------------|----------------------| + # | _original_ | +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_done. + # + # | Starting with version | The default value is | + # |-----------------------|----------------------| + # | _original_ | +5+ seconds | + attr_accessor :idle_response_timeout, 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) + attrs.each do send(:"#{_1}=", _2) end + yield self if block_given? + end + + @default = new( + debug: false, + open_timeout: 30, + idle_response_timeout: 5, + ).freeze + + @global = default.new + + end + end +end diff --git a/lib/net/imap/config/attr_accessors.rb b/lib/net/imap/config/attr_accessors.rb new file mode 100644 index 00000000..2cb2272c --- /dev/null +++ b/lib/net/imap/config/attr_accessors.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "forwardable" + +module Net + class IMAP + class Config + + # Config values are stored in a struct rather than ivars to simplify: + # * ensuring that all config objects share a single object shape + # * querying only locally configured values, e.g for inspection. + module AttrAccessors + module Macros # :nodoc: internal API + def attr_accessor(name) AttrAccessors.attr_accessor(name) end + end + private_constant :Macros + + def self.included(mod) + mod.extend Macros + end + private_class_method :included + + extend Forwardable + + def self.attr_accessor(name) # :nodoc: internal API + name = name.to_sym + def_delegators :data, name, :"#{name}=" + end + + def self.attributes + instance_methods.grep(/=\z/).map { _1.to_s.delete_suffix("=").to_sym } + end + private_class_method :attributes + + def self.struct # :nodoc: internal API + unless defined?(self::Struct) + const_set :Struct, Struct.new(*attributes) + end + self::Struct + end + + def initialize # :notnew: + super() + @data = AttrAccessors.struct.new + end + + # Freezes the internal attributes struct, in addition to +self+. + def freeze + data.freeze + super + end + + protected + + attr_reader :data # :nodoc: internal API + + private + + def initialize_dup(other) + super + @data = other.data.dup + end + + end + end + end +end diff --git a/lib/net/imap/config/attr_inheritance.rb b/lib/net/imap/config/attr_inheritance.rb new file mode 100644 index 00000000..be68a8cb --- /dev/null +++ b/lib/net/imap/config/attr_inheritance.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module Net + class IMAP + class Config + # Inheritance forms a singly-linked-list, so lookup will be O(n) on the + # number of ancestors. Without customization, ancestor trees will only be + # three or four deep: + # client -> [versioned ->] global -> default + module AttrInheritance + INHERITED = Module.new.freeze + private_constant :INHERITED + + module Macros # :nodoc: internal API + def attr_accessor(name) super; AttrInheritance.attr_accessor(name) end + end + private_constant :Macros + + def self.included(mod) + mod.extend Macros + end + private_class_method :included + + def self.attr_accessor(name) # :nodoc: internal API + module_eval <<~RUBY, __FILE__, __LINE__ + 1 + def #{name}; (val = super) == INHERITED ? parent&.#{name} : val end + RUBY + end + + # The parent Config object + attr_reader :parent + + def initialize(parent = nil) # :notnew: + super() + @parent = Config[parent] + reset + end + + # Creates a new config, which inherits from +self+. + def new(**attrs) self.class.new(self, **attrs) end + + # Returns +true+ if +attr+ is inherited from #parent and not overridden + # by this config. + def inherited?(attr) data[attr] == INHERITED end + + # :call-seq: + # reset -> self + # reset(attr) -> attribute value + # + # Resets an +attr+ to inherit from the #parent config. + # + # When +attr+ is nil or not given, all attributes are reset. + def reset(attr = nil) + if attr.nil? + data.members.each do |attr| data[attr] = INHERITED end + self + elsif inherited?(attr) + nil + else + old, data[attr] = data[attr], INHERITED + old + end + end + + private + + def initialize_copy(other) + super + @parent ||= other # only default has nil parent + end + + end + end + end +end diff --git a/lib/net/imap/config/attr_type_coercion.rb b/lib/net/imap/config/attr_type_coercion.rb new file mode 100644 index 00000000..7511193d --- /dev/null +++ b/lib/net/imap/config/attr_type_coercion.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Net + class IMAP + class Config + # Adds a +type+ keyword parameter to +attr_accessor+, which enforces + # config attributes have valid types, for example: boolean, numeric, + # enumeration, non-nullable, etc. + module AttrTypeCoercion + # :stopdoc: internal APIs only + + module Macros # :nodoc: internal API + def attr_accessor(attr, type: nil) + super(attr) + AttrTypeCoercion.attr_accessor(attr, type: type) + end + end + private_constant :Macros + + def self.included(mod) + mod.extend Macros + end + private_class_method :included + + def self.attr_accessor(attr, type: nil) + return unless type + if :boolean == type then boolean attr + elsif Integer == type then integer attr + else raise ArgumentError, "unknown type coercion %p" % [type] + end + end + + def self.boolean(attr) + define_method :"#{attr}=" do |val| super !!val end + define_method :"#{attr}?" do send attr end + end + + def self.integer(attr) + define_method :"#{attr}=" do |val| super Integer val end + end + + end + end + end +end diff --git a/lib/net/imap/response_parser.rb b/lib/net/imap/response_parser.rb index 84f6cf79..fe6badc8 100644 --- a/lib/net/imap/response_parser.rb +++ b/lib/net/imap/response_parser.rb @@ -11,12 +11,15 @@ class ResponseParser include ParserUtils extend ParserUtils::Generator + attr_reader :config + # :call-seq: Net::IMAP::ResponseParser.new -> Net::IMAP::ResponseParser - def initialize + def initialize(config: Config.global) @str = nil @pos = nil @lex_state = nil @token = nil + @config = Config[config] end # :call-seq: diff --git a/lib/net/imap/response_parser/parser_utils.rb b/lib/net/imap/response_parser/parser_utils.rb index d458ede7..bc84bed9 100644 --- a/lib/net/imap/response_parser/parser_utils.rb +++ b/lib/net/imap/response_parser/parser_utils.rb @@ -154,7 +154,7 @@ def accept(*args) end # To be used conditionally: - # assert_no_lookahead if Net::IMAP.debug + # assert_no_lookahead if config.debug? def assert_no_lookahead @token.nil? or parse_error("assertion failed: expected @token.nil?, actual %s: %p", @@ -181,23 +181,23 @@ def lookahead!(*args) end def peek_str?(str) - assert_no_lookahead if Net::IMAP.debug + assert_no_lookahead if config.debug? @str[@pos, str.length] == str end def peek_re(re) - assert_no_lookahead if Net::IMAP.debug + assert_no_lookahead if config.debug? re.match(@str, @pos) end def accept_re(re) - assert_no_lookahead if Net::IMAP.debug + assert_no_lookahead if config.debug? re.match(@str, @pos) and @pos = $~.end(0) $~ end def match_re(re, name) - assert_no_lookahead if Net::IMAP.debug + assert_no_lookahead if config.debug? if re.match(@str, @pos) @pos = $~.end(0) $~ @@ -212,7 +212,7 @@ def shift_token def parse_error(fmt, *args) msg = format(fmt, *args) - if IMAP.debug + if config.debug? local_path = File.dirname(__dir__) tok = @token ? "%s: %p" % [@token.symbol, @token.value] : "nil" warn "%s %s: %s" % [self.class, __method__, msg] diff --git a/test/net/imap/test_config.rb b/test/net/imap/test_config.rb new file mode 100644 index 00000000..e14d7b74 --- /dev/null +++ b/test/net/imap/test_config.rb @@ -0,0 +1,155 @@ +# frozen_string_literal: true + +require "net/imap" +require "test/unit" + +class ConfigTest < Test::Unit::TestCase + Config = Net::IMAP::Config + + setup do + Config.global.reset + end + + test "#debug" do + assert Config.new(debug: true).debug + refute Config.new(debug: false).debug + assert Config.new(debug: true).debug? + refute Config.new(debug: false).debug? + config = Config.new do |c| + c.debug = true + end + assert config.debug + config = Config.new + config.debug = true + assert config.debug + assert config.debug? + config.debug = false + refute config.debug + refute config.debug? + end + + test "boolean type constraints and conversion" do + config = Config.new + config.debug = 111 + assert_equal true, config.debug + config.debug = nil + assert_equal false, config.debug + end + + test "integer type constraints and conversion" do + config = Config.new + config.open_timeout = "111" + assert_equal 111, config.open_timeout + config.open_timeout = 222.0 + assert_equal 222, config.open_timeout + config.open_timeout = 333.3 + assert_equal 333, config.open_timeout + assert_raise(ArgumentError) do + config.open_timeout = "444 NaN" + end + assert_equal 333, config.open_timeout + end + + test ".default" do + default = Config.default + assert default.equal?(Config.default) + assert default.is_a?(Config) + assert default.frozen? + refute default.debug? + end + + test ".global" do + global = Config.global + assert global.equal?(Config.global) + assert global.is_a?(Config) + assert_same Config.default, global.parent + assert_equal false, global.debug? + global.debug = true + assert_equal true, global.debug? + global.reset(:debug) + assert_equal false, global.debug? + refute global.frozen? + end + + test "Net::IMAP.config" do + assert Net::IMAP.config.equal?(Config.global) + end + + test ".new(parent, ...) and inheritance" do + base = Config.new debug: false + child = Config.new(base) + assert_equal base, child.parent + assert_equal false, child.debug + assert_equal false, child.debug? + base.debug = true + assert_equal true, child.debug? + child.debug = false + assert_equal false, child.debug? + child.reset(:debug) + assert_equal true, child.debug? + base.debug = false + child.debug = true + assert_equal true, child.debug? + child = Config.new(base, debug: true) + assert_equal true, child.debug? + base.debug = true + child = Config.new(base, debug: false) + assert_equal false, child.debug? + end + + test "#new and inheritance" do + base = Config.new debug: false + child = base.new + assert_equal base, child.parent + assert_equal false, child.debug + assert_equal false, child.debug? + base.debug = true + assert_equal true, child.debug? + child.debug = false + assert_equal false, child.debug? + child.reset(:debug) + assert_equal true, child.debug? + base.debug = false + child.debug = true + assert_equal true, child.debug? + child = base.new(debug: true) + assert_equal true, child.debug? + base.debug = true + child = base.new(debug: false) + assert_equal false, child.debug? + end + + test ".new always sets a parent" do + assert_same Config.global, Config.new.parent + assert_same Config.default, Config.new(Config.default).parent + assert_same Config.global, Config.new(Config.global).parent + end + + test "#inherited? and #reset(attr)" do + base = Config.new debug: false, open_timeout: 99, idle_response_timeout: 15 + child = base.new debug: true, open_timeout: 15, idle_response_timeout: 10 + refute child.inherited?(:idle_response_timeout) + assert_equal 10, child.reset(:idle_response_timeout) + assert child.inherited?(:idle_response_timeout) + assert_equal 15, child.idle_response_timeout + refute child.inherited?(:open_timeout) + refute child.inherited?(:debug) + child.debug = false + refute child.inherited?(:debug) + assert_equal false, child.reset(:debug) + assert child.inherited?(:debug) + assert_equal false, child.debug + assert_equal nil, child.reset(:debug) + end + + test "#reset all attributes" do + base = Config.new debug: false, open_timeout: 99, idle_response_timeout: 15 + child = base.new debug: true, open_timeout: 15, idle_response_timeout: 10 + result = child.reset + assert_same child, result + assert child.inherited?(:debug) + assert child.inherited?(:open_timeout) + assert child.inherited?(:idle_response_timeout) + end + +end diff --git a/test/net/imap/test_deprecated_client_options.rb b/test/net/imap/test_deprecated_client_options.rb index 1dcdc339..042bf90f 100644 --- a/test/net/imap/test_deprecated_client_options.rb +++ b/test/net/imap/test_deprecated_client_options.rb @@ -8,6 +8,7 @@ class DeprecatedClientOptionsTest < Test::Unit::TestCase include Net::IMAP::FakeServer::TestHelper def setup + Net::IMAP.config.reset @do_not_reverse_lookup = Socket.do_not_reverse_lookup Socket.do_not_reverse_lookup = true @threads = [] diff --git a/test/net/imap/test_imap.rb b/test/net/imap/test_imap.rb index 0078956d..30012666 100644 --- a/test/net/imap/test_imap.rb +++ b/test/net/imap/test_imap.rb @@ -12,6 +12,7 @@ class IMAPTest < Test::Unit::TestCase include Net::IMAP::FakeServer::TestHelper def setup + Net::IMAP.config.reset @do_not_reverse_lookup = Socket.do_not_reverse_lookup Socket.do_not_reverse_lookup = true @threads = [] diff --git a/test/net/imap/test_imap_capabilities.rb b/test/net/imap/test_imap_capabilities.rb index 14e1f79b..252a7d9b 100644 --- a/test/net/imap/test_imap_capabilities.rb +++ b/test/net/imap/test_imap_capabilities.rb @@ -9,6 +9,7 @@ class IMAPCapabilitiesTest < Test::Unit::TestCase include Net::IMAP::FakeServer::TestHelper def setup + Net::IMAP.config.reset @do_not_reverse_lookup = Socket.do_not_reverse_lookup Socket.do_not_reverse_lookup = true @threads = [] diff --git a/test/net/imap/test_imap_response_data.rb b/test/net/imap/test_imap_response_data.rb index dc546432..0bee8b9a 100644 --- a/test/net/imap/test_imap_response_data.rb +++ b/test/net/imap/test_imap_response_data.rb @@ -6,6 +6,7 @@ class IMAPResponseDataTest < Test::Unit::TestCase def setup + Net::IMAP.config.reset @do_not_reverse_lookup = Socket.do_not_reverse_lookup Socket.do_not_reverse_lookup = true end diff --git a/test/net/imap/test_imap_response_parser.rb b/test/net/imap/test_imap_response_parser.rb index 0d4682cd..a4d70f92 100644 --- a/test/net/imap/test_imap_response_parser.rb +++ b/test/net/imap/test_imap_response_parser.rb @@ -11,6 +11,7 @@ class IMAPResponseParserTest < Test::Unit::TestCase extend NetIMAPTestHelpers::TestFixtureGenerators def setup + Net::IMAP.config.reset @do_not_reverse_lookup = Socket.do_not_reverse_lookup Socket.do_not_reverse_lookup = true end