diff --git a/lib/net/imap.rb b/lib/net/imap.rb index 16b91c36c..4abe44813 100644 --- a/lib/net/imap.rb +++ b/lib/net/imap.rb @@ -779,7 +779,7 @@ module Net # * {IMAP URLAUTH Authorization Mechanism Registry}[https://www.iana.org/assignments/urlauth-authorization-mechanism-registry/urlauth-authorization-mechanism-registry.xhtml] # class IMAP < Protocol - VERSION = "0.4.24" + VERSION = "0.4.25" # Aliases for supported capabilities, to be used with the #enable command. ENABLE_ALIASES = { @@ -1052,6 +1052,7 @@ def client_thread # :nodoc: # Related: #logout, #logout! def disconnect return if disconnected? + in_receiver_thread = Thread.current == @receiver_thread begin begin # try to call SSL::SSLSocket#io. @@ -1063,9 +1064,9 @@ def disconnect rescue Errno::ENOTCONN # ignore `Errno::ENOTCONN: Socket is not connected' on some platforms. rescue Exception => e - @receiver_thread.raise(e) + @receiver_thread.raise(e) unless in_receiver_thread end - @receiver_thread.join + @receiver_thread.join unless mon_owned? || in_receiver_thread synchronize do @sock.close end @@ -2560,10 +2561,11 @@ def enable(*capabilities) capabilities = capabilities .flatten .map {|e| ENABLE_ALIASES[e] || e } + .flat_map { _1.is_a?(String) && !_1.empty? ? _1.split(/ /, -1) : [_1] } .uniq - .join(' ') + .map { Atom[_1] } synchronize do - send_command("ENABLE #{capabilities}") + send_command("ENABLE", *capabilities) result = clear_responses("ENABLED").last || [] @utf8_strings ||= result.include? "UTF8=ACCEPT" @utf8_strings ||= result.include? "IMAP4REV2" diff --git a/lib/net/imap/command_data.rb b/lib/net/imap/command_data.rb index 0ace387b1..775222240 100644 --- a/lib/net/imap/command_data.rb +++ b/lib/net/imap/command_data.rb @@ -14,14 +14,15 @@ def validate_data(data) when nil when String when Integer - NumValidator.ensure_number(data) + # Covers modseq-valzer, which is the largest valid IMAP integer + if data.negative? + raise DataFormatError, "Integer argument must be unsigned: #{data}" + elsif 0xffff_ffff_ffff_ffff < data + raise DataFormatError, "Integer argument must fit in 64 bits: #{data}" + end when Array - if data[0] == 'CHANGEDSINCE' - NumValidator.ensure_mod_sequence_value(data[1]) - else - data.each do |i| - validate_data(i) - end + data.each do |i| + validate_data(i) end when Time, Date, DateTime when Symbol @@ -82,15 +83,23 @@ def send_binary_literal(*a, **kw) send_literal(*a, **kw, binary: true) end # `non_sync` is an optional tri-state flag: # * `true` -> Force non-synchronizing `LITERAL+`/`LITERAL-` behavior. - # TODO: raise or warn when capabilities don't allow non_sync. + # NOTE: raises DataFormatError when server doesn't support + # non-synchronizing literal, or literal is too large for LITERAL-. # * `false` -> Force normal synchronizing literal behavior. # * `nil` -> (default) Currently behaves like `false` (will be dynamic). # TODO: Dynamic, based on capabilities and bytesize. def send_literal(str, tag = nil, binary: false, non_sync: nil) + bytesize = str.bytesize synchronize do + if non_sync && !non_sync_literal_allowed?(bytesize) + # TODO: check in Printer, so we don't need to close the connection. + @sock.close + raise DataFormatError, "Connection closed: " \ + "Cannot send non-synchronizing literal without known server support" + end prefix = "~" if binary plus = "+" if non_sync - put_string("#{prefix}{#{str.bytesize}#{plus}}\r\n") + put_string("#{prefix}{#{bytesize}#{plus}}\r\n") if non_sync put_string(str) return @@ -109,8 +118,18 @@ def send_literal(str, tag = nil, binary: false, non_sync: nil) end end + def non_sync_literal_allowed?(bytesize) + return unless capabilities_cached? + return "+" if capable?("LITERAL+") + return "-" if capable_literal_minus? && bytesize <= 4096 + false + end + + def capable_literal_minus?; capable?("LITERAL-") || capable?("IMAP4rev2") end + + # NOTE: +num+ should already be an Integer def send_number_data(num) - put_string(num.to_s) + put_string(Integer(num).to_s) end def send_list_data(list, tag = nil) @@ -165,36 +184,38 @@ def validate end end - # Represents IMAP +text+ data, which may contain any 7-bit ASCII character, - # except for +NULL+, +CR+, or +LF+. +text+ is extended to allow any - # multibyte +UTF-8+ character when either +UTF8=ACCEPT+ or +IMAP4rev2+ have - # been enabled, or when the server supports only +IMAP4rev2+ and not earlier - # IMAP revisions, or when the server advertises +UTF8=ONLY+. + # Represents IMAP +text+ or +quoted+ data, which share the same + # validations of decoded #data, and differ only in how they are formatted. + # + # +data+ may contain any 7-bit ASCII character except +NULL+, +CR+, or +LF+. + # Any multibyte +UTF-8+ character is also allowed when the connection + # supports UTF8: either +UTF8=ACCEPT+ or +IMAP4rev2+ have been enabled, or + # the server supports only +IMAP4rev2+ and not earlier IMAP revisions, or + # the server advertises +UTF8=ONLY+. # - # NOTE: The current implementation does not validate whether the connection - # currently supports UTF-8. Future versions may change. + # NOTE: This does not verify whether the connection supports UTF-8, but that + # may change in future versions. # # The string's bytes must be valid ASCII or valid UTF-8. The string's # reported encoding is ignored, but the string is _not_ transcoded. - class RawText < CommandData # :nodoc: + class ValidNonLiteralData < CommandData def initialize(data:) data = String(data.to_str) - data = if [Encoding::ASCII, Encoding::UTF_8].include?(data.encoding) - -data - elsif data.ascii_only? - -(data.dup.force_encoding("ASCII")) - else - -(data.dup.force_encoding("UTF-8")) + unless [Encoding::ASCII, Encoding::UTF_8].include?(data.encoding) + data = data.dup.force_encoding(data.ascii_only? ? "ASCII" : "UTF-8") end + data = -data super validate end def validate - if data.include?("\0") - raise DataFormatError, "NULL byte must be binary literal encoded" + if ![Encoding::ASCII, Encoding::UTF_8].include?(data.encoding) + raise DataFormatError, "must use ASCII or UTF-8 encoding" elsif !data.valid_encoding? raise DataFormatError, "invalid UTF-8 must be literal encoded" + elsif data.include?("\0") + raise DataFormatError, "NULL byte must be binary literal encoded" elsif /[\r\n]/.match?(data) raise DataFormatError, "CR and LF bytes must be literal encoded" end @@ -202,12 +223,31 @@ def validate def ascii_only?; data.ascii_only? end - def send_data(imap, tag) imap.__send__(:put_string, data) end + def send_data(imap, tag = nil) imap.__send__(:put_string, formatted) end + end + + # Represents IMAP +text+ data, which covers everything in the IMAP grammar, + # except for +literal+, +literal8+, and the concluding +CRLF+. + # + # NOTE: The current implementation does not verify that the connection + # supports UTF-8. Future versions may validate this. + class RawText < ValidNonLiteralData # :nodoc: + # raw: no formatting necessary + alias formatted data end class RawData < CommandData # :nodoc: def initialize(data:) - data = split_parts(data) + case data + when String + data = self.class.split(data) + when Array + unless data.all? { |part| RawText === part || Literal === part } + raise TypeError, "expected String or Array[#{RawText} | #{Literal}]" + end + else + raise TypeError, "expected String or Array[#{RawText} | #{Literal}]" + end super validate end @@ -217,14 +257,16 @@ def send_data(imap, tag) data.each do _1.send_data(imap, tag) end end def validate return unless RawText === data.last text = data.last.data - if text.rindex(/~?\{[1-9]\d*\+?\}\z/n) + if text.rindex(/\{\d+\+?\}\z/n) raise DataFormatError, "RawData cannot end with literal continuation" end end - private - - def split_parts(data) + # Splits an input +string+ into an array of RawText and Literal/Literal8. + # + # NOTE: unlike RawData#validate, this does not prevent the final RawText + # from ending with a literal prefix. + def self.split(data) data = data.b # dups and ensures BINARY encoding parts = [] while data.match(/(~)?\{(0|[1-9]\d*)(\+)?\}\r\n/n) @@ -241,7 +283,7 @@ def split_parts(data) parts end - def extract_literal(data, binary:, bytesize:, non_sync:) + def self.extract_literal(data, binary:, bytesize:, non_sync:) if data.bytesize < bytesize raise DataFormatError, "Too few bytes in string for literal, " \ "expected: %s, remaining: %s" % [bytesize, data.bytesize] @@ -249,6 +291,7 @@ def extract_literal(data, binary:, bytesize:, non_sync:) literal = data.byteslice(0, bytesize) (binary ? Literal8 : Literal).new(data: literal, non_sync: non_sync) end + private_class_method :extract_literal end class Atom < CommandData # :nodoc: @@ -262,6 +305,8 @@ def validate or raise DataFormatError, "#{self.class} must be ASCII only" data.match?(ResponseParser::Patterns::ATOM_SPECIALS) \ and raise DataFormatError, "#{self.class} must not contain atom-specials" + data.empty? \ + and raise DataFormatError, "#{self.class} must not be empty" end def send_data(imap, tag) @@ -275,19 +320,13 @@ def send_data(imap, tag) end end - class QuotedString # :nodoc: - def send_data(imap, tag) - imap.__send__(:send_quoted_string, @data) - end - - def validate - end - - private - - def initialize(data) - @data = data - end + # Represents a IMAP +quoted+ string, which can encode any valid ASCII or + # UTF-8 string, unless it contains any +CR+, +LF+, or +NULL+ bytes. + # + # NOTE: The current implementation does not verify that the connection + # supports UTF-8. Future versions may validate this. + class QuotedString < ValidNonLiteralData # :nodoc: + def formatted; %("#{data.gsub(/["\\]/, "\\\\\\&")}") end end class Literal # :nodoc: diff --git a/lib/net/imap/response_parser.rb b/lib/net/imap/response_parser.rb index 51ba119de..df38e2e47 100644 --- a/lib/net/imap/response_parser.rb +++ b/lib/net/imap/response_parser.rb @@ -2055,10 +2055,7 @@ def next_token if $1 return Token.new(T_SPACE, $+) elsif $2 - len = $+.to_i - val = @str[@pos, len] - @pos += len - return Token.new(T_LITERAL8, val) + literal_token($+, T_LITERAL8) elsif $3 && $7 # greedily match ATOM, prefixed with NUMBER, NIL, or PLUS. return Token.new(T_ATOM, $3) @@ -2086,10 +2083,7 @@ def next_token elsif $15 return Token.new(T_RBRA, $+) elsif $16 - len = $+.to_i - val = @str[@pos, len] - @pos += len - return Token.new(T_LITERAL, val) + literal_token($+) elsif $17 return Token.new(T_PERCENT, $+) elsif $18 @@ -2115,10 +2109,7 @@ def next_token elsif $4 return Token.new(T_QUOTED, Patterns.unescape_quoted($+)) elsif $5 - len = $+.to_i - val = @str[@pos, len] - @pos += len - return Token.new(T_LITERAL, val) + literal_token($+) elsif $6 return Token.new(T_LPAR, $+) elsif $7 @@ -2133,6 +2124,23 @@ def next_token else parse_error("invalid @lex_state - %s", @lex_state.inspect) end + rescue DataFormatError => error + parse_error error.message + end + + def literal_token(len, type = T_LITERAL) + len = coerce_number64 len.to_i + val = @str[@pos, len] + @pos += len + Token.new(type, val) + end + + # copied/adapted from NumValidator in v0.6 + def coerce_number64(num) + int = num.to_i + return int if 0 <= int && int <= 0x7fff_ffff_ffff_ffff + raise DataFormatError, + "number64 must be unsigned 63-bit integer: #{num}" end end diff --git a/lib/net/imap/response_reader.rb b/lib/net/imap/response_reader.rb index 56958ebad..ce22abe17 100644 --- a/lib/net/imap/response_reader.rb +++ b/lib/net/imap/response_reader.rb @@ -4,6 +4,8 @@ module Net class IMAP # See https://www.rfc-editor.org/rfc/rfc9051#section-2.2.2 class ResponseReader # :nodoc: + include NumValidator + attr_reader :client def initialize(client, sock) @@ -35,7 +37,10 @@ def done?; line_done? && !literal_size end def line_done?; buff.end_with?(CRLF) end def get_literal_size(buff) - buff.end_with?("}\r\n") && buff.rindex(/\{(\d+)\}\r\n\z/n) && $1.to_i + buff.end_with?("}\r\n") && buff.rindex(/\{(\d+)\}\r\n\z/n) && + coerce_number64($1) + rescue DataFormatError + raise DataFormatError, format("invalid response literal size (%s)", $1) end def read_line @@ -76,6 +81,14 @@ def max_response_remaining! ) end + # copied/adapted from NumValidator in v0.6 + def coerce_number64(num) + int = num.to_i + return int if 0 <= int && int <= 0x7fff_ffff_ffff_ffff + raise DataFormatError, + "number64 must be unsigned 63-bit integer: #{num}" + end + end end end diff --git a/test/net/imap/fake_server/command_reader.rb b/test/net/imap/fake_server/command_reader.rb index 03d1da50d..8857cd9b8 100644 --- a/test/net/imap/fake_server/command_reader.rb +++ b/test/net/imap/fake_server/command_reader.rb @@ -3,13 +3,15 @@ require "net/imap" class Net::IMAP::FakeServer - CommandParseError = RuntimeError + CommandParseError = Class.new(RuntimeError) class CommandReader + attr_reader :config attr_reader :last_command attr_accessor :literal_acceptor - def initialize(socket) + def initialize(socket, config:) + @config = config @socket = socket @last_command = nil @literal_acceptor = proc {|buff, size| true } @@ -35,8 +37,11 @@ def get_command 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") + rescue CommandParseError + if config.ignore_abrupt_eof? && socket.eof? && !buf.end_with?("\r\n") + throw :eof + end + raise end private @@ -46,7 +51,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 CommandParseError, "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/configuration.rb b/test/net/imap/fake_server/configuration.rb index 91fe72a42..fde14aa78 100644 --- a/test/net/imap/fake_server/configuration.rb +++ b/test/net/imap/fake_server/configuration.rb @@ -45,6 +45,8 @@ class Configuration mailboxes: { "INBOX" => { name: "INBOX" }.freeze, }.freeze, + + ignore_abrupt_eof: false, } def initialize(with_extensions: [], without_extensions: [], **opts, &block) @@ -68,6 +70,7 @@ def initialize(with_extensions: [], without_extensions: [], **opts, &block) alias greeting_bye? greeting_bye alias greeting_capabilities? greeting_capabilities alias sasl_ir? sasl_ir + alias ignore_abrupt_eof? ignore_abrupt_eof def on(event, &handler) handler or raise ArgumentError diff --git a/test/net/imap/fake_server/connection.rb b/test/net/imap/fake_server/connection.rb index 4317364de..df428828d 100644 --- a/test/net/imap/fake_server/connection.rb +++ b/test/net/imap/fake_server/connection.rb @@ -12,9 +12,10 @@ def initialize(server, tcp_socket:) @config = server.config @socket = Socket.new tcp_socket, config: config @state = ConnectionState.new socket: socket, config: config - @reader = CommandReader.new socket + @reader = CommandReader.new socket, config: config @writer = ResponseWriter.new socket, config: config, state: state @router = CommandRouter.new writer, config: config, state: state + @mutex = Thread::Mutex.new end def commands; state.commands end @@ -34,11 +35,13 @@ def run end def close - unless state.logout? - state.logout - writer.bye + @mutex.synchronize do + unless state.logout? + state.logout + writer.bye + end + socket&.close unless socket&.closed? end - socket&.close unless socket&.closed? end private diff --git a/test/net/imap/fake_server/socket.rb b/test/net/imap/fake_server/socket.rb index 65593d86f..84e94d589 100644 --- a/test/net/imap/fake_server/socket.rb +++ b/test/net/imap/fake_server/socket.rb @@ -18,10 +18,10 @@ 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 + def eof?; ignore_closed?(true) { socket.eof? } end + def gets(...) ignore_closed?(nil) { socket.gets(...) } end + def read(...) ignore_closed?(nil) { socket.read(...) } end + def print(...) ignore_closed?(nil) { socket.print(...) } end def use_tls @tls_socket ||= OpenSSL::SSL::SSLSocket.new(tcp_socket, ssl_ctx).tap do |s| @@ -48,5 +48,13 @@ def ssl_ctx end end + def ignore_closed?(fallback) + yield + rescue IOError => err + close if !closed? && (@tcp_socket.closed? || @tls_socket.closed?) + return fallback if err.message.match?(/stream closed|closed stream/i) + raise + end + end end diff --git a/test/net/imap/fixtures/response_parser/quirky_behaviors.yml b/test/net/imap/fixtures/response_parser/quirky_behaviors.yml index e6bf9d482..a0145b602 100644 --- a/test/net/imap/fixtures/response_parser/quirky_behaviors.yml +++ b/test/net/imap/fixtures/response_parser/quirky_behaviors.yml @@ -7,6 +7,22 @@ data: raw_data: "* NOOP\r\n" + "literal numeric formatted with zero-prefix": + :response: "* 20367 FETCH (BODY[HEADER.FIELDS (Foo)] {012}\r\nFoo: bar\r\n\r\n)\r\n" + :expected: !ruby/struct:Net::IMAP::UntaggedResponse + name: FETCH + data: !ruby/struct:Net::IMAP::FetchData + seqno: 20367 + attr: + BODY[HEADER.FIELDS (Foo)]: "Foo: bar\r\n\r\n" + raw_data: "* 20367 FETCH (BODY[HEADER.FIELDS (Foo)] {012}\r\nFoo: bar\r\n\r\n)\r\n" + + "invalid literal numeric format (too large)": + :test_type: :assert_parse_failure + :message: "number64 must be unsigned 63-bit integer: 99999999999999999999" + :response: + "* 20367 FETCH (BODY[] {99999999999999999999}\r\nwon't parse this)\r\n" + test_invalid_noop_response_with_unparseable_data: :response: "* NOOP froopy snood\r\n" :expected: !ruby/struct:Net::IMAP::IgnoredResponse diff --git a/test/net/imap/test_command_data.rb b/test/net/imap/test_command_data.rb index f476afdc2..bb460cd86 100644 --- a/test/net/imap/test_command_data.rb +++ b/test/net/imap/test_command_data.rb @@ -8,6 +8,7 @@ class CommandDataTest < Test::Unit::TestCase Atom = Net::IMAP::Atom Flag = Net::IMAP::Flag + QuotedString = Net::IMAP::QuotedString Literal = Net::IMAP::Literal Literal8 = Net::IMAP::Literal8 RawText = Net::IMAP::RawText @@ -109,6 +110,7 @@ def send_data(*data, tag: TAG) "with_quoted_specials\\", "with\rCR", "with\nLF", + "", # empty ].each do |symbol| assert_raise_with_message(Net::IMAP::DataFormatError, /\batom\b/i) do imap.send_data Atom[symbol] @@ -140,6 +142,7 @@ def send_data(*data, tag: TAG) :"with_quoted_specials\\", :"with\rCR", :"with\nLF", + :"", # empty ].each do |symbol| assert_raise_with_message(Net::IMAP::DataFormatError, /\bflag\b/i) do imap.send_data Flag[symbol] @@ -177,20 +180,131 @@ def send_data(*data, tag: TAG) ], imap.output end + class QuotedStringTest < CommandDataTest + test "quotes ASCII strings (no specials)" do + assert_equal '"INBOX"', QuotedString["INBOX"].formatted + imap.send_data( + QuotedString["INBOX"], + QuotedString["etc"] + ) + assert_equal [ + Output.put_string('"INBOX"'), + Output.put_string('"etc"'), + ], imap.output + imap.clear + end + + test "quotes ASCII strings (atom specials)" do + [ + " with spaces in string ", + "with_parens()", + "with_list_wildcards*", + "with_list_wildcards%", + "with_resp_special]", + "with\x7fcontrol_char", + %{(){}[]%*'}, + ].each do |string| + imap.send_data QuotedString[string] + end + assert_equal [ + Output.put_string('" with spaces in string "'), + Output.put_string('"with_parens()"'), + Output.put_string('"with_list_wildcards*"'), + Output.put_string('"with_list_wildcards%"'), + Output.put_string('"with_resp_special]"'), + Output.put_string(%{"with\x7fcontrol_char"}), + Output.put_string(%Q{"(){}[]%*'"}), + ], imap.output + end + + test "escapes quoted specials" do + [ + '"with" "quoted" "specials"', + "\\with\\quoted\\specials\\", + %{(){}[]%*"'\\}, + ].each do |string| + imap.send_data QuotedString[string] + end + assert_equal [ + Output.put_string('"\"with\" \"quoted\" \"specials\""'), + Output.put_string('"\\\\with\\\\quoted\\\\specials\\\\"'), + Output.put_string(%q{"(){}[]%*\"'\\\\"}), + ], imap.output + end + + test "ASCII compatible string with another encodings" do + imap.send_data QuotedString.new("foo bar".encode("cp1252")) + assert_equal [ + Output.put_string('"foo bar"'), + ], imap.output + end + + test "allows ASCII control chars" do + text = QuotedString.new("beep\b beep\b escape!\e delete this:\x1f") + imap.send_data text + assert_equal [ + Output.put_string(%{"beep\b beep\b escape!\e delete this:\x1f"}), + ], imap.output + end + + test "quotes valid UTF-8 multibyte chars" do + imap.send_data QuotedString.new("föó bär") + imap.send_data QuotedString.new("ほげ ふが ぴよ") + assert_equal [ + Output.put_string('"föó bär"'), + Output.put_string('"ほげ ふが ぴよ"'), + ], imap.output + end + end + class RawTextTest < CommandDataTest - test "basic ASCII string" do - imap.send_data RawText.new('foo "bar" (baz)') - assert_equal [Output.put_string('foo "bar" (baz)')], imap.output + test "allows ASCII strings with no specials" do + imap.send_data( + RawText["INBOX"], + RawText["etc"] + ) + assert_equal [ + Output.put_string("INBOX"), + Output.put_string("etc"), + ], imap.output + imap.clear + end + + test "allows atom specials" do + [ + " with spaces in string ", + "with_parens()", + "with_list_wildcards*", + "with_list_wildcards%", + "with_resp_special]", + "with\x7fcontrol_char", + %{(){}[]%*'}, + ].each do |string| + imap.send_data RawText[string] + end + assert_equal [ + Output.put_string(" with spaces in string "), + Output.put_string("with_parens()"), + Output.put_string("with_list_wildcards*"), + Output.put_string("with_list_wildcards%"), + Output.put_string("with_resp_special]"), + Output.put_string("with\x7fcontrol_char"), + Output.put_string(%{(){}[]%*'}), + ], imap.output end - test "allows IMAP atom-special symbols" do - imap.send_data RawText.new('foo "bar" (baz)') - imap.send_data RawText.new("(){}[]%*\"\\") - imap.send_data RawText.new("(((((((((((((((( unbalanced ]]]]]]]]]]]]]") + test "allows quoted specials" do + [ + '"with" "quoted" "specials"', + '\\with\\quoted\\specials\\', + %{(){}[]%*"'\\}, + ].each do |string| + imap.send_data RawText[string] + end assert_equal [ - Output.put_string('foo "bar" (baz)'), - Output.put_string("(){}[]%*\"\\"), - Output.put_string("(((((((((((((((( unbalanced ]]]]]]]]]]]]]"), + Output.put_string('"with" "quoted" "specials"'), + Output.put_string('\\with\\quoted\\specials\\'), + Output.put_string(%{(){}[]%*"'\\}), ], imap.output end @@ -209,13 +323,28 @@ class RawTextTest < CommandDataTest ], imap.output end + test "allows valid UTF-8 multibyte chars" do + imap.send_data RawText.new("föó bär") + imap.send_data RawText.new("ほげ ふが ぴよ") + assert_equal [ + Output.put_string("föó bär"), + Output.put_string("ほげ ふが ぴよ"), + ], imap.output + end + end + + SharedValidNonLiteralDataTests = ->(data_type) do data( "NULL" => ["with \0 NULL", /NULL\b.+\bbyte/i], "CR" => ["with \r CR", /CR\b.+\bbyte/i], "LF" => ["with \n LF", /LF\b.+\bbyte/i], ) test "invalid ASCII byte" do |(text, error_message)| - try_multiple_encodings(error_message, text) + with_multiple_encodings(text) do |encoded| + assert_raise_with_message(DataFormatError, error_message) do + data_type[encoded] + end + end end # See Table 3-7, Well-Formed UTF-8 Byte Sequences, in The Unicode Standard: @@ -232,7 +361,11 @@ class RawTextTest < CommandDataTest "windows-1252" => "åêïõü".encode("windows-1252"), ) test "invalid UTF-8" do |text| - try_multiple_encodings(/invalid UTF-8/i, text) + with_multiple_encodings(text) do |encoded| + assert_raise_with_message(DataFormatError, /invalid UTF-8/i) do + data_type[encoded] + end + end end def with_multiple_encodings(data) @@ -241,15 +374,9 @@ def with_multiple_encodings(data) yield data.dup.force_encoding("UTF-8") yield data.dup.force_encoding("cp1252") end - - def try_multiple_encodings(error_message, data) - with_multiple_encodings(data) do |encoded| - assert_raise_with_message(DataFormatError, error_message) do - RawText[encoded] - end - end - end end + QuotedStringTest.class_exec QuotedString, &SharedValidNonLiteralDataTests + RawTextTest .class_exec RawText, &SharedValidNonLiteralDataTests class RawDataTest < CommandDataTest test "simple raw text" do @@ -377,6 +504,30 @@ class RawDataTest < CommandDataTest assert_raise(DataFormatError) do RawData.new(data: "~literal+ ~{123+}") end raw = RawData.new(data: " {123} ") assert_equal [RawText[" {123} "]], raw.data + + assert_raise(DataFormatError) do RawData.new(data: "literal {0}") end + assert_raise(DataFormatError) do RawData.new(data: "literal+ {0+}") end + assert_raise(DataFormatError) do RawData.new(data: "~literal ~{0}") end + assert_raise(DataFormatError) do RawData.new(data: "~literal+ ~{0+}") end + raw = RawData.new(data: " {0} ") + assert_equal [RawText[" {0} "]], raw.data + end + + data( + "simple raw text" => 'hello "world"', + "text, literal, text" => "OK {5}\r\nhello {5}\r\nworld", + "empty literals" => "{0}\r\n{0+}\r\n~{0}\r\n~{0+}\r\n", + "binary and regular" => "foo ~{7}\r\n\0bar\r\nbaz {4}\r\nquux", + ) + test ".split" do |string| + assert_equal(RawData[string].data, RawData.split(string)) + end + + test ".split allows final literal prefix" do + assert_equal [RawText["text {123}"]], RawData.split("text {123}") + assert_equal [RawText["text+ {123+}"]], RawData.split("text+ {123+}") + assert_equal [RawText["~text ~{123}"]], RawData.split("~text ~{123}") + assert_equal [RawText["~text+ ~{123+}"]], RawData.split("~text+ ~{123+}") end end diff --git a/test/net/imap/test_imap.rb b/test/net/imap/test_imap.rb index 79e6deaf3..cbbe6babc 100644 --- a/test/net/imap/test_imap.rb +++ b/test/net/imap/test_imap.rb @@ -632,19 +632,23 @@ def test_send_invalid_number sock.print("RUBY0001 OK TEST completed\r\n") sock.gets # Integer: 2**32 - 1 sock.print("RUBY0002 OK TEST completed\r\n") - sock.gets # MessageSet: 1 + sock.gets # Integer: 2**32 sock.print("RUBY0003 OK TEST completed\r\n") - sock.gets # MessageSet: 2**32 - 1 + sock.gets # Integer: 2**64 - 1 sock.print("RUBY0004 OK TEST completed\r\n") - sock.gets # SequenceSet: -1 => "*" + sock.gets # MessageSet: 1 sock.print("RUBY0005 OK TEST completed\r\n") - sock.gets # SequenceSet: 1 + sock.gets # MessageSet: 2**32 - 1 sock.print("RUBY0006 OK TEST completed\r\n") - sock.gets # SequenceSet: 2**32 - 1 + sock.gets # SequenceSet: -1 => "*" sock.print("RUBY0007 OK TEST completed\r\n") + sock.gets # SequenceSet: 1 + sock.print("RUBY0008 OK TEST completed\r\n") + sock.gets # SequenceSet: 2**32 - 1 + sock.print("RUBY0009 OK TEST completed\r\n") sock.gets # LOGOUT sock.print("* BYE terminating connection\r\n") - sock.print("RUBY0008 OK LOGOUT completed\r\n") + sock.print("RUBY0010 OK LOGOUT completed\r\n") ensure sock.close server.close @@ -658,8 +662,10 @@ def test_send_invalid_number end imap.__send__(:send_command, "TEST", 0) imap.__send__(:send_command, "TEST", 2**32 - 1) + imap.__send__(:send_command, "TEST", 2**32) + imap.__send__(:send_command, "TEST", 2**64 - 1) assert_raise(Net::IMAP::DataFormatError) do - imap.__send__(:send_command, "TEST", 2**32) + imap.__send__(:send_command, "TEST", 2**64) end # MessageSet numbers may be non-zero uint32 assert_raise(Net::IMAP::DataFormatError) do @@ -747,7 +753,7 @@ def test_raw_data end test("send literal args") do - with_fake_server do |server, imap| + with_fake_server(with_extensions: %w[LITERAL-]) do |server, imap| server.on "TEST", &:done_ok send_args = ->(*args) do imap.__send__(:send_command, "TEST", *args) @@ -796,6 +802,56 @@ def test_raw_data end end + test("send non-synchronizing literals with LITERAL+") do + with_fake_server( + with_extensions: %w[LITERAL+], greeting_capabilities: true, + ) do |server, imap| + def imap.send_test_args(*args) send_command("TEST", *args) end + server.on "TEST", &:done_ok + + # imap.config.max_non_synchronizing_literal = 5_000 + # NOTE: support for automatic non-synchronizing literals added in v0.6 + large = "\xff".b * 5_000 + imap.send_test_args Net::IMAP::Literal[large, nil] + assert_equal("{5000}\r\n#{large}".b, server.commands.pop.args) + + large = "\xff".b * 10_000 + imap.send_test_args Net::IMAP::Literal[large, nil] + assert_equal("{10000}\r\n#{large}".b, server.commands.pop.args) + + imap.send_test_args Net::IMAP::Literal[large, true] + assert_equal("{10000+}\r\n#{large}".b, server.commands.pop.args) + end + end + + test("send non-synchronizing literal that's too large for LITERAL-") do + with_fake_server( + with_extensions: %w[LITERAL-], greeting_capabilities: true, + ignore_abrupt_eof: true, ignore_io_error: true + ) do |server, imap| + def imap.send_test_args(*args) send_command("TEST", *args) end + server.on "TEST", &:done_ok + assert_raise(Net::IMAP::DataFormatError) do + imap.send_test_args Net::IMAP::Literal["\xff".b * 5000, true] + end + assert imap.disconnected? + end + end + + test("send non-synchronizing literal without known server support") do + with_fake_server( + with_extensions: %w[LITERAL+], greeting_capabilities: false, + ignore_abrupt_eof: true, ignore_io_error: true + ) do |server, imap| + def imap.send_test_args(*args) send_command("TEST", *args) end + server.on "TEST", &:done_ok + assert_raise(Net::IMAP::DataFormatError) do + imap.send_test_args Net::IMAP::Literal["\xff".b * 100, true] + end + assert imap.disconnected? + end + end + def test_disconnect server = create_tcp_server port = server.addr[1] @@ -1210,6 +1266,14 @@ def test_enable assert_equal %w[CONDSTORE], result1 assert_equal %w[UTF8=ACCEPT], result2 assert_equal [], result3 + + assert_raise(Net::IMAP::DataFormatError) do + imap.enable "injection\r\ninjected logout" + end + assert_empty cmdq + assert_raise(Net::IMAP::DataFormatError) do + imap.enable "foo", "", "bar" + end end end diff --git a/test/net/imap/test_response_reader.rb b/test/net/imap/test_response_reader.rb index e3e21e7ab..e1be671f2 100644 --- a/test/net/imap/test_response_reader.rb +++ b/test/net/imap/test_response_reader.rb @@ -28,6 +28,8 @@ def literal(str) "{#{str.bytesize}}\r\n#{str}" end 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" + zero_padded = "+ {010}\r\n1234567890\r\n" # NOTE: it's decimal, not octal! + goofy_zero = "+ {000}\r\n\r\n" io = StringIO.new([ simple, long_line, @@ -36,6 +38,8 @@ def literal(str) "{#{str.bytesize}}\r\n#{str}" end zero_literal, illegal_crs, illegal_lfs, + zero_padded, + goofy_zero, simple, ].join) rcvr = Net::IMAP::ResponseReader.new(client, io) @@ -46,6 +50,8 @@ def literal(str) "{#{str.bytesize}}\r\n#{str}" end 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 zero_padded, rcvr.read_response_buffer.to_str + assert_equal goofy_zero, rcvr.read_response_buffer.to_str assert_equal simple, rcvr.read_response_buffer.to_str assert_equal "", rcvr.read_response_buffer.to_str end @@ -85,4 +91,19 @@ def literal(str) "{#{str.bytesize}}\r\n#{str}" end end end + data( + bad_int64: "+ {99999999999999999999}\r\ndon't even try to read this...", + ) + test "#read_response_buffer with invalid literal size" do |invalid| + client = FakeClient.new + client.config.max_response_size = nil # any size is allowed! + io = StringIO.new(invalid, "rb") + rcvr = Net::IMAP::ResponseReader.new(client, io) + assert_raise Net::IMAP::DataFormatError do + result = rcvr.read_response_buffer + flunk "Got result: %p" % [result] + end + # assert io.closed? + end + end