diff --git a/lib/net/imap.rb b/lib/net/imap.rb
index c3cb89c09..8215cdb4e 100644
--- a/lib/net/imap.rb
+++ b/lib/net/imap.rb
@@ -43,10 +43,16 @@ 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.
+ #
# === Sequence numbers and UIDs
#
# Messages have two sorts of identifiers: message sequence
@@ -199,6 +205,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 +302,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 +360,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 +411,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 +437,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 +491,7 @@ module Net
# ==== RFC3691: +UNSELECT+
# Folded into IMAP4rev2[https://tools.ietf.org/html/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+
@@ -719,7 +761,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.4.19"
+ VERSION = "0.4.20"
# Aliases for supported capabilities, to be used with the #enable command.
ENABLE_ALIASES = {
@@ -727,6 +769,7 @@ class IMAP < Protocol
"UTF8=ONLY" => "UTF8=ACCEPT",
}.freeze
+ autoload :ResponseReader, File.expand_path("imap/response_reader", __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__)
@@ -741,9 +784,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
@@ -764,7 +809,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.
@@ -773,13 +818,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
@@ -835,6 +895,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.
@@ -906,7 +972,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
@@ -929,6 +995,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"
@@ -944,6 +1011,7 @@ def initialize(host, port: nil, ssl: nil,
# Connection
@tls_verified = false
@sock = tcp_socket(@host, @port)
+ @reader = ResponseReader.new(self, @sock)
start_tls_session if ssl_ctx
start_imap_connection
@@ -1204,6 +1272,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
@@ -2706,6 +2778,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
@@ -2732,6 +2808,7 @@ 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
@sock.close
@@ -2860,23 +2937,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
#############################
@@ -3077,6 +3141,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)
diff --git a/lib/net/imap/config.rb b/lib/net/imap/config.rb
index 79f813555..e6a00dd44 100644
--- a/lib/net/imap/config.rb
+++ b/lib/net/imap/config.rb
@@ -129,8 +129,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
@@ -153,18 +170,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
@@ -191,10 +207,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
@@ -223,6 +242,40 @@ def self.[](config)
# Use +SASL-IR+ when it is supported by the server and the mechanism.
attr_accessor :sasl_ir, type: :boolean
+ # 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. To use a lower limit, fetch message bodies in
+ # chunks rather than all at once. A _much_ lower value should be used
+ # with untrusted servers (for example, when connecting to a user-provided
+ # hostname).
+ #
+ # 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.
+ # See also #socket_read_limit.
+ #
+ # 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+).
@@ -250,7 +303,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,
]
@@ -295,7 +348,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
]
@@ -401,6 +454,7 @@ def defaults_hash
open_timeout: 30,
idle_response_timeout: 5,
sasl_ir: true,
+ max_response_size: nil,
responses_without_block: :silence_deprecation_warning,
parser_use_deprecated_uidplus_data: true,
parser_max_deprecated_uidplus_data_size: 1000,
@@ -408,36 +462,63 @@ def defaults_hash
@global = default.new
- version_defaults[0.4] = Config[default.send(:defaults_hash)]
+ version_defaults[:default] = Config[default.send(:defaults_hash)]
- version_defaults[0] = Config[0.4].dup.update(
+ version_defaults[0r] = Config[:default].dup.update(
sasl_ir: 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.5] = Config[0.4].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.5r] = Config[0.4r].dup.update(
+ 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[:default] = Config[0.4]
- version_defaults[:current] = Config[0.4]
- version_defaults[:next] = Config[0.5]
-
- 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[:future] = Config[0.6]
+
+ 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.is_a? 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[0.7r]
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..e0db0784c 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
end
private_constant :Macros
@@ -26,34 +28,36 @@ 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
+ if defined?(Ractor.make_shareable)
+ def self.safe(...) Ractor.make_shareable nil.instance_eval(...).freeze end
+ else
+ def self.safe(...) nil.instance_eval(...).freeze end
end
+ private_class_method :safe
- def self.boolean(attr)
- define_method :"#{attr}=" do |val| super !!val end
- define_method :"#{attr}?" do send attr end
+ Types = Hash.new do |h, type|
+ type.nil? || Proc === type or raise TypeError, "type not nil or Proc"
+ 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/errors.rb b/lib/net/imap/errors.rb
index a9d929cec..695ed3846 100644
--- a/lib/net/imap/errors.rb
+++ b/lib/net/imap/errors.rb
@@ -11,6 +11,39 @@ class Error < StandardError
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..fd7561fa7
--- /dev/null
+++ b/lib/net/imap/response_reader.rb
@@ -0,0 +1,75 @@
+# 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 end
+ def empty?; buff.empty? end
+ def done?; line_done? && !get_literal_size end
+ def line_done?; buff.end_with?(CRLF) end
+ def get_literal_size; /\{(\d+)\}\r\n\z/n =~ buff && $1.to_i end
+
+ 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 end
+ def max_response_remaining; max_response_size &.- bytes_read end
+ def response_too_large?; max_response_size &.< min_response_size end
+ def min_response_size; bytes_read + min_response_remaining end
+
+ 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: max_response_size,
+ bytes_read: bytes_read,
+ literal_size: literal_size,
+ )
+ end
+
+ end
+ end
+end
diff --git a/lib/net/imap/sequence_set.rb b/lib/net/imap/sequence_set.rb
index ba6a606e0..cbdc2a74b 100644
--- a/lib/net/imap/sequence_set.rb
+++ b/lib/net/imap/sequence_set.rb
@@ -178,7 +178,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 *.
#
@@ -243,13 +243,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
@@ -262,17 +262,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
@@ -283,13 +283,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+.
#
@@ -326,9 +327,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.
#
@@ -694,7 +698,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+.
@@ -702,8 +706,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
@@ -712,9 +716,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
@@ -722,19 +726,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+.
#
@@ -742,8 +746,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
@@ -779,8 +783,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
@@ -824,33 +828,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
@@ -1390,29 +1392,29 @@ 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 ENUMABLE 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 ENUMABLE 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/lib/helper.rb b/test/lib/helper.rb
index e84854a59..47737a36b 100644
--- a/test/lib/helper.rb
+++ b/test/lib/helper.rb
@@ -14,4 +14,16 @@ def wait_for_response_count(imap, type:, count:,
sleep interval
end
end
+
+ # Copied from minitest
+ def assert_pattern
+ flunk "assert_pattern requires a block to capture errors." unless block_given?
+ assert_block do
+ yield
+ true
+ rescue NoMatchingPatternError => e
+ flunk e.message
+ end
+ 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 eee6a153c..4ebe0cd6d 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: 5, **opts)
+ IO_ERRORS = [
+ IOError,
+ EOFError,
+ Errno::ECONNABORTED,
+ Errno::ECONNRESET,
+ Errno::EPIPE,
+ Errno::ETIMEDOUT,
+ ].freeze
+
+ def run_fake_server_in_thread(ignore_io_error: false, timeout: 5,
+ report_on_exception: true,
+ **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/test_config.rb b/test/net/imap/test_config.rb
index 7a2cd174c..1354aa4ff 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 = 0.7
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.4], Config[:current]
- assert_same Config[0.4], Config[:default]
- assert_same Config[0.5], 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 end
+ assert_same Config[:current], Config[duck]
+
+ # to_r
+ duck = Object.new
+ def duck.to_r; 0.6r end
+ assert_same Config[0.6r], Config[duck]
+
+ # to_f
+ duck = Object.new
+ def duck.to_f; 0.4 end
+ assert_same Config[0.4], Config[duck]
+
+ # prefer to_r over to_f
+ def duck.to_r; 0.5r end
+ assert_same Config[0.5r], Config[duck]
+
+ # prefer to_sym over to_r
+ def duck.to_sym; :original end
+ assert_same Config[:original], Config[duck]
+
+ # keeps trying if to_sym finds nothing
+ duck = Object.new
+ def duck.to_sym; :nope end
+ def duck.to_f; 0.5 end
+ assert_same Config[0.5], Config[duck]
+ # keeps trying if to_sym and to_r both find nothing
+ def duck.to_r; 1/11111 end
+ assert_same Config[0.5], Config[duck]
end
test ".[] with a hash" do
@@ -190,8 +262,8 @@ 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.5], Config.new(:next).parent
- assert_same Config[0.6], Config.new(:future).parent
+ assert_same Config[NEXT_VERSION], Config.new(:next).parent
+ assert_same Config[FUTURE_VERSION], Config.new(:future).parent
assert_equal true, Config.new({debug: true}, debug: false).parent.debug?
assert_equal true, Config.new({debug: true}, debug: false).parent.frozen?
end
@@ -356,4 +428,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_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..7e79cf6a6
--- /dev/null
+++ b/test/net/imap/test_imap_response_handlers.rb
@@ -0,0 +1,92 @@
+# 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
+ responses = responses[0, 6].map {|which, resp|
+ [which, resp.class, resp.name, resp.data]
+ }
+ assert_equal [
+ [:block, Net::IMAP::UntaggedResponse, "EXPUNGE", 1],
+ [:proc, Net::IMAP::UntaggedResponse, "EXPUNGE", 1],
+ [:block, Net::IMAP::UntaggedResponse, "EXPUNGE", 2],
+ [:proc, Net::IMAP::UntaggedResponse, "EXPUNGE", 2],
+ [:block, Net::IMAP::UntaggedResponse, "EXPUNGE", 3],
+ [:proc, Net::IMAP::UntaggedResponse, "EXPUNGE", 3],
+ ], responses
+ end
+ end
+
+ test "::new with response_handlers kwarg" do
+ greeting = nil
+ expunges = []
+ alerts = []
+ untagged = 0
+ handler0 = ->{ greeting ||= _1 }
+ handler1 = ->(r) { alerts << r.data.text if r.data.code.name == "ALERT" rescue nil }
+ handler2 = ->(r) { expunges << r.data if r.name == "EXPUNGE" }
+ handler3 = ->(r) { untagged += 1 if r.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: port,
+ response_handlers: 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..249f43a9d
--- /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 end
+ def max_response_size; config.max_response_size end
+ end
+
+ def literal(str) "{#{str.bytesize}}\r\n#{str}" end
+
+ 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