diff --git a/lib/net/imap.rb b/lib/net/imap.rb index 2abf8405..b9f0a462 100644 --- a/lib/net/imap.rb +++ b/lib/net/imap.rb @@ -45,10 +45,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 @@ -126,6 +132,41 @@ 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 #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. + # # == Errors # # An \IMAP server can send three different types of responses to indicate @@ -187,7 +228,7 @@ module Net # - Net::IMAP.new: A new client connects immediately and waits for a # successful server greeting before returning the new client object. # - #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. # @@ -230,40 +271,39 @@ module Net # Capabilities may change after #starttls, #authenticate, or #login # and cached capabilities must be reloaded. # - #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. # # ==== \IMAP commands for the "Not Authenticated" state # - # In addition to the universal commands, the following commands are valid in - # the "not authenticated" state: + # In addition to the commands for any state, the following commands are valid + # 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 a {SASL - # mechanism}[https://www.iana.org/assignments/sasl-mechanisms/sasl-mechanisms.xhtml]. - # Enters the "_authenticated_" state. + # - #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. # # Requires the AUTH=#{mechanism} capability for the chosen # mechanism. # - #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. # # ==== \IMAP commands for the "Authenticated" state # - # In addition to the universal commands, the following commands are valid in - # the "_authenticated_" state: + # In addition to the commands for any state, the following commands are valid + # in the +authenticated+ state: # #-- # - #enable: Not implemented by Net::IMAP, yet. # # Requires the +ENABLE+ 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. @@ -289,12 +329,12 @@ module Net # # ==== \IMAP commands for the "Selected" state # - # In addition to the universal commands 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+ capability. @@ -384,7 +424,7 @@ module Net # ==== RFC3691: +UNSELECT+ # Folded into IMAP4rev2[https://tools.ietf.org/html/rfc9051], so it is also # listed 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+ @@ -699,7 +739,9 @@ module Net # * {Character sets}[https://www.iana.org/assignments/character-sets/character-sets.xhtml] # class IMAP < Protocol - VERSION = "0.3.8" + VERSION = "0.3.9" + + autoload :ResponseReader, File.expand_path("imap/response_reader", __dir__) include MonitorMixin if defined?(OpenSSL::SSL) @@ -734,6 +776,40 @@ class IMAP < Protocol # Seconds to wait until an IDLE response is received. attr_reader :idle_response_timeout + # The maximum allowed server response size. When +nil+, there is no limit + # on response size. + # + # The default value is _unlimited_ (after +v0.5.8+, the default is 512 MiB). + # 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. + # 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 a config + # attribute. + # + # * original: +nil+ (no limit) + # * +0.5+: 512 MiB + attr_accessor :max_response_size + attr_accessor :client_thread # :nodoc: # Returns the debug mode. @@ -1960,6 +2036,11 @@ def idle_done # 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 @response_handlers.push(block || handler) @@ -1995,6 +2076,13 @@ def remove_response_handler(handler) # OpenSSL::SSL::SSLContext#set_params as parameters. # open_timeout:: Seconds to wait until a connection is opened # idle_response_timeout:: Seconds to wait until an IDLE response is received + # 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. + # max_response_size:: See #max_response_size. # # The most common errors are: # @@ -2025,8 +2113,10 @@ def initialize(host, port_or_options = {}, @tagno = 0 @open_timeout = options[:open_timeout] || 30 @idle_response_timeout = options[:idle_response_timeout] || 5 + @max_response_size = options[:max_response_size] @parser = ResponseParser.new @sock = tcp_socket(@host, @port) + @reader = ResponseReader.new(self, @sock) begin if options[:ssl] start_tls_session(options[:ssl]) @@ -2037,6 +2127,7 @@ def initialize(host, port_or_options = {}, @responses = Hash.new([].freeze) @tagged_responses = {} @response_handlers = [] + options[:response_handlers]&.each do |h| add_response_handler(h) end @tagged_response_arrival = new_cond @continued_command_tag = nil @continuation_request_arrival = new_cond @@ -2053,6 +2144,7 @@ def initialize(host, port_or_options = {}, if @greeting.name == "BYE" raise ByeResponseError, @greeting end + @response_handlers.each do |handler| handler.call(@greeting) end @client_thread = Thread.current @receiver_thread = Thread.start { @@ -2176,25 +2268,14 @@ 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 @@debug - $stderr.print(buff.gsub(/^/n, "S: ")) - end - return @parser.parse(buff) + $stderr.print(buff.gsub(/^/n, "S: ")) if @@debug + @parser.parse(buff) end + ############################# + def record_response(name, data) unless @responses.has_key?(name) @responses[name] = [] @@ -2372,6 +2453,7 @@ def start_tls_session(params = {}) context.verify_callback = VerifyCallbackProc end @sock = SSLSocket.new(@sock, context) + @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/errors.rb b/lib/net/imap/errors.rb index b353756f..52cb936b 100644 --- a/lib/net/imap/errors.rb +++ b/lib/net/imap/errors.rb @@ -11,6 +11,40 @@ class Error < StandardError class DataFormatError < Error end + # Error raised when the socket cannot be read, due to a configured 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(" ") + return super(msg, *args) if kwargs.empty? # ruby 2.6 compatibility + 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-parseable. class ResponseParseError < Error end diff --git a/lib/net/imap/response_parser.rb b/lib/net/imap/response_parser.rb index 0341356c..5317bfc9 100644 --- a/lib/net/imap/response_parser.rb +++ b/lib/net/imap/response_parser.rb @@ -1382,7 +1382,7 @@ def uid_set when T_NUMBER then [Integer(token.value)] when T_ATOM entries = uid_set__ranges(token.value) - if (count = entries.sum(&:count)) > MAX_UID_SET_SIZE + if (count = entries.sum(&:size)) > MAX_UID_SET_SIZE parse_error("uid-set is too large: %d > 10k", count) end entries.flat_map(&:to_a) diff --git a/lib/net/imap/response_reader.rb b/lib/net/imap/response_reader.rb new file mode 100644 index 00000000..fd7561fa --- /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/test/net/imap/test_errors.rb b/test/net/imap/test_errors.rb new file mode 100644 index 00000000..a6a7cb0f --- /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 00000000..7ec554c3 --- /dev/null +++ b/test/net/imap/test_imap_max_response_size.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require "net/imap" +require "test/unit" + +class IMAPMaxResponseSizeTest < Test::Unit::TestCase + + def setup + @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 + _, port = with_server_socket do |sock| + sock.gets # => NOOP + sock.print("RUBY0001 OK done\r\n") + sock.gets # => NOOP + sock.print("* 1 FETCH (BODY[] {12345}\r\n" + "a" * 12_345 + ")\r\n") + sock.print("RUBY0002 OK done\r\n") + "RUBY0003" + end + Timeout.timeout(5) do + imap = Net::IMAP.new("localhost", port: port, max_response_size: 640 << 20) + assert_equal 640 << 20, imap.max_response_size + imap.max_response_size = 12_345 + 30 + assert_equal 12_345 + 30, imap.max_response_size + imap.noop # to reset the get_response limit + imap.noop # to send the FETCH + assert_equal "a" * 12_345, imap.responses["FETCH"].first.attr["BODY[]"] + ensure + imap.logout rescue nil + imap.disconnect rescue nil + end + end + + test "#max_response_size closes connection for too long line" do + _, port = with_server_socket do |sock| + sock.gets or next # => never called + fail "client disconnects first" + end + assert_raise_with_message( + Net::IMAP::ResponseTooLargeError, /exceeds max_response_size .*\b10B\b/ + ) do + Net::IMAP.new("localhost", port: port, max_response_size: 10) + fail "should not get here (greeting longer than max_response_size)" + end + end + + test "#max_response_size closes connection for too long literal" do + _, port = with_server_socket(ignore_io_error: true) do |sock| + sock.gets # => NOOP + sock.print "* 1 FETCH (BODY[] {1000}\r\n" + "a" * 1000 + ")\r\n" + sock.print("RUBY0001 OK done\r\n") + end + client = Net::IMAP.new("localhost", port: port, max_response_size: 1000) + assert_equal 1000, client.max_response_size + client.max_response_size = 50 + assert_equal 50, client.max_response_size + 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 + + def with_server_socket(ignore_io_error: false) + server = create_tcp_server + port = server.addr[1] + start_server do + Timeout.timeout(5) do + sock = server.accept + sock.print("* OK connection established\r\n") + logout_tag = yield sock if block_given? + sock.gets # => LOGOUT + sock.print("* BYE terminating connection\r\n") + sock.print("#{logout_tag} OK LOGOUT completed\r\n") if logout_tag + rescue IOError, EOFError, Errno::ECONNABORTED, Errno::ECONNRESET, + Errno::EPIPE, Errno::ETIMEDOUT + ignore_io_error or raise + ensure + sock.close rescue nil + server.close rescue nil + end + end + return server, port + end + + def start_server + th = Thread.new do + yield + end + @threads << th + sleep 0.1 until th.stop? + end + + def create_tcp_server + return TCPServer.new(server_addr, 0) + end + + def server_addr + Addrinfo.tcp("localhost", 0).ip_address + 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 00000000..3786f242 --- /dev/null +++ b/test/net/imap/test_imap_response_handlers.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require "net/imap" +require "test/unit" + +class IMAPResponseHandlersTest < Test::Unit::TestCase + + def setup + @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 + server = create_tcp_server + port = server.addr[1] + start_server do + sock = server.accept + Timeout.timeout(5) do + sock.print("* OK connection established\r\n") + sock.gets # => NOOP + sock.print("* 1 EXPUNGE\r\n") + sock.print("* 2 EXPUNGE\r\n") + sock.print("* 3 EXPUNGE\r\n") + sock.print("RUBY0001 OK NOOP completed\r\n") + sock.gets # => LOGOUT + sock.print("* BYE terminating connection\r\n") + sock.print("RUBY0002 OK LOGOUT completed\r\n") + ensure + sock.close + server.close + end + end + begin + responses = [] + imap = Net::IMAP.new(server_addr, port: port) + assert_equal 0, imap.response_handlers.length + imap.add_response_handler do |r| responses << [:block, r] end + assert_equal 1, imap.response_handlers.length + imap.add_response_handler(->(r) { responses << [:proc, r] }) + 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 + ensure + imap&.logout + imap&.disconnect + end + end + + test "::new with response_handlers kwarg" do + greeting = nil + expunges = [] + alerts = [] + untagged = 0 + handler0 = ->(r) { greeting ||= r } + 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] + + server = create_tcp_server + port = server.addr[1] + start_server do + sock = server.accept + Timeout.timeout(5) do + sock.print("* OK connection established\r\n") + sock.gets # => NOOP + sock.print("* 1 EXPUNGE\r\n") + sock.print("* 1 EXPUNGE\r\n") + sock.print("* OK [ALERT] The first alert.\r\n") + sock.print("RUBY0001 OK [ALERT] Did you see the alert?\r\n") + sock.gets # => LOGOUT + sock.print("* BYE terminating connection\r\n") + sock.print("RUBY0002 OK LOGOUT completed\r\n") + ensure + sock.close + server.close + end + end + begin + 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 + + 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 + imap&.disconnect + end + end + + def start_server + th = Thread.new do + yield + end + @threads << th + sleep 0.1 until th.stop? + end + + def create_tcp_server + return TCPServer.new(server_addr, 0) + end + + def server_addr + Addrinfo.tcp("localhost", 0).ip_address + end +end diff --git a/test/net/imap/test_imap_response_parser.rb b/test/net/imap/test_imap_response_parser.rb index 66df868c..f1c48d8d 100644 --- a/test/net/imap/test_imap_response_parser.rb +++ b/test/net/imap/test_imap_response_parser.rb @@ -445,6 +445,13 @@ def test_uidplus_copyuid__too_large "A004 OK [copyUID 1 10000:20000,1 1:10001] Done\r\n" ) end + Timeout.timeout(1) do + assert_raise Net::IMAP::ResponseParseError, /uid-set is too large/ do + parser.parse( + "A004 OK [copyUID 1 1:#{2**32 - 1} 1:#{2**32 - 1}] Done\r\n" + ) + end + 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 00000000..d2c1c11a --- /dev/null +++ b/test/net/imap/test_response_reader.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require "net/imap" +require "stringio" +require "test/unit" + +class ResponseReaderTest < Test::Unit::TestCase + class FakeClient + attr_accessor :max_response_size + 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.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