diff --git a/.github/workflows/push_gem.yml b/.github/workflows/push_gem.yml index a085de5cd..0221b5a49 100644 --- a/.github/workflows/push_gem.yml +++ b/.github/workflows/push_gem.yml @@ -24,7 +24,7 @@ jobs: steps: # Set up - name: Harden Runner - uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4 + uses: step-security/harden-runner@c6295a65d1254861815972266d5933fd6e532bdf # v2.11.1 with: egress-policy: audit diff --git a/lib/net/imap.rb b/lib/net/imap.rb index 1729f0b7b..30f91fc68 100644 --- a/lib/net/imap.rb +++ b/lib/net/imap.rb @@ -43,10 +43,18 @@ module Net # To work on the messages within a mailbox, the client must # first select that mailbox, using either #select or #examine # (for read-only access). Once the client has successfully - # selected a mailbox, they enter the "_selected_" state, and that + # selected a mailbox, they enter the +selected+ state, and that # mailbox becomes the _current_ mailbox, on which mail-item # related commands implicitly operate. # + # === Connection state + # + # Once an IMAP connection is established, the connection is in one of four + # states: not authenticated, +authenticated+, +selected+, and + # +logout+. Most commands are valid only in certain states. + # + # See #connection_state. + # # === Sequence numbers and UIDs # # Messages have two sorts of identifiers: message sequence @@ -199,6 +207,42 @@ module Net # # This script invokes the FETCH command and the SEARCH command concurrently. # + # When running multiple commands, care must be taken to avoid ambiguity. For + # example, SEARCH responses are ambiguous about which command they are + # responding to, so search commands should not run simultaneously, unless the + # server supports +ESEARCH+ {[RFC4731]}[https://rfc-editor.org/rfc/rfc4731] or + # IMAP4rev2[https://www.rfc-editor.org/rfc/rfc9051]. See {RFC9051 + # §5.5}[https://www.rfc-editor.org/rfc/rfc9051.html#section-5.5] for + # other examples of command sequences which should not be pipelined. + # + # == Unbounded memory use + # + # Net::IMAP reads server responses in a separate receiver thread per client. + # Unhandled response data is saved to #responses, and response_handlers run + # inside the receiver thread. See the list of methods for {handling server + # responses}[rdoc-ref:Net::IMAP@Handling+server+responses], below. + # + # Because the receiver thread continuously reads and saves new responses, some + # scenarios must be careful to avoid unbounded memory use: + # + # * Commands such as #list or #fetch can have an enormous number of responses. + # * Commands such as #fetch can result in an enormous size per response. + # * Long-lived connections will gradually accumulate unsolicited server + # responses, especially +EXISTS+, +FETCH+, and +EXPUNGE+ responses. + # * A buggy or untrusted server could send inappropriate responses, which + # could be very numerous, very large, and very rapid. + # + # Use paginated or limited versions of commands whenever possible. + # + # Use Config#max_response_size to impose a limit on incoming server responses + # as they are being read. This is especially important for untrusted + # servers. + # + # Use #add_response_handler to handle responses after each one is received. + # Use the +response_handlers+ argument to ::new to assign response handlers + # before the receiver thread is started. Use #extract_responses, + # #clear_responses, or #responses (with a block) to prune responses. + # # == Errors # # An \IMAP server can send three different types of responses to indicate @@ -260,8 +304,9 @@ module Net # # - Net::IMAP.new: Creates a new \IMAP client which connects immediately and # waits for a successful server greeting before the method returns. + # - #connection_state: Returns the connection state. # - #starttls: Asks the server to upgrade a clear-text connection to use TLS. - # - #logout: Tells the server to end the session. Enters the "_logout_" state. + # - #logout: Tells the server to end the session. Enters the +logout+ state. # - #disconnect: Disconnects the connection (without sending #logout first). # - #disconnected?: True if the connection has been closed. # @@ -317,37 +362,36 @@ module Net # In general, #capable? should be used rather than explicitly sending a # +CAPABILITY+ command to the server. # - #noop: Allows the server to send unsolicited untagged #responses. - # - #logout: Tells the server to end the session. Enters the "_logout_" state. + # - #logout: Tells the server to end the session. Enters the +logout+ state. # # ==== Not Authenticated state # # In addition to the commands for any state, the following commands are valid - # in the "not authenticated" state: + # in the +not_authenticated+ state: # # - #starttls: Upgrades a clear-text connection to use TLS. # # Requires the +STARTTLS+ capability. # - #authenticate: Identifies the client to the server using the given # {SASL mechanism}[https://www.iana.org/assignments/sasl-mechanisms/sasl-mechanisms.xhtml] - # and credentials. Enters the "_authenticated_" state. + # and credentials. Enters the +authenticated+ state. # # The server should list "AUTH=#{mechanism}" capabilities for # supported mechanisms. # - #login: Identifies the client to the server using a plain text password. - # Using #authenticate is generally preferred. Enters the "_authenticated_" - # state. + # Using #authenticate is preferred. Enters the +authenticated+ state. # # The +LOGINDISABLED+ capability must NOT be listed. # # ==== Authenticated state # # In addition to the commands for any state, the following commands are valid - # in the "_authenticated_" state: + # in the +authenticated+ state: # # - #enable: Enables backwards incompatible server extensions. # Requires the +ENABLE+ or +IMAP4rev2+ capability. - # - #select: Open a mailbox and enter the "_selected_" state. - # - #examine: Open a mailbox read-only, and enter the "_selected_" state. + # - #select: Open a mailbox and enter the +selected+ state. + # - #examine: Open a mailbox read-only, and enter the +selected+ state. # - #create: Creates a new mailbox. # - #delete: Permanently remove a mailbox. # - #rename: Change the name of a mailbox. @@ -369,12 +413,12 @@ module Net # # ==== Selected state # - # In addition to the commands for any state and the "_authenticated_" - # commands, the following commands are valid in the "_selected_" state: + # In addition to the commands for any state and the +authenticated+ + # commands, the following commands are valid in the +selected+ state: # - # - #close: Closes the mailbox and returns to the "_authenticated_" state, + # - #close: Closes the mailbox and returns to the +authenticated+ state, # expunging deleted messages, unless the mailbox was opened as read-only. - # - #unselect: Closes the mailbox and returns to the "_authenticated_" state, + # - #unselect: Closes the mailbox and returns to the +authenticated+ state, # without expunging any messages. # Requires the +UNSELECT+ or +IMAP4rev2+ capability. # - #expunge: Permanently removes messages which have the Deleted flag set. @@ -395,7 +439,7 @@ module Net # # ==== Logout state # - # No \IMAP commands are valid in the "_logout_" state. If the socket is still + # No \IMAP commands are valid in the +logout+ state. If the socket is still # open, Net::IMAP will close it after receiving server confirmation. # Exceptions will be raised by \IMAP commands that have already started and # are waiting for a response, as well as any that are called after logout. @@ -449,7 +493,7 @@ module Net # ==== RFC3691: +UNSELECT+ # Folded into IMAP4rev2[https://www.rfc-editor.org/rfc/rfc9051] and also included # above with {Core IMAP commands}[rdoc-ref:Net::IMAP@Core+IMAP+commands]. - # - #unselect: Closes the mailbox and returns to the "_authenticated_" state, + # - #unselect: Closes the mailbox and returns to the +authenticated+ state, # without expunging any messages. # # ==== RFC4314: +ACL+ @@ -744,7 +788,7 @@ module Net # * {IMAP URLAUTH Authorization Mechanism Registry}[https://www.iana.org/assignments/urlauth-authorization-mechanism-registry/urlauth-authorization-mechanism-registry.xhtml] # class IMAP < Protocol - VERSION = "0.5.6" + VERSION = "0.5.7" # Aliases for supported capabilities, to be used with the #enable command. ENABLE_ALIASES = { @@ -752,9 +796,12 @@ class IMAP < Protocol "UTF8=ONLY" => "UTF8=ACCEPT", }.freeze - 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__) + dir = File.expand_path("imap", __dir__) + autoload :ConnectionState, "#{dir}/connection_state" + autoload :ResponseReader, "#{dir}/response_reader" + autoload :SASL, "#{dir}/sasl" + autoload :SASLAdapter, "#{dir}/sasl_adapter" + autoload :StringPrep, "#{dir}/stringprep" include MonitorMixin if defined?(OpenSSL::SSL) @@ -766,9 +813,11 @@ class IMAP < Protocol def self.config; Config.global end # Returns the global debug mode. + # Delegates to {Net::IMAP.config.debug}[rdoc-ref:Config#debug]. def self.debug; config.debug end # Sets the global debug mode. + # Delegates to {Net::IMAP.config.debug=}[rdoc-ref:Config#debug=]. def self.debug=(val) config.debug = val end @@ -789,7 +838,7 @@ class << self alias default_ssl_port default_tls_port end - # Returns the initial greeting the server, an UntaggedResponse. + # Returns the initial greeting sent by the server, an UntaggedResponse. attr_reader :greeting # The client configuration. See Net::IMAP::Config. @@ -798,13 +847,28 @@ class << self # 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. - def open_timeout; config.open_timeout end + ## + # :attr_reader: open_timeout + # Seconds to wait until a connection is opened. Also used by #starttls. + # Delegates to {config.open_timeout}[rdoc-ref:Config#open_timeout]. + ## + # :attr_reader: idle_response_timeout # Seconds to wait until an IDLE response is received. - def idle_response_timeout; config.idle_response_timeout end + # Delegates to {config.idle_response_timeout}[rdoc-ref:Config#idle_response_timeout]. + + ## + # :attr_accessor: max_response_size + # + # The maximum allowed server response size, in bytes. + # Delegates to {config.max_response_size}[rdoc-ref:Config#max_response_size]. + + # :stopdoc: + def open_timeout; config.open_timeout end + def idle_response_timeout; config.idle_response_timeout end + def max_response_size; config.max_response_size end + def max_response_size=(val) config.max_response_size = val end + # :startdoc: # The hostname this client connected to attr_reader :host @@ -827,6 +891,67 @@ def idle_response_timeout; config.idle_response_timeout end # Returns +false+ for a plaintext connection. attr_reader :ssl_ctx_params + # Returns the current connection state. + # + # Once an IMAP connection is established, the connection is in one of four + # states: +not_authenticated+, +authenticated+, +selected+, and +logout+. + # Most commands are valid only in certain states. + # + # The connection state object responds to +to_sym+ and +name+ with the name + # of the current connection state, as a Symbol or String. Future versions + # of +net-imap+ may store additional information on the state object. + # + # From {RFC9051}[https://www.rfc-editor.org/rfc/rfc9051#section-3]: + # +----------------------+ + # |connection established| + # +----------------------+ + # || + # \/ + # +--------------------------------------+ + # | server greeting | + # +--------------------------------------+ + # || (1) || (2) || (3) + # \/ || || + # +-----------------+ || || + # |Not Authenticated| || || + # +-----------------+ || || + # || (7) || (4) || || + # || \/ \/ || + # || +----------------+ || + # || | Authenticated |<=++ || + # || +----------------+ || || + # || || (7) || (5) || (6) || + # || || \/ || || + # || || +--------+ || || + # || || |Selected|==++ || + # || || +--------+ || + # || || || (7) || + # \/ \/ \/ \/ + # +--------------------------------------+ + # | Logout | + # +--------------------------------------+ + # || + # \/ + # +-------------------------------+ + # |both sides close the connection| + # +-------------------------------+ + # + # >>> + # Legend for the above diagram: + # + # 1. connection without pre-authentication (+OK+ #greeting) + # 2. pre-authenticated connection (+PREAUTH+ #greeting) + # 3. rejected connection (+BYE+ #greeting) + # 4. successful #login or #authenticate command + # 5. successful #select or #examine command + # 6. #close or #unselect command, unsolicited +CLOSED+ response code, or + # failed #select or #examine command + # 7. #logout command, server shutdown, or connection closed + # + # Before the server greeting, the state is +not_authenticated+. + # After the connection closes, the state remains +logout+. + attr_reader :connection_state + # Creates a new Net::IMAP object and connects it to the specified # +host+. # @@ -860,6 +985,12 @@ def idle_response_timeout; config.idle_response_timeout end # # See DeprecatedClientOptions.new for deprecated SSL arguments. # + # [response_handlers] + # A list of response handlers to be added before the receiver thread is + # started. This ensures every server response is handled, including the + # #greeting. Note that the greeting is handled in the current thread, but + # all other responses are handled in the receiver thread. + # # [config] # A Net::IMAP::Config object to use as the basis for #config. By default, # the global Net::IMAP.config is used. @@ -931,7 +1062,7 @@ def idle_response_timeout; config.idle_response_timeout end # [Net::IMAP::ByeResponseError] # Connected to the host successfully, but it immediately said goodbye. # - def initialize(host, port: nil, ssl: nil, + def initialize(host, port: nil, ssl: nil, response_handlers: nil, config: Config.global, **config_options) super() # Config options @@ -946,6 +1077,8 @@ def initialize(host, port: nil, ssl: nil, @exception = nil @greeting = nil @capabilities = nil + @tls_verified = false + @connection_state = ConnectionState::NotAuthenticated.new # Client Protocol Receiver @parser = ResponseParser.new(config: @config) @@ -954,6 +1087,7 @@ def initialize(host, port: nil, ssl: nil, @receiver_thread = nil @receiver_thread_exception = nil @receiver_thread_terminating = false + response_handlers&.each do add_response_handler(_1) end # Client Protocol Sender (including state for currently running commands) @tag_prefix = "RUBY" @@ -967,8 +1101,8 @@ def initialize(host, port: nil, ssl: nil, @logout_command_tag = nil # Connection - @tls_verified = false @sock = tcp_socket(@host, @port) + @reader = ResponseReader.new(self, @sock) start_tls_session if ssl_ctx start_imap_connection end @@ -983,6 +1117,7 @@ def tls_verified?; @tls_verified end # Related: #logout, #logout! def disconnect return if disconnected? + state_logout! begin begin # try to call SSL::SSLSocket#io. @@ -1221,6 +1356,10 @@ def logout! # both successful. Any error indicates that the connection has not been # secured. # + # After the server agrees to start a TLS connection, this method waits up to + # {config.open_timeout}[rdoc-ref:Config#open_timeout] before raising + # +Net::OpenTimeout+. + # # *Note:* # >>> # Any #response_handlers added before STARTTLS should be aware that the @@ -1368,7 +1507,7 @@ def starttls(**options) # capabilities, they will be cached. def authenticate(*args, sasl_ir: config.sasl_ir, **props, &callback) sasl_adapter.authenticate(*args, sasl_ir: sasl_ir, **props, &callback) - .tap { @capabilities = capabilities_from_resp_code _1 } + .tap do state_authenticated! _1 end end # Sends a {LOGIN command [IMAP4rev1 §6.2.3]}[https://www.rfc-editor.org/rfc/rfc3501#section-6.2.3] @@ -1402,7 +1541,7 @@ def login(user, password) raise LoginDisabledError end send_command("LOGIN", user, password) - .tap { @capabilities = capabilities_from_resp_code _1 } + .tap do state_authenticated! _1 end end # Sends a {SELECT command [IMAP4rev1 §6.3.1]}[https://www.rfc-editor.org/rfc/rfc3501#section-6.3.1] @@ -1442,8 +1581,10 @@ def select(mailbox, condstore: false) args = ["SELECT", mailbox] args << ["CONDSTORE"] if condstore synchronize do + state_unselected! # implicitly closes current mailbox @responses.clear send_command(*args) + .tap do state_selected! end end end @@ -1460,8 +1601,10 @@ def examine(mailbox, condstore: false) args = ["EXAMINE", mailbox] args << ["CONDSTORE"] if condstore synchronize do + state_unselected! # implicitly closes current mailbox @responses.clear send_command(*args) + .tap do state_selected! end end end @@ -1900,6 +2043,7 @@ def check # Related: #unselect def close send_command("CLOSE") + .tap do state_authenticated! end end # Sends an {UNSELECT command [RFC3691 §2]}[https://www.rfc-editor.org/rfc/rfc3691#section-3] @@ -1916,6 +2060,7 @@ def close # [RFC3691[https://www.rfc-editor.org/rfc/rfc3691]]. def unselect send_command("UNSELECT") + .tap do state_authenticated! end end # call-seq: @@ -3146,6 +3291,10 @@ def response_handlers # end # } # + # Response handlers can also be added when the client is created before the + # receiver thread is started, by the +response_handlers+ argument to ::new. + # This ensures every server response is handled, including the #greeting. + # # Related: #remove_response_handler, #response_handlers def add_response_handler(handler = nil, &block) raise ArgumentError, "two Procs are passed" if handler && block @@ -3172,8 +3321,10 @@ def remove_response_handler(handler) def start_imap_connection @greeting = get_server_greeting @capabilities = capabilities_from_resp_code @greeting + @response_handlers.each do |handler| handler.call(@greeting) end @receiver_thread = start_receiver_thread rescue Exception + state_logout! @sock.close raise end @@ -3182,7 +3333,10 @@ def get_server_greeting greeting = get_response raise Error, "No server greeting - connection closed" unless greeting record_untagged_response_code greeting - raise ByeResponseError, greeting if greeting.name == "BYE" + case greeting.name + when "PREAUTH" then state_authenticated! + when "BYE" then state_logout!; raise ByeResponseError, greeting + end greeting end @@ -3192,6 +3346,8 @@ def start_receiver_thread rescue Exception => ex @receiver_thread_exception = ex # don't exit the thread with an exception + ensure + state_logout! end end @@ -3214,6 +3370,7 @@ def receive_responses resp = get_response rescue Exception => e synchronize do + state_logout! @sock.close @exception = e end @@ -3233,6 +3390,7 @@ def receive_responses @tagged_response_arrival.broadcast case resp.tag when @logout_command_tag + state_logout! return when @continued_command_tag @continuation_request_exception = @@ -3242,6 +3400,7 @@ def receive_responses when UntaggedResponse record_untagged_response(resp) if resp.name == "BYE" && @logout_command_tag.nil? + state_logout! @sock.close @exception = ByeResponseError.new(resp) connection_closed = true @@ -3249,6 +3408,7 @@ def receive_responses when ContinuationRequest @continuation_request_arrival.signal end + state_unselected! if resp in {data: {code: {name: "CLOSED"}}} @response_handlers.each do |handler| handler.call(resp) end @@ -3300,23 +3460,10 @@ def get_tagged_response(tag, cmd, timeout = nil) end def get_response - buff = String.new - while true - s = @sock.gets(CRLF) - break unless s - buff.concat(s) - if /\{(\d+)\}\r\n/n =~ s - s = @sock.read($1.to_i) - buff.concat(s) - else - break - end - end + buff = @reader.read_response_buffer return nil if buff.length == 0 - if config.debug? - $stderr.print(buff.gsub(/^/n, "S: ")) - end - return @parser.parse(buff) + $stderr.print(buff.gsub(/^/n, "S: ")) if config.debug? + @parser.parse(buff) end ############################# @@ -3620,6 +3767,7 @@ def start_tls_session raise "already using SSL" if @sock.kind_of?(OpenSSL::SSL::SSLSocket) raise "cannot start TLS without SSLContext" unless ssl_ctx @sock = SSLSocket.new(@sock, ssl_ctx) + @reader = ResponseReader.new(self, @sock) @sock.sync_close = true @sock.hostname = @host if @sock.respond_to? :hostname= ssl_socket_connect(@sock, open_timeout) @@ -3629,6 +3777,29 @@ def start_tls_session end end + def state_authenticated!(resp = nil) + synchronize do + @capabilities = capabilities_from_resp_code resp if resp + @connection_state = ConnectionState::Authenticated.new + end + end + + def state_selected! + synchronize do + @connection_state = ConnectionState::Selected.new + end + end + + def state_unselected! + state_authenticated! if connection_state.to_sym == :selected + end + + def state_logout! + synchronize do + @connection_state = ConnectionState::Logout.new + end + end + def sasl_adapter SASLAdapter.new(self, &method(:send_command_with_continuations)) end diff --git a/lib/net/imap/config.rb b/lib/net/imap/config.rb index 1e0300c5d..013cccccb 100644 --- a/lib/net/imap/config.rb +++ b/lib/net/imap/config.rb @@ -131,8 +131,25 @@ def self.default; @default end 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 = {} + @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 @@ -155,18 +172,17 @@ def self.[](config) elsif config.nil? && global.nil? then nil elsif config.respond_to?(:to_hash) then new(global, **config).freeze else - version_defaults.fetch(config) do + version_defaults[config] or case config when Numeric raise RangeError, "unknown config version: %p" % [config] - when Symbol + 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 end @@ -193,10 +209,13 @@ def self.[](config) # 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. + # See Net::IMAP.new and Net::IMAP#starttls. # # The default value is +30+ seconds. attr_accessor :open_timeout, type: Integer @@ -245,10 +264,44 @@ def self.[](config) # present. When capabilities are unknown, Net::IMAP will automatically # send a +CAPABILITY+ command first before sending +LOGIN+. # - attr_accessor :enforce_logindisabled, type: [ + 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 very high 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. + # + # Please Note: 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 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. + # + # * original: +nil+ (no limit) + # * +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+). # @@ -275,7 +328,7 @@ def self.[](config) # Raise an ArgumentError with the deprecation warning. # # Note: #responses_without_args is an alias for #responses_without_block. - attr_accessor :responses_without_block, type: [ + attr_accessor :responses_without_block, type: Enum[ :silence_deprecation_warning, :warn, :frozen_dup, :raise, ] @@ -320,7 +373,7 @@ def self.[](config) # # [+false+ (planned default for +v0.6+)] # ResponseParser _only_ uses AppendUIDData and CopyUIDData. - attr_accessor :parser_use_deprecated_uidplus_data, type: [ + attr_accessor :parser_use_deprecated_uidplus_data, type: Enum[ true, :up_to_max_size, false ] @@ -427,6 +480,7 @@ def defaults_hash 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, @@ -435,36 +489,64 @@ def defaults_hash @global = default.new version_defaults[:default] = Config[default.send(:defaults_hash)] - version_defaults[:current] = Config[:default] - version_defaults[0] = Config[:current].dup.update( + 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.0] = Config[0] - version_defaults[0.1] = Config[0] - version_defaults[0.2] = Config[0] - version_defaults[0.3] = Config[0] + 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.4] = Config[0.3].dup.update( + version_defaults[0.4r] = Config[0.3r].dup.update( sasl_ir: true, parser_max_deprecated_uidplus_data_size: 1000, ).freeze - version_defaults[0.5] = Config[:current] + 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.6] = Config[0.5].dup.update( + 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[:next] = Config[0.6] - version_defaults[:future] = Config[:next] + + 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 diff --git a/lib/net/imap/config/attr_type_coercion.rb b/lib/net/imap/config/attr_type_coercion.rb index c15dbb8a6..264bc63a8 100644 --- a/lib/net/imap/config/attr_type_coercion.rb +++ b/lib/net/imap/config/attr_type_coercion.rb @@ -18,6 +18,8 @@ def attr_accessor(attr, type: nil) super(attr) AttrTypeCoercion.attr_accessor(attr, type: type) end + + module_function def Integer? = NilOrInteger end private_constant :Macros @@ -26,34 +28,29 @@ def self.included(mod) 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 - elsif Array === type then enum attr, type - else raise ArgumentError, "unknown type coercion %p" % [type] - end - end + def self.safe(...) = Ractor.make_shareable nil.instance_eval(...).freeze + private_class_method :safe - def self.boolean(attr) - define_method :"#{attr}=" do |val| super !!val end - define_method :"#{attr}?" do send attr end - end + Types = Hash.new do |h, type| type => Proc | nil; safe{type} end + Types[:boolean] = Boolean = safe{-> {!!_1}} + Types[Integer] = safe{->{Integer(_1)}} - def self.integer(attr) - define_method :"#{attr}=" do |val| super Integer val end + def self.attr_accessor(attr, type: nil) + type = Types[type] or return + define_method :"#{attr}=" do |val| super type[val] end + define_method :"#{attr}?" do send attr end if type == Boolean end - def self.enum(attr, enum) - enum = enum.dup.freeze + NilOrInteger = safe{->val { Integer val unless val.nil? }} + + Enum = ->(*enum) { + enum = safe{enum} expected = -"one of #{enum.map(&:inspect).join(", ")}" - define_method :"#{attr}=" do |val| - unless enum.include?(val) - raise ArgumentError, "expected %s, got %p" % [expected, val] - end - super val - end - end + safe{->val { + return val if enum.include?(val) + raise ArgumentError, "expected %s, got %p" % [expected, val] + }} + } end end diff --git a/lib/net/imap/connection_state.rb b/lib/net/imap/connection_state.rb new file mode 100644 index 000000000..906e99b54 --- /dev/null +++ b/lib/net/imap/connection_state.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Net + class IMAP + class ConnectionState < Net::IMAP::Data # :nodoc: + def self.define(symbol, *attrs) + symbol => Symbol + state = super(*attrs) + state.const_set :NAME, symbol + state + end + + def symbol; self.class::NAME end + def name; self.class::NAME.name end + alias to_sym symbol + + def deconstruct; [symbol, *super] end + + def deconstruct_keys(names) + hash = super + hash[:symbol] = symbol if names.nil? || names.include?(:symbol) + hash[:name] = name if names.nil? || names.include?(:name) + hash + end + + def to_h(&block) + hash = deconstruct_keys(nil) + block ? hash.to_h(&block) : hash + end + + def not_authenticated?; to_sym == :not_authenticated end + def authenticated?; to_sym == :authenticated end + def selected?; to_sym == :selected end + def logout?; to_sym == :logout end + + NotAuthenticated = define(:not_authenticated) + Authenticated = define(:authenticated) + Selected = define(:selected) + Logout = define(:logout) + + class << self + undef :define + end + freeze + end + + end +end diff --git a/lib/net/imap/errors.rb b/lib/net/imap/errors.rb index d329a513d..bab4bbcf5 100644 --- a/lib/net/imap/errors.rb +++ b/lib/net/imap/errors.rb @@ -17,6 +17,39 @@ def initialize(msg = "Remote server has disabled the LOGIN command", ...) class DataFormatError < Error end + # Error raised when the socket cannot be read, due to a Config limit. + class ResponseReadError < Error + end + + # Error raised when a response is larger than IMAP#max_response_size. + class ResponseTooLargeError < ResponseReadError + attr_reader :bytes_read, :literal_size + attr_reader :max_response_size + + def initialize(msg = nil, *args, + bytes_read: nil, + literal_size: nil, + max_response_size: nil, + **kwargs) + @bytes_read = bytes_read + @literal_size = literal_size + @max_response_size = max_response_size + msg ||= [ + "Response size", response_size_msg, "exceeds max_response_size", + max_response_size && "(#{max_response_size}B)", + ].compact.join(" ") + super(msg, *args, **kwargs) + end + + private + + def response_size_msg + if bytes_read && literal_size + "(#{bytes_read}B read + #{literal_size}B literal)" + end + end + end + # Error raised when a response from the server is non-parsable. class ResponseParseError < Error end diff --git a/lib/net/imap/response_reader.rb b/lib/net/imap/response_reader.rb new file mode 100644 index 000000000..d3e819c00 --- /dev/null +++ b/lib/net/imap/response_reader.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +module Net + class IMAP + # See https://www.rfc-editor.org/rfc/rfc9051#section-2.2.2 + class ResponseReader # :nodoc: + attr_reader :client + + def initialize(client, sock) + @client, @sock = client, sock + end + + def read_response_buffer + @buff = String.new + catch :eof do + while true + read_line + break unless (@literal_size = get_literal_size) + read_literal + end + end + buff + ensure + @buff = nil + end + + private + + attr_reader :buff, :literal_size + + def bytes_read = buff.bytesize + def empty? = buff.empty? + def done? = line_done? && !get_literal_size + def line_done? = buff.end_with?(CRLF) + def get_literal_size = /\{(\d+)\}\r\n\z/n =~ buff && $1.to_i + + def read_line + buff << (@sock.gets(CRLF, read_limit) or throw :eof) + max_response_remaining! unless line_done? + end + + def read_literal + # check before allocating memory for literal + max_response_remaining! + literal = String.new(capacity: literal_size) + buff << (@sock.read(read_limit(literal_size), literal) or throw :eof) + ensure + @literal_size = nil + end + + def read_limit(limit = nil) + [limit, max_response_remaining!].compact.min + end + + def max_response_size = client.max_response_size + def max_response_remaining = max_response_size &.- bytes_read + def response_too_large? = max_response_size &.< min_response_size + def min_response_size = bytes_read + min_response_remaining + + def min_response_remaining + empty? ? 3 : done? ? 0 : (literal_size || 0) + 2 + end + + def max_response_remaining! + return max_response_remaining unless response_too_large? + raise ResponseTooLargeError.new( + max_response_size:, bytes_read:, literal_size:, + ) + end + + end + end +end diff --git a/lib/net/imap/sequence_set.rb b/lib/net/imap/sequence_set.rb index 02cd150d0..d67c77540 100644 --- a/lib/net/imap/sequence_set.rb +++ b/lib/net/imap/sequence_set.rb @@ -174,7 +174,7 @@ class IMAP # # Set membership: # - #include? (aliased as #member?): - # Returns whether a given object (nz-number, range, or *) is + # Returns whether a given element (nz-number, range, or *) is # contained by the set. # - #include_star?: Returns whether the set contains *. # @@ -239,13 +239,13 @@ class IMAP # These methods do not modify +self+. # # - #| (aliased as #union and #+): Returns a new set combining all members - # from +self+ with all members from the other object. + # from +self+ with all members from the other set. # - #& (aliased as #intersection): Returns a new set containing all members - # common to +self+ and the other object. + # common to +self+ and the other set. # - #- (aliased as #difference): Returns a copy of +self+ with all members - # in the other object removed. + # in the other set removed. # - #^ (aliased as #xor): Returns a new set containing all members from - # +self+ and the other object except those common to both. + # +self+ and the other set except those common to both. # - #~ (aliased as #complement): Returns a new set containing all members # that are not in +self+ # - #limit: Returns a copy of +self+ which has replaced * with a @@ -258,17 +258,17 @@ class IMAP # # These methods always update #string to be fully sorted and coalesced. # - # - #add (aliased as #<<): Adds a given object to the set; returns +self+. - # - #add?: If the given object is not an element in the set, adds it and + # - #add (aliased as #<<): Adds a given element to the set; returns +self+. + # - #add?: If the given element is not fully included the set, adds it and # returns +self+; otherwise, returns +nil+. - # - #merge: Merges multiple elements into the set; returns +self+. + # - #merge: Adds all members of the given sets into this set; returns +self+. # - #complement!: Replaces the contents of the set with its own #complement. # # Order preserving: # # These methods _may_ cause #string to not be sorted or coalesced. # - # - #append: Adds a given object to the set, appending it to the existing + # - #append: Adds the given entry to the set, appending it to the existing # string, and returns +self+. # - #string=: Assigns a new #string value and replaces #elements to match. # - #replace: Replaces the contents of the set with the contents @@ -279,13 +279,14 @@ class IMAP # sorted and coalesced. # # - #clear: Removes all elements in the set; returns +self+. - # - #delete: Removes a given object from the set; returns +self+. - # - #delete?: If the given object is an element in the set, removes it and + # - #delete: Removes a given element from the set; returns +self+. + # - #delete?: If the given element is included in the set, removes it and # returns it; otherwise, returns +nil+. # - #delete_at: Removes the number at a given offset. # - #slice!: Removes the number or consecutive numbers at a given offset or # range of offsets. - # - #subtract: Removes each given object from the set; returns +self+. + # - #subtract: Removes all members of the given sets from this set; returns + # +self+. # - #limit!: Replaces * with a given maximum value and removes all # members over that maximum; returns +self+. # @@ -318,9 +319,12 @@ class SequenceSet class << self # :call-seq: - # SequenceSet[*values] -> valid frozen sequence set + # SequenceSet[*inputs] -> valid frozen sequence set # - # Returns a frozen SequenceSet, constructed from +values+. + # Returns a frozen SequenceSet, constructed from +inputs+. + # + # When only a single valid frozen SequenceSet is given, that same set is + # returned. # # An empty SequenceSet is invalid and will raise a DataFormatError. # @@ -690,7 +694,7 @@ def ~; remain_frozen dup.complement! end alias complement :~ # :call-seq: - # add(object) -> self + # add(element) -> self # self << other -> self # # Adds a range or number to the set and returns +self+. @@ -698,8 +702,8 @@ def ~; remain_frozen dup.complement! end # #string will be regenerated. Use #merge to add many elements at once. # # Related: #add?, #merge, #union - def add(object) - tuple_add input_to_tuple object + def add(element) + tuple_add input_to_tuple element normalize! end alias << add @@ -708,9 +712,9 @@ def add(object) # # Unlike #add, #merge, or #union, the new value is appended to #string. # This may result in a #string which has duplicates or is out-of-order. - def append(object) + def append(entry) modifying! - tuple = input_to_tuple object + tuple = input_to_tuple entry entry = tuple_to_str tuple string unless empty? # write @string before tuple_add tuple_add tuple @@ -718,19 +722,19 @@ def append(object) self end - # :call-seq: add?(object) -> self or nil + # :call-seq: add?(element) -> self or nil # # Adds a range or number to the set and returns +self+. Returns +nil+ - # when the object is already included in the set. + # when the element is already included in the set. # # #string will be regenerated. Use #merge to add many elements at once. # # Related: #add, #merge, #union, #include? - def add?(object) - add object unless include? object + def add?(element) + add element unless include? element end - # :call-seq: delete(object) -> self + # :call-seq: delete(element) -> self # # Deletes the given range or number from the set and returns +self+. # @@ -738,8 +742,8 @@ def add?(object) # many elements at once. # # Related: #delete?, #delete_at, #subtract, #difference - def delete(object) - tuple_subtract input_to_tuple object + def delete(element) + tuple_subtract input_to_tuple element normalize! end @@ -775,8 +779,8 @@ def delete(object) # #string will be regenerated after deletion. # # Related: #delete, #delete_at, #subtract, #difference, #disjoint? - def delete?(object) - tuple = input_to_tuple object + def delete?(element) + tuple = input_to_tuple element if tuple.first == tuple.last return unless include_tuple? tuple tuple_subtract tuple @@ -820,33 +824,31 @@ def slice!(index, length = nil) deleted end - # Merges all of the elements that appear in any of the +inputs+ into the + # Merges all of the elements that appear in any of the +sets+ into the # set, and returns +self+. # - # The +inputs+ may be any objects that would be accepted by ::new: - # non-zero 32 bit unsigned integers, ranges, sequence-set - # formatted strings, other sequence sets, or enumerables containing any of - # these. + # The +sets+ may be any objects that would be accepted by ::new: non-zero + # 32 bit unsigned integers, ranges, sequence-set formatted + # strings, other sequence sets, or enumerables containing any of these. # - # #string will be regenerated after all inputs have been merged. + # #string will be regenerated after all sets have been merged. # # Related: #add, #add?, #union - def merge(*inputs) - tuples_add input_to_tuples inputs + def merge(*sets) + tuples_add input_to_tuples sets normalize! end - # Removes all of the elements that appear in any of the given +objects+ - # from the set, and returns +self+. + # Removes all of the elements that appear in any of the given +sets+ from + # the set, and returns +self+. # - # The +objects+ may be any objects that would be accepted by ::new: - # non-zero 32 bit unsigned integers, ranges, sequence-set - # formatted strings, other sequence sets, or enumerables containing any of - # these. + # The +sets+ may be any objects that would be accepted by ::new: non-zero + # 32 bit unsigned integers, ranges, sequence-set formatted + # strings, other sequence sets, or enumerables containing any of these. # # Related: #difference - def subtract(*objects) - tuples_subtract input_to_tuples objects + def subtract(*sets) + tuples_subtract input_to_tuples sets normalize! end @@ -1367,6 +1369,18 @@ def send_data(imap, tag) # :nodoc: imap.__send__(:put_string, valid_string) end + # For YAML serialization + def encode_with(coder) # :nodoc: + # we can perfectly reconstruct from the string + coder['string'] = to_s + end + + # For YAML deserialization + def init_with(coder) # :nodoc: + @tuples = [] + self.string = coder['string'] + end + protected attr_reader :tuples # :nodoc: @@ -1386,30 +1400,30 @@ def initialize_dup(other) super end - def input_to_tuple(obj) - obj = input_try_convert obj - case obj - when *STARS, Integer then [int = to_tuple_int(obj), int] - when Range then range_to_tuple(obj) - when String then str_to_tuple(obj) + def input_to_tuple(entry) + entry = input_try_convert entry + case entry + when *STARS, Integer then [int = to_tuple_int(entry), int] + when Range then range_to_tuple(entry) + when String then str_to_tuple(entry) else - raise DataFormatError, "expected number or range, got %p" % [obj] + raise DataFormatError, "expected number or range, got %p" % [entry] end end - def input_to_tuples(obj) - obj = input_try_convert obj - case obj - when *STARS, Integer, Range then [input_to_tuple(obj)] - when String then str_to_tuples obj - when SequenceSet then obj.tuples - when Set then obj.map { [to_tuple_int(_1)] * 2 } - when Array then obj.flat_map { input_to_tuples _1 } + def input_to_tuples(set) + set = input_try_convert set + case set + when *STARS, Integer, Range then [input_to_tuple(set)] + when String then str_to_tuples set + when SequenceSet then set.tuples + when Set then set.map { [to_tuple_int(_1)] * 2 } + when Array then set.flat_map { input_to_tuples _1 } when nil then [] else raise DataFormatError, "expected nz-number, range, string, or enumerable; " \ - "got %p" % [obj] + "got %p" % [set] end end diff --git a/test/net/imap/fake_server/command_reader.rb b/test/net/imap/fake_server/command_reader.rb index b840384f5..8495e5d45 100644 --- a/test/net/imap/fake_server/command_reader.rb +++ b/test/net/imap/fake_server/command_reader.rb @@ -3,6 +3,7 @@ require "net/imap" class Net::IMAP::FakeServer + CommandParseError = RuntimeError class CommandReader attr_reader :last_command @@ -21,7 +22,10 @@ def get_command $2 or socket.print "+ Continue\r\n" buf << socket.read(Integer($1)) end + throw :eof if buf.empty? @last_command = parse(buf) + rescue CommandParseError => err + raise IOError, err.message if socket.eof? && !buf.end_with?("\r\n") end private @@ -31,7 +35,7 @@ def get_command # TODO: convert bad command exception to tagged BAD response, when possible def parse(buf) /\A([^ ]+) ((?:UID )?\w+)(?: (.+))?\r\n\z/min =~ buf or - raise "bad request: %p" [buf] + raise CommandParseError, "bad request: %p" [buf] case $2.upcase when "LOGIN", "SELECT", "EXAMINE", "ENABLE", "AUTHENTICATE" Command.new $1, $2, scan_astrings($3), buf diff --git a/test/net/imap/fake_server/command_router.rb b/test/net/imap/fake_server/command_router.rb index 82430316d..6865112ca 100644 --- a/test/net/imap/fake_server/command_router.rb +++ b/test/net/imap/fake_server/command_router.rb @@ -10,7 +10,9 @@ module Routable def on(*command_names, &handler) scope = self.is_a?(Module) ? self : singleton_class command_names.each do |command_name| - scope.define_method("handle_#{command_name.downcase}", &handler) + method_name = :"handle_#{command_name.downcase}" + scope.undef_method(method_name) if scope.method_defined?(method_name) + scope.define_method(method_name, &handler) end end end diff --git a/test/net/imap/fake_server/connection.rb b/test/net/imap/fake_server/connection.rb index d8813bfd5..711ab6cc1 100644 --- a/test/net/imap/fake_server/connection.rb +++ b/test/net/imap/fake_server/connection.rb @@ -23,7 +23,9 @@ def unsolicited(...) writer.untagged(...) end def run writer.greeting - router << reader.get_command until state.logout? + catch(:eof) do + router << reader.get_command until state.logout? + end ensure close end diff --git a/test/net/imap/fake_server/socket.rb b/test/net/imap/fake_server/socket.rb index 21795aa59..65593d86f 100644 --- a/test/net/imap/fake_server/socket.rb +++ b/test/net/imap/fake_server/socket.rb @@ -18,6 +18,7 @@ def initialize(tcp_socket, config:) def tls?; !!@tls_socket end def closed?; @closed end + def eof?; socket.eof? end def gets(...) socket.gets(...) end def read(...) socket.read(...) end def print(...) socket.print(...) end diff --git a/test/net/imap/fake_server/test_helper.rb b/test/net/imap/fake_server/test_helper.rb index 1987d048f..8a91939e0 100644 --- a/test/net/imap/fake_server/test_helper.rb +++ b/test/net/imap/fake_server/test_helper.rb @@ -4,17 +4,34 @@ module Net::IMAP::FakeServer::TestHelper - def run_fake_server_in_thread(ignore_io_error: false, timeout: 10, **opts) + IO_ERRORS = [ + IOError, + EOFError, + Errno::ECONNABORTED, + Errno::ECONNRESET, + Errno::EPIPE, + Errno::ETIMEDOUT, + ].freeze + + def run_fake_server_in_thread(ignore_io_error: false, + report_on_exception: true, + timeout: 10, **opts) Timeout.timeout(timeout) do server = Net::IMAP::FakeServer.new(timeout: timeout, **opts) @threads << Thread.new do + Thread.current.abort_on_exception = false + Thread.current.report_on_exception = report_on_exception server.run - rescue IOError + rescue *IO_ERRORS raise unless ignore_io_error end yield server ensure - server&.shutdown + begin + server&.shutdown + rescue *IO_ERRORS + raise unless ignore_io_error + end end end diff --git a/test/net/imap/fixtures/response_parser/esearch_responses.yml b/test/net/imap/fixtures/response_parser/esearch_responses.yml index d070d4337..b76e0da12 100644 --- a/test/net/imap/fixtures/response_parser/esearch_responses.yml +++ b/test/net/imap/fixtures/response_parser/esearch_responses.yml @@ -25,11 +25,6 @@ - - ALL - !ruby/object:Net::IMAP::SequenceSet string: 2,10:11 - tuples: - - - 2 - - 2 - - - 10 - - 11 raw_data: "* ESEARCH (TAG \"A283\") ALL 2,10:11\r\n" rfc9051_6.4.4_ESEARCH_example_3: @@ -53,9 +48,6 @@ - - ALL - !ruby/object:Net::IMAP::SequenceSet string: '43' - tuples: - - - 43 - - 43 raw_data: "* ESEARCH (TAG \"A285\") ALL 43\r\n" rfc9051_6.4.4_ESEARCH_example_5: @@ -107,11 +99,6 @@ - - ALL - !ruby/object:Net::IMAP::SequenceSet string: '17,900,901' - tuples: - - - 17 - - 17 - - - 900 - - 901 raw_data: "* ESEARCH (TAG \"A301\") UID ALL 17,900,901\r\n" rfc9051_6.4.4.4_ESEARCH_example_2: @@ -125,15 +112,6 @@ - - ALL - !ruby/object:Net::IMAP::SequenceSet string: 882,1102,3003,3005:3006 - tuples: - - - 882 - - 882 - - - 1102 - - 1102 - - - 3003 - - 3003 - - - 3005 - - 3006 raw_data: "* ESEARCH (TAG \"P283\") ALL 882,1102,3003,3005:3006\r\n" rfc9051_6.4.4.4_ESEARCH_example_3: @@ -147,13 +125,6 @@ - - ALL - !ruby/object:Net::IMAP::SequenceSet string: 3:15,27,29:103 - tuples: - - - 3 - - 15 - - - 27 - - 27 - - - 29 - - 103 raw_data: "* ESEARCH (TAG \"G283\") ALL 3:15,27,29:103\r\n" rfc9051_6.4.4.4_ESEARCH_example_4: @@ -167,13 +138,6 @@ - - ALL - !ruby/object:Net::IMAP::SequenceSet string: 2,10:15,21 - tuples: - - - 2 - - 2 - - - 10 - - 15 - - - 21 - - 21 raw_data: "* ESEARCH (TAG \"C283\") ALL 2,10:15,21\r\n" rfc9051_6.4.4.4_ESEARCH_example_5: @@ -231,13 +195,6 @@ - - ALL - !ruby/object:Net::IMAP::SequenceSet string: 2,10:15,21 - tuples: - - - 2 - - 2 - - - 10 - - 15 - - - 21 - - 21 raw_data: "* ESEARCH (TAG \"C286\") MIN 2 ALL 2,10:15,21\r\n" rfc9051_7.1_ESEARCH_example_1: @@ -251,19 +208,6 @@ - - ALL - !ruby/object:Net::IMAP::SequenceSet string: 1:3,5,8,13,21,42 - tuples: - - - 1 - - 3 - - - 5 - - 5 - - - 8 - - 8 - - - 13 - - 13 - - - 21 - - 21 - - - 42 - - 42 raw_data: "* ESEARCH (TAG \"h\") ALL 1:3,5,8,13,21,42\r\n" rfc9051_7.3.4_ESEARCH_example_1: @@ -279,13 +223,6 @@ - - ALL - !ruby/object:Net::IMAP::SequenceSet string: 4:18,21,28 - tuples: - - - 4 - - 18 - - - 21 - - 21 - - - 28 - - 28 raw_data: "* ESEARCH UID COUNT 17 ALL 4:18,21,28\r\n" rfc9051_7.3.4_ESEARCH_example_2: @@ -301,13 +238,6 @@ - - ALL - !ruby/object:Net::IMAP::SequenceSet string: 4:18,21,28 - tuples: - - - 4 - - 18 - - - 21 - - 21 - - - 28 - - 28 raw_data: "* ESEARCH (TAG \"a567\") UID COUNT 17 ALL 4:18,21,28\r\n" rfc9051_7.3.4_ESEARCH_example_3: @@ -323,9 +253,4 @@ - - ALL - !ruby/object:Net::IMAP::SequenceSet string: 1:17,21 - tuples: - - - 1 - - 17 - - - 21 - - 21 raw_data: "* ESEARCH COUNT 18 ALL 1:17,21\r\n" diff --git a/test/net/imap/fixtures/response_parser/rfc7162_condstore_qresync_responses.yml b/test/net/imap/fixtures/response_parser/rfc7162_condstore_qresync_responses.yml index 19fcd1780..9ad950159 100644 --- a/test/net/imap/fixtures/response_parser/rfc7162_condstore_qresync_responses.yml +++ b/test/net/imap/fixtures/response_parser/rfc7162_condstore_qresync_responses.yml @@ -55,12 +55,7 @@ code: !ruby/struct:Net::IMAP::ResponseCode name: MODIFIED data: !ruby/object:Net::IMAP::SequenceSet - str: '7,9' - tuples: - - - 7 - - 7 - - - 9 - - 9 + string: '7,9' text: Conditional STORE failed raw_data: "d105 OK [MODIFIED 7,9] Conditional STORE failed\r\n" @@ -109,11 +104,6 @@ - - ALL - !ruby/object:Net::IMAP::SequenceSet string: 1:3,5 - tuples: - - - 1 - - 3 - - - 5 - - 5 - - MODSEQ - 1236 raw_data: "* ESEARCH (TAG \"a\") ALL 1:3,5 MODSEQ 1236\r\n" @@ -129,11 +119,6 @@ - - ALL - !ruby/object:Net::IMAP::SequenceSet string: '5,3,2,1' - tuples: - - - 1 - - 3 - - - 5 - - 5 - - MODSEQ - 1236 raw_data: "* ESEARCH (TAG \"a\") ALL 5,3,2,1 MODSEQ 1236\r\n" @@ -145,17 +130,6 @@ data: !ruby/object:Net::IMAP::VanishedData uids: !ruby/object:Net::IMAP::SequenceSet string: 41,43:116,118,120:211,214:540 - tuples: - - - 41 - - 41 - - - 43 - - 116 - - - 118 - - 118 - - - 120 - - 211 - - - 214 - - 540 earlier: true raw_data: "* VANISHED (EARLIER) 41,43:116,118,120:211,214:540\r\n" @@ -166,14 +140,5 @@ data: !ruby/object:Net::IMAP::VanishedData uids: !ruby/object:Net::IMAP::SequenceSet string: '405,407,410,425' - tuples: - - - 405 - - 405 - - - 407 - - 407 - - - 410 - - 410 - - - 425 - - 425 earlier: false raw_data: "* VANISHED 405,407,410,425\r\n" diff --git a/test/net/imap/fixtures/response_parser/rfc9394_partial.yml b/test/net/imap/fixtures/response_parser/rfc9394_partial.yml index 233a9096e..19498e3e1 100644 --- a/test/net/imap/fixtures/response_parser/rfc9394_partial.yml +++ b/test/net/imap/fixtures/response_parser/rfc9394_partial.yml @@ -20,11 +20,6 @@ excl: false results: !ruby/object:Net::IMAP::SequenceSet string: 200:250,252:300 - tuples: - - - 200 - - 250 - - - 252 - - 300 raw_data: "* ESEARCH (TAG \"A01\") UID PARTIAL (-1:-100 200:250,252:300)\r\n" "RFC9394 PARTIAL 3.1. example 2": @@ -43,9 +38,6 @@ excl: false results: !ruby/object:Net::IMAP::SequenceSet string: 55500:56000 - tuples: - - - 55500 - - 56000 raw_data: "* ESEARCH (TAG \"A02\") UID PARTIAL (23500:24000 55500:56000)\r\n" "RFC9394 PARTIAL 3.1. example 3": diff --git a/test/net/imap/fixtures/response_parser/status_responses.yml b/test/net/imap/fixtures/response_parser/status_responses.yml index 074b56ad6..85581360c 100644 --- a/test/net/imap/fixtures/response_parser/status_responses.yml +++ b/test/net/imap/fixtures/response_parser/status_responses.yml @@ -52,11 +52,6 @@ SEQ: !ruby/struct:Net::IMAP::ExtensionData data: !ruby/object:Net::IMAP::SequenceSet string: 1234:5,*:789654 - tuples: - - - 5 - - 1234 - - - 789654 - - 4294967296 COMP-EMPTY: !ruby/struct:Net::IMAP::ExtensionData data: [] COMP-QUOTED: !ruby/struct:Net::IMAP::ExtensionData diff --git a/test/net/imap/test_config.rb b/test/net/imap/test_config.rb index fa11c74a0..aa9ed5e7f 100644 --- a/test/net/imap/test_config.rb +++ b/test/net/imap/test_config.rb @@ -5,6 +5,9 @@ class ConfigTest < Test::Unit::TestCase Config = Net::IMAP::Config + THIS_VERSION = Net::IMAP::VERSION.to_f + NEXT_VERSION = THIS_VERSION + 0.1 + FUTURE_VERSION = THIS_VERSION + 0.2 setup do Config.global.reset @@ -137,23 +140,44 @@ class ConfigTest < Test::Unit::TestCase test ".version_defaults are all frozen, and inherit debug from global" do Config.version_defaults.each do |name, config| - assert [0, Float, Symbol].any? { _1 === name } + assert [0, Float, Rational, Symbol].any? { _1 === name } assert_kind_of Config, config assert config.frozen?, "#{name} isn't frozen" assert config.inherited?(:debug), "#{name} doesn't inherit debug" + keys = config.to_h.keys - [:debug] + keys.each do |key| + refute config.inherited?(key) + end assert_same Config.global, config.parent end end + test "Config[:default] and Config[:current] both hold default config" do + defaults = Config.default.to_h + assert_equal(defaults, Config[:default].to_h) + assert_equal(defaults, Config[:current].to_h) + end + + test ".[] for all version_defaults" do + Config.version_defaults.each do |version, config| + assert_same Config[version], config + end + end + test ".[] for all x.y versions" do - original = Config[0] + original = Config[0r] assert_kind_of Config, original + assert_same original, Config[0] assert_same original, Config[0.0] assert_same original, Config[0.1] assert_same original, Config[0.2] assert_same original, Config[0.3] - assert_kind_of Config, Config[0.4] - assert_kind_of Config, Config[0.5] + ((0.4r..FUTURE_VERSION.to_r) % 0.1r).each do |version| + config = Config[version] + assert_kind_of Config, config + assert_same config, Config[version.to_f] + assert_same config, Config[version.to_f.to_r] + end end test ".[] range errors" do @@ -166,13 +190,61 @@ class ConfigTest < Test::Unit::TestCase test ".[] key errors" do assert_raise(KeyError) do Config[:nonexistent] end + assert_raise(KeyError) do Config["nonexistent"] end + assert_raise(KeyError) do Config["0.01"] end end test ".[] with symbol names" do - assert_same Config[0.5], Config[:current] - assert_same Config[0.5], Config[:default] - assert_same Config[0.6], Config[:next] - assert_kind_of Config, Config[:future] + assert_equal Config[THIS_VERSION].to_h, Config[:default].to_h + assert_same Config[THIS_VERSION], Config[:current] + assert_same Config[NEXT_VERSION], Config[:next] + assert_same Config[FUTURE_VERSION], Config[:future] + end + + test ".[] with string names" do + assert_same Config[:original], Config["original"] + assert_same Config[:current], Config["current"] + assert_same Config[0.4r], Config["0.4.11"] + assert_same Config[0.5r], Config["0.5.6"] + assert_same Config[:current], Config[Net::IMAP::VERSION] + end + + test ".[] with object responding to to_sym, to_r, or to_f" do + # responds to none of the methods + duck = Object.new + assert_raise TypeError do Config[duck] end + + # to_sym + duck = Object.new + def duck.to_sym = :current + assert_same Config[:current], Config[duck] + + # to_r + duck = Object.new + def duck.to_r = 0.6r + assert_same Config[0.6r], Config[duck] + + # to_f + duck = Object.new + def duck.to_f = 0.4 + assert_same Config[0.4], Config[duck] + + # prefer to_r over to_f + def duck.to_r = 0.5r + assert_same Config[0.5r], Config[duck] + + # prefer to_sym over to_r + def duck.to_sym = :original + assert_same Config[:original], Config[duck] + + # keeps trying if to_sym finds nothing + duck = Object.new + def duck.to_sym = :nope + def duck.to_f = 0.5 + assert_same Config[0.5], Config[duck] + # keeps trying if to_sym and to_r both find nothing + def duck.to_r = 1/11111 + assert_same Config[0.5], Config[duck] end test ".[] with a hash" do @@ -190,7 +262,7 @@ class ConfigTest < Test::Unit::TestCase assert_same Config.default, Config.new(Config.default).parent assert_same Config.global, Config.new(Config.global).parent assert_same Config[0.4], Config.new(0.4).parent - assert_same Config[0.6], Config.new(:next).parent + assert_same Config[NEXT_VERSION], Config.new(:next).parent assert_equal true, Config.new({debug: true}, debug: false).parent.debug? assert_equal true, Config.new({debug: true}, debug: false).parent.frozen? end @@ -355,4 +427,17 @@ class ConfigTest < Test::Unit::TestCase assert_same grandchild, greatgrandchild.parent end + test "#max_response_size=(Integer | nil)" do + config = Config.new + + config.max_response_size = 10_000 + assert_equal 10_000, config.max_response_size + + config.max_response_size = nil + assert_nil config.max_response_size + + assert_raise(ArgumentError) do config.max_response_size = "invalid" end + assert_raise(TypeError) do config.max_response_size = :invalid end + end + end diff --git a/test/net/imap/test_errors.rb b/test/net/imap/test_errors.rb new file mode 100644 index 000000000..a6a7cb0f4 --- /dev/null +++ b/test/net/imap/test_errors.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "net/imap" +require "test/unit" + +class IMAPErrorsTest < Test::Unit::TestCase + + test "ResponseTooLargeError" do + err = Net::IMAP::ResponseTooLargeError.new + assert_nil err.bytes_read + assert_nil err.literal_size + assert_nil err.max_response_size + + err = Net::IMAP::ResponseTooLargeError.new("manually set message") + assert_equal "manually set message", err.message + assert_nil err.bytes_read + assert_nil err.literal_size + assert_nil err.max_response_size + + err = Net::IMAP::ResponseTooLargeError.new(max_response_size: 1024) + assert_equal "Response size exceeds max_response_size (1024B)", err.message + assert_nil err.bytes_read + assert_nil err.literal_size + assert_equal 1024, err.max_response_size + + err = Net::IMAP::ResponseTooLargeError.new(bytes_read: 1200, + max_response_size: 1200) + assert_equal 1200, err.bytes_read + assert_equal "Response size exceeds max_response_size (1200B)", err.message + + err = Net::IMAP::ResponseTooLargeError.new(bytes_read: 800, + literal_size: 1000, + max_response_size: 1200) + assert_equal 800, err.bytes_read + assert_equal 1000, err.literal_size + assert_equal("Response size (800B read + 1000B literal) " \ + "exceeds max_response_size (1200B)", err.message) + end + +end diff --git a/test/net/imap/test_imap_connection_state.rb b/test/net/imap/test_imap_connection_state.rb new file mode 100644 index 000000000..41f4f5bfb --- /dev/null +++ b/test/net/imap/test_imap_connection_state.rb @@ -0,0 +1,226 @@ +# frozen_string_literal: true + +require "net/imap" +require "test/unit" +require_relative "fake_server" + +class ConnectionStateTest < Test::Unit::TestCase + NotAuthenticated = Net::IMAP::ConnectionState::NotAuthenticated + Authenticated = Net::IMAP::ConnectionState::Authenticated + Selected = Net::IMAP::ConnectionState::Selected + Logout = Net::IMAP::ConnectionState::Logout + + test "#name" do + assert_equal "not_authenticated", NotAuthenticated[].name + assert_equal "authenticated", Authenticated[] .name + assert_equal "selected", Selected[] .name + assert_equal "logout", Logout[] .name + end + + + test "#to_sym" do + assert_equal :not_authenticated, NotAuthenticated[].to_sym + assert_equal :authenticated, Authenticated[] .to_sym + assert_equal :selected, Selected[] .to_sym + assert_equal :logout, Logout[] .to_sym + end + + test "#deconstruct" do + assert_equal [:not_authenticated], NotAuthenticated[].deconstruct + assert_equal [:authenticated], Authenticated[] .deconstruct + assert_equal [:selected], Selected[] .deconstruct + assert_equal [:logout], Logout[] .deconstruct + end + + test "#deconstruct_keys" do + assert_equal({symbol: :not_authenticated}, NotAuthenticated[].deconstruct_keys([:symbol])) + assert_equal({symbol: :authenticated}, Authenticated[] .deconstruct_keys([:symbol])) + assert_equal({symbol: :selected}, Selected[] .deconstruct_keys([:symbol])) + assert_equal({symbol: :logout}, Logout[] .deconstruct_keys([:symbol])) + assert_equal({name: "not_authenticated"}, NotAuthenticated[].deconstruct_keys([:name])) + assert_equal({name: "authenticated"}, Authenticated[] .deconstruct_keys([:name])) + assert_equal({name: "selected"}, Selected[] .deconstruct_keys([:name])) + assert_equal({name: "logout"}, Logout[] .deconstruct_keys([:name])) + end + + test "#not_authenticated?" do + assert_equal true, NotAuthenticated[].not_authenticated? + assert_equal false, Authenticated[] .not_authenticated? + assert_equal false, Selected[] .not_authenticated? + assert_equal false, Logout[] .not_authenticated? + end + + test "#authenticated?" do + assert_equal false, NotAuthenticated[].authenticated? + assert_equal true, Authenticated[] .authenticated? + assert_equal false, Selected[] .authenticated? + assert_equal false, Logout[] .authenticated? + end + + test "#selected?" do + assert_equal false, NotAuthenticated[].selected? + assert_equal false, Authenticated[] .selected? + assert_equal true, Selected[] .selected? + assert_equal false, Logout[] .selected? + end + + test "#logout?" do + assert_equal false, NotAuthenticated[].logout? + assert_equal false, Authenticated[] .logout? + assert_equal false, Selected[] .logout? + assert_equal true, Logout[] .logout? + end + +end + +class IMAPConnectionStateTest < 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 = [] + end + + def teardown + if !@threads.empty? + assert_join_threads(@threads) + end + ensure + Socket.do_not_reverse_lookup = @do_not_reverse_lookup + end + + test "#connection_state after AUTHENTICATE, SELECT, CLOSE successes" do + with_fake_server(preauth: false) do |server, imap| + # AUTHENTICATE, SELECT, CLOSE + assert_equal :not_authenticated, imap.connection_state.to_sym + imap.authenticate :plain, "test_user", "test-password" + assert_equal :authenticated, imap.connection_state.to_sym + imap.select "INBOX" + assert_equal :selected, imap.connection_state.to_sym + imap.close + assert_equal :authenticated, imap.connection_state.to_sym + end + end + + test "#connection_state after LOGIN, EXAMINE, UNSELECT successes" do + with_fake_server(preauth: false, cleartext_login: true) do |server, imap| + assert_equal :not_authenticated, imap.connection_state.to_sym + imap.login "test_user", "test-password" + assert_equal :authenticated, imap.connection_state.to_sym + imap.examine "INBOX" + assert_equal :selected, imap.connection_state.to_sym + imap.unselect + assert_equal :authenticated, imap.connection_state.to_sym + end + end + + test "#connection_state after PREAUTH" do + with_fake_server(preauth: true) do |server, imap| + assert_equal :authenticated, imap.connection_state.to_sym + imap.select "INBOX" + assert_equal :selected, imap.connection_state.to_sym + imap.unselect + assert_equal :authenticated, imap.connection_state.to_sym + end + end + + test "#connection_state after [CLOSED] response code" do + with_fake_server(select: "INBOX") do |server, imap| + # NOOP doesn't _normally_ change the connection_state + assert_equal :selected, imap.connection_state.to_sym + server.on("NOOP", &:done_ok) + imap.noop + assert_equal :selected, imap.connection_state.to_sym + + # using NOOP to trigger the response code + server.on("NOOP") do |resp| + resp.untagged "OK", "[CLOSED] server maintenance" + resp.done_ok + end + imap.noop + assert_equal :authenticated, imap.connection_state.to_sym + end + end + + test "#connection_state after failed LOGIN or AUTHENTICATE" do + with_fake_server(preauth: false, cleartext_login: false) do |server, imap| + assert_raise(Net::IMAP::LoginDisabledError) do imap.login "foo", "bar" end + assert_equal :not_authenticated, imap.connection_state.to_sym + + imap.config.enforce_logindisabled = false + server.on "LOGIN" do |cmd| cmd.fail_no "nope" end + server.on "AUTHENTICATE" do |cmd| cmd.fail_no "nope" end + + assert_raise(Net::IMAP::NoResponseError) do + imap.login "foo", "bar" + end + assert_equal :not_authenticated, imap.connection_state.to_sym + + assert_raise(Net::IMAP::NoResponseError) do + imap.authenticate :plain, "foo", "bar" + end + assert_equal :not_authenticated, imap.connection_state.to_sym + + server.on "LOGIN" do |cmd| cmd.fail_bad "bad!" end + server.on "AUTHENTICATE" do |cmd| cmd.fail_bad "bad!" end + + assert_raise(Net::IMAP::BadResponseError) do + imap.login "foo", "bar" + end + assert_equal :not_authenticated, imap.connection_state.to_sym + + assert_raise(Net::IMAP::BadResponseError) do + imap.authenticate :plain, "foo", "bar" + end + assert_equal :not_authenticated, imap.connection_state.to_sym + end + end + + test "#connection_state after failed SELECT or EXAMINE" do + with_fake_server(preauth: true) do |server, imap| + # good SELECT to enter the :selected state + imap.select "INBOX" + assert_equal :selected, imap.connection_state.to_sym + # bad SELECT enters the :authenticated state + assert_raise(Net::IMAP::NoResponseError) do + imap.select "doesn't exist" + end + assert_equal :authenticated, imap.connection_state.to_sym + + # back into the :selected state + imap.examine "INBOX" + assert_equal :selected, imap.connection_state.to_sym + # bad EXAMINE enters the :authenticated state + assert_raise(Net::IMAP::NoResponseError) do + imap.examine "doesn't exist" + end + assert_equal :authenticated, imap.connection_state.to_sym + end + end + + test "#connection_state after #logout" do + with_fake_server do |server, imap| + imap.logout + assert_equal :logout, imap.connection_state.to_sym + imap.disconnect # avoid `logout!` warning and wait for closed socket + end + end + + test "#connection_state after #logout!" do + with_fake_server do |server, imap| + imap.logout! + assert_equal :logout, imap.connection_state.to_sym + end + end + + test "#connection_state after #disconnect" do + with_fake_server(ignore_io_error: true) do + |server, imap| + imap.disconnect + assert_equal :logout, imap.connection_state.to_sym + end + end + +end diff --git a/test/net/imap/test_imap_max_response_size.rb b/test/net/imap/test_imap_max_response_size.rb new file mode 100644 index 000000000..3751d0bc8 --- /dev/null +++ b/test/net/imap/test_imap_max_response_size.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +require "net/imap" +require "test/unit" +require_relative "fake_server" + +class IMAPMaxResponseSizeTest < 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 = [] + end + + def teardown + if !@threads.empty? + assert_join_threads(@threads) + end + ensure + Socket.do_not_reverse_lookup = @do_not_reverse_lookup + end + + test "#max_response_size reading literals" do + with_fake_server(preauth: true) do |server, imap| + imap.max_response_size = 12_345 + 30 + server.on("NOOP") do |resp| + resp.untagged("1 FETCH (BODY[] {12345}\r\n" + "a" * 12_345 + ")") + resp.done_ok + end + imap.noop + assert_equal "a" * 12_345, imap.responses("FETCH").first.message + end + end + + test "#max_response_size closes connection for too long line" do + Net::IMAP.config.max_response_size = 10 + run_fake_server_in_thread(preauth: false, ignore_io_error: true) do |server| + assert_raise_with_message( + Net::IMAP::ResponseTooLargeError, /exceeds max_response_size .*\b10B\b/ + ) do + with_client("localhost", port: server.port) do + fail "should not get here (greeting longer than max_response_size)" + end + end + end + end + + test "#max_response_size closes connection for too long literal" do + Net::IMAP.config.max_response_size = 1<<20 + with_fake_server(preauth: false, ignore_io_error: true) do |server, client| + client.max_response_size = 50 + server.on("NOOP") do |resp| + resp.untagged("1 FETCH (BODY[] {1000}\r\n" + "a" * 1000 + ")") + end + assert_raise_with_message( + Net::IMAP::ResponseTooLargeError, + /\d+B read \+ 1000B literal.* exceeds max_response_size .*\b50B\b/ + ) do + client.noop + fail "should not get here (FETCH literal longer than max_response_size)" + end + end + end + +end diff --git a/test/net/imap/test_imap_response_handlers.rb b/test/net/imap/test_imap_response_handlers.rb new file mode 100644 index 000000000..f513d867f --- /dev/null +++ b/test/net/imap/test_imap_response_handlers.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +require "net/imap" +require "test/unit" +require_relative "fake_server" + +class IMAPResponseHandlersTest < 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 = [] + end + + def teardown + if !@threads.empty? + assert_join_threads(@threads) + end + ensure + Socket.do_not_reverse_lookup = @do_not_reverse_lookup + end + + test "#add_response_handlers" do + responses = [] + with_fake_server do |server, imap| + server.on("NOOP") do |resp| + 3.times do resp.untagged("#{_1 + 1} EXPUNGE") end + resp.done_ok + end + + assert_equal 0, imap.response_handlers.length + imap.add_response_handler do responses << [:block, _1] end + assert_equal 1, imap.response_handlers.length + imap.add_response_handler(->{ responses << [:proc, _1] }) + assert_equal 2, imap.response_handlers.length + + imap.noop + assert_pattern do + responses => [ + [:block, Net::IMAP::UntaggedResponse[name: "EXPUNGE", data: 1]], + [:proc, Net::IMAP::UntaggedResponse[name: "EXPUNGE", data: 1]], + [:block, Net::IMAP::UntaggedResponse[name: "EXPUNGE", data: 2]], + [:proc, Net::IMAP::UntaggedResponse[name: "EXPUNGE", data: 2]], + [:block, Net::IMAP::UntaggedResponse[name: "EXPUNGE", data: 3]], + [:proc, Net::IMAP::UntaggedResponse[name: "EXPUNGE", data: 3]], + ] + end + end + end + + test "::new with response_handlers kwarg" do + greeting = nil + expunges = [] + alerts = [] + untagged = 0 + handler0 = ->{ greeting ||= _1 } + handler1 = ->{ alerts << _1.data.text if _1 in {data: {code: {name: "ALERT"}}} } + handler2 = ->{ expunges << _1.data if _1 in {name: "EXPUNGE"} } + handler3 = ->{ untagged += 1 if _1.is_a?(Net::IMAP::UntaggedResponse) } + response_handlers = [handler0, handler1, handler2, handler3] + + run_fake_server_in_thread do |server| + port = server.port + imap = Net::IMAP.new("localhost", port:, response_handlers:) + assert_equal response_handlers, imap.response_handlers + refute_same response_handlers, imap.response_handlers + + # handler0 recieved the greeting and handler3 counted it + assert_equal imap.greeting, greeting + assert_equal 1, untagged + + server.on("NOOP") do |resp| + resp.untagged "1 EXPUNGE" + resp.untagged "1 EXPUNGE" + resp.untagged "OK [ALERT] The first alert." + resp.done_ok "[ALERT] Did you see the alert?" + end + + imap.noop + assert_equal 4, untagged + assert_equal [1, 1], expunges # from handler2 + assert_equal ["The first alert.", "Did you see the alert?"], alerts + ensure + imap&.logout! unless imap&.disconnected? + end + end + +end diff --git a/test/net/imap/test_response_reader.rb b/test/net/imap/test_response_reader.rb new file mode 100644 index 000000000..5d64c07a0 --- /dev/null +++ b/test/net/imap/test_response_reader.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require "net/imap" +require "stringio" +require "test/unit" + +class ResponseReaderTest < Test::Unit::TestCase + def setup + Net::IMAP.config.reset + end + + class FakeClient + def config = @config ||= Net::IMAP.config.new + def max_response_size = config.max_response_size + end + + def literal(str) = "{#{str.bytesize}}\r\n#{str}" + + test "#read_response_buffer" do + client = FakeClient.new + aaaaaaaaa = "a" * (20 << 10) + many_crs = "\r" * 1000 + many_crlfs = "\r\n" * 500 + simple = "* OK greeting\r\n" + long_line = "tag ok #{aaaaaaaaa} #{aaaaaaaaa}\r\n" + literal_aaaa = "* fake #{literal aaaaaaaaa}\r\n" + literal_crlf = "tag ok #{literal many_crlfs} #{literal many_crlfs}\r\n" + zero_literal = "tag ok #{literal ""} #{literal ""}\r\n" + illegal_crs = "tag ok #{many_crs} #{many_crs}\r\n" + illegal_lfs = "tag ok #{literal "\r"}\n#{literal "\r"}\n\r\n" + io = StringIO.new([ + simple, + long_line, + literal_aaaa, + literal_crlf, + zero_literal, + illegal_crs, + illegal_lfs, + simple, + ].join) + rcvr = Net::IMAP::ResponseReader.new(client, io) + assert_equal simple, rcvr.read_response_buffer.to_str + assert_equal long_line, rcvr.read_response_buffer.to_str + assert_equal literal_aaaa, rcvr.read_response_buffer.to_str + assert_equal literal_crlf, rcvr.read_response_buffer.to_str + assert_equal zero_literal, rcvr.read_response_buffer.to_str + assert_equal illegal_crs, rcvr.read_response_buffer.to_str + assert_equal illegal_lfs, rcvr.read_response_buffer.to_str + assert_equal simple, rcvr.read_response_buffer.to_str + assert_equal "", rcvr.read_response_buffer.to_str + end + + test "#read_response_buffer with max_response_size" do + client = FakeClient.new + client.config.max_response_size = 10 + under = "+ 3456\r\n" + exact = "+ 345678\r\n" + over = "+ 3456789\r\n" + io = StringIO.new([under, exact, over].join) + rcvr = Net::IMAP::ResponseReader.new(client, io) + assert_equal under, rcvr.read_response_buffer.to_str + assert_equal exact, rcvr.read_response_buffer.to_str + assert_raise Net::IMAP::ResponseTooLargeError do + rcvr.read_response_buffer + end + end + +end