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