diff --git a/benchmarks/generate_parser_benchmarks b/benchmarks/generate_parser_benchmarks index 566237e50..7758402ae 100755 --- a/benchmarks/generate_parser_benchmarks +++ b/benchmarks/generate_parser_benchmarks @@ -28,7 +28,8 @@ init = <UTF8=ACCEPT, UTF8=ONLY + # + # - See #enable for information about support foi UTF-8 string encoding. # #-- # ==== RFC7888: LITERAL+, +LITERAL-+ @@ -679,6 +676,11 @@ module Net # Gulbrandsen, A. and N. Freed, Ed., "Internet Message Access Protocol # (\IMAP) - MOVE Extension", RFC 6851, DOI 10.17487/RFC6851, January 2013, # . + # [UTF8=ACCEPT[https://tools.ietf.org/html/rfc6855]]:: + # [UTF8=ONLY[https://tools.ietf.org/html/rfc6855]]:: + # Resnick, P., Ed., Newman, C., Ed., and S. Shen, Ed., + # "IMAP Support for UTF-8", RFC 6855, DOI 10.17487/RFC6855, March 2013, + # . # # === IANA registries # @@ -705,6 +707,12 @@ module Net class IMAP < Protocol VERSION = "0.3.4" + # Aliases for supported capabilities, to be used with the #enable command. + ENABLE_ALIASES = { + utf8: "UTF8=ACCEPT", + "UTF8=ONLY" => "UTF8=ACCEPT", + }.freeze + autoload :SASL, File.expand_path("imap/sasl", __dir__) autoload :StringPrep, File.expand_path("imap/stringprep", __dir__) @@ -812,12 +820,14 @@ def disconnected? # Capability requirements—other than +IMAP4rev1+—are listed in the # documentation for each command method. # + # Related: #enable + # # ===== Basic IMAP4rev1 capabilities # # All IMAP4rev1 servers must include +IMAP4rev1+ in their capabilities list. # All IMAP4rev1 servers must _implement_ the +STARTTLS+, # AUTH=PLAIN, and +LOGINDISABLED+ capabilities, and clients must - # respect their presence or absence. See the capabilites requirements on + # respect their presence or absence. See the capabilities requirements on # #starttls, #login, and #authenticate. # # ===== Using IMAP4rev1 extensions @@ -1886,26 +1896,84 @@ def uid_thread(algorithm, search_keys, charset) # Sends an {ENABLE command [RFC5161 §3.2]}[https://www.rfc-editor.org/rfc/rfc5161#section-3.1] # {[IMAP4rev2 §6.3.1]}[https://www.rfc-editor.org/rfc/rfc9051#section-6.3.1] - # to enable the specified extenstions, which may be either an - # array or a string. Returns a list of the extensions that were enabled. - # - # Some of the extensions that use ENABLE permit the server to send - # syntax that this class cannot parse. Caution is advised. + # to enable the specified server +capabilities+. Each capability may be an + # array, string, or symbol. Returns a list of the capabilities that were + # enabled. # # The +ENABLE+ command is only valid in the _authenticated_ state, before # any mailbox is selected. # + # Related: #capability + # # ===== Capabilities # - # The server's capabilities must include +ENABLE+ - # [RFC5161[https://tools.ietf.org/html/rfc5161]] or IMAP4REV2 - # [RFC9051[https://tools.ietf.org/html/rfc9051]]. + # The server's capabilities must include + # +ENABLE+ [RFC5161[https://tools.ietf.org/html/rfc5161]] + # or +IMAP4REV2+ [RFC9051[https://tools.ietf.org/html/rfc9051]]. # # Additionally, the server capabilities must include a capability matching # each enabled extension (usually the same name as the enabled extension). - def enable(extensions) + # The following capabilities may be enabled: + # + # [+:utf8+ --- an alias for "UTF8=ACCEPT"] + # + # In a future release, enable(:utf8) will enable either + # "UTF8=ACCEPT" or "IMAP4rev2", depending on server + # capabilities. + # + # ["UTF8=ACCEPT" [RFC6855[https://tools.ietf.org/html/rfc6855]]] + # + # The server's capabilities must include UTF8=ACCEPT _or_ + # UTF8=ONLY. + # + # This allows the server to send strings encoded as UTF-8 which might + # otherwise need to use a 7-bit encoding, such as {modified + # UTF-7}[::decode_utf7] for mailbox names, or RFC2047 encoded-words for + # message headers. + # + # *Note:* For now, strings with 8-bit characters are still _sent_ using + # "literal" syntax. A future update will change how commands send UTF-8 + # strings when UTF8=ACCEPT is enabled. This update should be + # backward-compatible. + # + # *Note:* A future update may set string encodings slightly + # differently, e.g: "US-ASCII" when UTF-8 is not enabled, and "UTF-8" + # when it is. Currently, the encoding of strings sent as "quoted" or + # "text" will _always_ be "UTF-8", even when a 7-bit encoding is used + # (e.g. UTF-7, encoded-words, quoted-printable, base64). And currently, + # string "literals" sent by the server will always have an "ASCII-8BIT" + # (binary) encoding, even if they must contain UTF-8 data---although a + # server _should_ use "quoted" strings once UTF8=ACCEPT is + # enabled. + # + # ["UTF8=ONLY" [RFC6855[https://tools.ietf.org/html/rfc6855]]] + # + # A server that reports the UTF8=ONLY #capability _requires_ that + # the client enable("UTF8=ACCEPT") before any mailboxes may be + # selected. For convenience, enable("UTF8=ONLY") is aliased to + # enable("UTF8=ACCEPT"). + # + # ===== Unsupported capabilities + # + # *Note:* Some extensions that use ENABLE permit the server to send syntax + # that Net::IMAP cannot parse, which may raise an exception and disconnect. + # Some extensions may work, but the support may be incomplete, untested, or + # experimental. + # + # Until a capability is documented here as supported, enabling it may result + # in undocumented behavior and a future release may update with incompatible + # behavior without warning or deprecation. + # + # Caution is advised. + # + def enable(*capabilities) + capabilities = capabilities + .flatten + .map {|e| ENABLE_ALIASES[e] || e } + .uniq + .join(' ') synchronize do - send_command("ENABLE #{[extensions].flatten.join(' ')}") + send_command("ENABLE #{capabilities}") return @responses.delete("ENABLED")[-1] end end diff --git a/lib/net/imap/response_parser.rb b/lib/net/imap/response_parser.rb index ea106e7e2..de884b4ae 100644 --- a/lib/net/imap/response_parser.rb +++ b/lib/net/imap/response_parser.rb @@ -9,6 +9,7 @@ class IMAP < Protocol # Parses an \IMAP server response. class ResponseParser include ParserUtils + extend ParserUtils::Generator # :call-seq: Net::IMAP::ResponseParser.new -> Net::IMAP::ResponseParser def initialize @@ -38,9 +39,6 @@ def parse(str) EXPR_BEG = :EXPR_BEG # the default, used in most places EXPR_DATA = :EXPR_DATA # envelope, body(structure), namespaces - EXPR_TEXT = :EXPR_TEXT # text, after 'resp-text-code "]"' - EXPR_RTEXT = :EXPR_RTEXT # resp-text, before "[" - EXPR_CTEXT = :EXPR_CTEXT # resp-text-code, after 'atom SP' T_SPACE = :SPACE # atom special T_ATOM = :ATOM # atom (subset of astring chars) @@ -60,13 +58,138 @@ def parse(str) T_TEXT = :TEXT # any char except CRLF T_EOF = :EOF # end of response string + module Patterns + + module CharClassSubtraction + refine Regexp do + def -(rhs); /[#{source}&&[^#{rhs.source}]]/n.freeze end + end + end + using CharClassSubtraction + + # From RFC5234, "Augmented BNF for Syntax Specifications: ABNF" + # >>> + # ALPHA = %x41-5A / %x61-7A ; A-Z / a-z + # CHAR = %x01-7F + # CRLF = CR LF + # ; Internet standard newline + # CTL = %x00-1F / %x7F + # ; controls + # DIGIT = %x30-39 + # ; 0-9 + # DQUOTE = %x22 + # ; " (Double Quote) + # HEXDIG = DIGIT / "A" / "B" / "C" / "D" / "E" / "F" + # OCTET = %x00-FF + # SP = %x20 + module RFC5234 + ALPHA = /[A-Za-z]/n + CHAR = /[\x01-\x7f]/n + CRLF = /\r\n/n + CTL = /[\x00-\x1F\x7F]/n + DIGIT = /\d/n + DQUOTE = /"/n + HEXDIG = /\h/ + OCTET = /[\x00-\xFF]/n # not using /./m for embedding purposes + SP = / /n + end + + # UTF-8, a transformation format of ISO 10646 + # >>> + # UTF8-1 = %x00-7F + # UTF8-tail = %x80-BF + # UTF8-2 = %xC2-DF UTF8-tail + # UTF8-3 = %xE0 %xA0-BF UTF8-tail / %xE1-EC 2( UTF8-tail ) / + # %xED %x80-9F UTF8-tail / %xEE-EF 2( UTF8-tail ) + # UTF8-4 = %xF0 %x90-BF 2( UTF8-tail ) / %xF1-F3 3( UTF8-tail ) / + # %xF4 %x80-8F 2( UTF8-tail ) + # UTF8-char = UTF8-1 / UTF8-2 / UTF8-3 / UTF8-4 + # UTF8-octets = *( UTF8-char ) + # + # n.b. String * Integer is used for repetition, rather than /x{3}/, + # because ruby 3.2's linear-time cache-based optimization doesn't work + # with "bounded or fixed times repetition nesting in another repetition + # (e.g. /(a{2,3})*/). It is an implementation issue entirely, but we + # believe it is hard to support this case correctly." + # See https://bugs.ruby-lang.org/issues/19104 + module RFC3629 + UTF8_1 = /[\x00-\x7f]/n # aka ASCII 7bit + UTF8_TAIL = /[\x80-\xBF]/n + UTF8_2 = /[\xC2-\xDF]#{UTF8_TAIL}/n + UTF8_3 = Regexp.union(/\xE0[\xA0-\xBF]#{UTF8_TAIL}/n, + /\xED[\x80-\x9F]#{UTF8_TAIL}/n, + /[\xE1-\xEC]#{ UTF8_TAIL.source * 2}/n, + /[\xEE-\xEF]#{ UTF8_TAIL.source * 2}/n) + UTF8_4 = Regexp.union(/[\xF1-\xF3]#{ UTF8_TAIL.source * 3}/n, + /\xF0[\x90-\xBF]#{UTF8_TAIL.source * 2}/n, + /\xF4[\x80-\x8F]#{UTF8_TAIL.source * 2}/n) + UTF8_CHAR = Regexp.union(UTF8_1, UTF8_2, UTF8_3, UTF8_4) + UTF8_OCTETS = /#{UTF8_CHAR}*/n + end + + include RFC5234 + include RFC3629 + + # quoted-specials = DQUOTE / "\" + QUOTED_SPECIALS = /["\\]/n + # resp-specials = "]" + RESP_SPECIALS = /[\]]/n + + # TEXT-CHAR = + TEXT_CHAR = CHAR - /[\r\n]/ + + # resp-text-code = ... / atom [SP 1*] + CODE_TEXT_CHAR = TEXT_CHAR - RESP_SPECIALS + CODE_TEXT = /#{CODE_TEXT_CHAR}+/n + + # RFC3501: + # QUOTED-CHAR = / + # "\" quoted-specials + # RFC9051: + # QUOTED-CHAR = / + # "\" quoted-specials / UTF8-2 / UTF8-3 / UTF8-4 + # RFC3501 & RFC9051: + # quoted = DQUOTE *QUOTED-CHAR DQUOTE + QUOTED_CHAR_safe = TEXT_CHAR - QUOTED_SPECIALS + QUOTED_CHAR_esc = /\\#{QUOTED_SPECIALS}/n + QUOTED_CHAR_rev1 = Regexp.union(QUOTED_CHAR_safe, QUOTED_CHAR_esc) + QUOTED_CHAR_rev2 = Regexp.union(QUOTED_CHAR_rev1, + UTF8_2, UTF8_3, UTF8_4) + QUOTED_rev1 = /"(#{QUOTED_CHAR_rev1}*)"/n + QUOTED_rev2 = /"(#{QUOTED_CHAR_rev2}*)"/n + + # RFC3501: + # text = 1*TEXT-CHAR + # RFC9051: + # text = 1*(TEXT-CHAR / UTF8-2 / UTF8-3 / UTF8-4) + # ; Non-ASCII text can only be returned + # ; after ENABLE IMAP4rev2 command + TEXT_rev1 = /#{TEXT_CHAR}+/ + TEXT_rev2 = /#{Regexp.union TEXT_CHAR, UTF8_2, UTF8_3, UTF8_4}+/ + + module_function + + def unescape_quoted!(quoted) + quoted + &.gsub!(/\\(#{QUOTED_SPECIALS})/n, "\\1") + &.force_encoding("UTF-8") + end + + def unescape_quoted(quoted) + quoted + &.gsub(/\\(#{QUOTED_SPECIALS})/n, "\\1") + &.force_encoding("UTF-8") + end + + end + # the default, used in most places BEG_REGEXP = /\G(?:\ (?# 1: SPACE )( +)|\ (?# 2: NIL )(NIL)(?=[\x80-\xff(){ \x00-\x1f\x7f%*"\\\[\]+])|\ (?# 3: NUMBER )(\d+)(?=[\x80-\xff(){ \x00-\x1f\x7f%*"\\\[\]+])|\ (?# 4: ATOM )([^\x80-\xff(){ \x00-\x1f\x7f%*"\\\[\]+]+)|\ -(?# 5: QUOTED )"((?:[^\x00\r\n"\\]|\\["\\])*)"|\ +(?# 5: QUOTED )#{Patterns::QUOTED_rev2}|\ (?# 6: LPAR )(\()|\ (?# 7: RPAR )(\))|\ (?# 8: BSLASH )(\\)|\ @@ -84,26 +207,24 @@ def parse(str) (?# 1: SPACE )( )|\ (?# 2: NIL )(NIL)|\ (?# 3: NUMBER )(\d+)|\ -(?# 4: QUOTED )"((?:[^\x00\r\n"\\]|\\["\\])*)"|\ +(?# 4: QUOTED )#{Patterns::QUOTED_rev2}|\ (?# 5: LITERAL )\{(\d+)\}\r\n|\ (?# 6: LPAR )(\()|\ (?# 7: RPAR )(\)))/ni # text, after 'resp-text-code "]"' - TEXT_REGEXP = /\G(?:\ -(?# 1: TEXT )([^\x00\r\n]*))/ni - - # resp-text, before "[" - RTEXT_REGEXP = /\G(?:\ -(?# 1: LBRA )(\[)|\ -(?# 2: TEXT )([^\x00\r\n]*))/ni + TEXT_REGEXP = /\G(#{Patterns::TEXT_rev2})/n # resp-text-code, after 'atom SP' - CTEXT_REGEXP = /\G(?:\ -(?# 1: TEXT )([^\x00\r\n\]]*))/ni + CTEXT_REGEXP = /\G(#{Patterns::CODE_TEXT})/n Token = Struct.new(:symbol, :value) + def_char_matchers :SP, " ", :T_SPACE + + def_char_matchers :lbra, "[", :T_LBRA + def_char_matchers :rbra, "]", :T_RBRA + # atom = 1*ATOM-CHAR # # TODO: match atom entirely by regexp (in the "lexer") @@ -1140,23 +1261,35 @@ def namespace_response_extensions data end - # text = 1*TEXT-CHAR - # TEXT-CHAR = + # TEXT-CHAR = + # RFC3501: + # text = 1*TEXT-CHAR + # RFC9051: + # text = 1*(TEXT-CHAR / UTF8-2 / UTF8-3 / UTF8-4) + # ; Non-ASCII text can only be returned + # ; after ENABLE IMAP4rev2 command def text - match(T_TEXT, lex_state: EXPR_TEXT).value + match_re(TEXT_REGEXP, "text")[0].force_encoding("UTF-8") + end + + # an "accept" versiun of #text + def text? + accept_re(TEXT_REGEXP)&.[](0)&.force_encoding("UTF-8") end - # resp-text = ["[" resp-text-code "]" SP] text + # RFC3501: + # resp-text = ["[" resp-text-code "]" SP] text + # RFC9051: + # resp-text = ["[" resp-text-code "]" SP] [text] + # + # We leniently re-interpret this as + # resp-text = ["[" resp-text-code "]" [SP [text]] / [text] def resp_text - token = match(T_LBRA, T_TEXT, lex_state: EXPR_RTEXT) - case token.symbol - when T_LBRA - code = resp_text_code - match(T_RBRA) - accept_space # violating RFC - ResponseText.new(code, text) - when T_TEXT - ResponseText.new(nil, token.value) + if lbra? + code = resp_text_code; rbra + ResponseText.new(code, SP? && text? || "") + else + ResponseText.new(nil, text? || "") end end @@ -1198,8 +1331,7 @@ def resp_text_code token = lookahead if token.symbol == T_SPACE shift_token - token = match(T_TEXT, lex_state: EXPR_CTEXT) - result = ResponseCode.new(name, token.value) + result = ResponseCode.new(name, text_chars_except_rbra) else result = ResponseCode.new(name, nil) end @@ -1207,6 +1339,11 @@ def resp_text_code return result end + # 1* + def text_chars_except_rbra + match_re(CTEXT_REGEXP, '1*')[0] + end + def charset_list result = [] if accept(T_SPACE) @@ -1288,9 +1425,7 @@ def address mailbox = $3 host = $4 for s in [name, route, mailbox, host] - if s - s.gsub!(/\\(["\\])/n, "\\1") - end + Patterns.unescape_quoted! s end else name = nstring @@ -1447,21 +1582,6 @@ def nil_atom SPACES_REGEXP = /\G */n - # This advances @pos directly so it's safe before changing @lex_state. - def accept_space - if @token - if @token.symbol == T_SPACE - shift_token - " " - end - elsif @str[@pos] == " " - @pos += 1 - " " - end - end - - alias SP? accept_space - # The RFC is very strict about this and usually we should be too. # But skipping spaces is usually a safe workaround for buggy servers. # @@ -1487,8 +1607,7 @@ def next_token elsif $4 return Token.new(T_ATOM, $+) elsif $5 - return Token.new(T_QUOTED, - $+.gsub(/\\(["\\])/n, "\\1")) + return Token.new(T_QUOTED, Patterns.unescape_quoted($+)) elsif $6 return Token.new(T_LPAR, $+) elsif $7 @@ -1531,8 +1650,7 @@ def next_token elsif $3 return Token.new(T_NUMBER, $+) elsif $4 - return Token.new(T_QUOTED, - $+.gsub(/\\(["\\])/n, "\\1")) + return Token.new(T_QUOTED, Patterns.unescape_quoted($+)) elsif $5 len = $+.to_i val = @str[@pos, len] @@ -1549,44 +1667,6 @@ def next_token @str.index(/\S*/n, @pos) parse_error("unknown token - %s", $&.dump) end - when EXPR_TEXT - if @str.index(TEXT_REGEXP, @pos) - @pos = $~.end(0) - if $1 - return Token.new(T_TEXT, $+) - else - parse_error("[Net::IMAP BUG] TEXT_REGEXP is invalid") - end - else - @str.index(/\S*/n, @pos) - parse_error("unknown token - %s", $&.dump) - end - when EXPR_RTEXT - if @str.index(RTEXT_REGEXP, @pos) - @pos = $~.end(0) - if $1 - return Token.new(T_LBRA, $+) - elsif $2 - return Token.new(T_TEXT, $+) - else - parse_error("[Net::IMAP BUG] RTEXT_REGEXP is invalid") - end - else - @str.index(/\S*/n, @pos) - parse_error("unknown token - %s", $&.dump) - end - when EXPR_CTEXT - if @str.index(CTEXT_REGEXP, @pos) - @pos = $~.end(0) - if $1 - return Token.new(T_TEXT, $+) - else - parse_error("[Net::IMAP BUG] CTEXT_REGEXP is invalid") - end - else - @str.index(/\S*/n, @pos) #/ - parse_error("unknown token - %s", $&.dump) - end else parse_error("invalid @lex_state - %s", @lex_state.inspect) end diff --git a/lib/net/imap/response_parser/parser_utils.rb b/lib/net/imap/response_parser/parser_utils.rb index 49517d7ef..1583c8956 100644 --- a/lib/net/imap/response_parser/parser_utils.rb +++ b/lib/net/imap/response_parser/parser_utils.rb @@ -8,26 +8,58 @@ class ResponseParser # (internal API, subject to change) module ParserUtils # :nodoc: - private + module Generator + + LOOKAHEAD = "(@token ||= next_token)" + SHIFT_TOKEN = "(@token = nil)" + + # we can skip lexer for single character matches, as a shortcut + def def_char_matchers(name, char, token) + match_name = name.match(/\A[A-Z]/) ? "#{name}!" : name + char = char.dump + class_eval <<~RUBY, __FILE__, __LINE__ + 1 + # frozen_string_literal: true - def match(*args, lex_state: @lex_state) - if @token && lex_state != @lex_state - parse_error("invalid lex_state change to %s with unconsumed token", - lex_state) + # like accept(token_symbols); returns token or nil + def #{name}? + if @token&.symbol == #{token} + #{SHIFT_TOKEN} + #{char} + elsif !@token && @str[@pos] == #{char} + @pos += 1 + #{char} + end + end + + # like match(token_symbols); returns token or raises parse_error + def #{match_name} + if @token&.symbol == #{token} + #{SHIFT_TOKEN} + #{char} + elsif !@token && @str[@pos] == #{char} + @pos += 1 + #{char} + else + parse_error("unexpected %s (expected %p)", + @token&.symbol || @str[@pos].inspect, #{char}) + end + end + RUBY end - begin - @lex_state, original_lex_state = lex_state, @lex_state - token = lookahead - unless args.include?(token.symbol) - parse_error('unexpected token %s (expected %s)', - token.symbol.id2name, - args.collect {|i| i.id2name}.join(" or ")) - end - shift_token - return token - ensure - @lex_state = original_lex_state + + end + + private + + def match(*args) + token = lookahead + unless args.include?(token.symbol) + parse_error('unexpected token %s (expected %s)', + token.symbol.id2name, + args.collect {|i| i.id2name}.join(" or ")) end + shift_token + token end # like match, but does not raise error on failure. @@ -42,6 +74,14 @@ def accept(*args) end end + # To be used conditionally: + # assert_no_lookahead if Net::IMAP.debug + def assert_no_lookahead + @token.nil? or + parse_error("assertion failed: expected @token.nil?, actual %s: %p", + @token.symbol, @token.value) + end + # like accept, without consuming the token def lookahead?(*symbols) @token if symbols.include?((@token ||= next_token)&.symbol) @@ -51,6 +91,22 @@ def lookahead @token ||= next_token end + def accept_re(re) + assert_no_lookahead if Net::IMAP.debug + re.match(@str, @pos) and @pos = $~.end(0) + $~ + end + + def match_re(re, name) + assert_no_lookahead if Net::IMAP.debug + if re.match(@str, @pos) + @pos = $~.end(0) + $~ + else + parse_error("invalid #{name}") + end + end + def shift_token @token = nil end diff --git a/test/net/imap/fixtures/response_parser/utf8_responses.yml b/test/net/imap/fixtures/response_parser/utf8_responses.yml new file mode 100644 index 000000000..e38275da6 --- /dev/null +++ b/test/net/imap/fixtures/response_parser/utf8_responses.yml @@ -0,0 +1,23 @@ + +--- +:tests: + + test_utf8_in_list_mailbox: + :response: "* LIST () \"/\" \"☃️&☺️\"\r\n" + :expected: !ruby/struct:Net::IMAP::UntaggedResponse + name: LIST + data: !ruby/struct:Net::IMAP::MailboxList + attr: [] + delim: "/" + name: "☃️&☺️" + raw_data: !binary |- + KiBMSVNUICgpICIvIiAi4piD77iPJuKYuu+4jyINCg== + + test_utf8_in_resp_text: + :response: "* OK 𝖀𝖓𝖎𝖈𝖔𝖉𝖊 «α-ω» ほげ ふが ʇɐɥʍ\r\n" + :expected: !ruby/struct:Net::IMAP::UntaggedResponse + name: OK + data: !ruby/struct:Net::IMAP::ResponseText + text: "𝖀𝖓𝖎𝖈𝖔𝖉𝖊 «α-ω» ほげ ふが ʇɐɥʍ" + raw_data: !binary |- + KiBPSyDwnZaA8J2Wk/Cdlo7wnZaI8J2WlPCdlonwnZaKIMKrzrEtz4nCuyDjgbvjgZIg44G144 GMIMqHyZDJpcqNDQo= diff --git a/test/net/imap/net_imap_test_helpers.rb b/test/net/imap/net_imap_test_helpers.rb index ab3e0224e..f848f35b4 100644 --- a/test/net/imap/net_imap_test_helpers.rb +++ b/test/net/imap/net_imap_test_helpers.rb @@ -40,7 +40,7 @@ def generate_tests_from(fixture_data: nil, fixture_file: nil) case type when :parser_assert_equal - response = test.fetch(:response) + response = test.fetch(:response).force_encoding "ASCII-8BIT" expected = test.fetch(:expected) define_method name do diff --git a/test/net/imap/test_imap.rb b/test/net/imap/test_imap.rb index 838100b40..80b86d40d 100644 --- a/test/net/imap/test_imap.rb +++ b/test/net/imap/test_imap.rb @@ -870,23 +870,18 @@ def test_uidplus_responses end def test_enable - server = create_tcp_server - port = server.addr[1] - requests = [] - start_server do - sock = server.accept - begin - sock.print("* OK test server\r\n") - requests.push(sock.gets) - sock.print("* ENABLED SMTPUTF8\r\n") - sock.print("RUBY0001 OK \r\n") - sock.gets - sock.print("* BYE terminating connection\r\n") - sock.print("RUBY0002 OK LOGOUT completed\r\n") - ensure - sock.close - server.close - end + requests = Queue.new + port = yields_in_test_server_thread do |sock| + requests << (tag, = sock.getcmd).join(" ") + "\r\n" + sock.print "* ENABLED SMTPUTF8\r\n" + sock.print "#{tag} OK \r\n" + requests << (tag, = sock.getcmd).join(" ") + "\r\n" + sock.print "* ENABLED CONDSTORE UTF8=ACCEPT\r\n" + sock.print "#{tag} OK \r\n" + requests << (tag, = sock.getcmd).join(" ") + "\r\n" + sock.print "* ENABLED \r\n" + sock.print "#{tag} OK \r\n" + sock.getcmd # waits for logout command end begin @@ -894,6 +889,13 @@ def test_enable response = imap.enable(["SMTPUTF8", "X-NO-SUCH-THING"]) assert_equal("RUBY0001 ENABLE SMTPUTF8 X-NO-SUCH-THING\r\n", requests.pop) assert_equal(response, ["SMTPUTF8"]) + response = imap.enable(:utf8, "condstore QResync", "x-pig-latin") + assert_equal("RUBY0002 ENABLE UTF8=ACCEPT condstore QResync x-pig-latin\r\n", + requests.pop) + response = imap.enable(:utf8, "UTF8=ACCEPT", "UTF8=ONLY") + assert_equal(response, []) + assert_equal("RUBY0003 ENABLE UTF8=ACCEPT\r\n", + requests.pop) imap.logout ensure imap.disconnect if imap diff --git a/test/net/imap/test_imap_response_parser.rb b/test/net/imap/test_imap_response_parser.rb index 61f28dce8..1e42ee3e4 100644 --- a/test/net/imap/test_imap_response_parser.rb +++ b/test/net/imap/test_imap_response_parser.rb @@ -31,6 +31,9 @@ def teardown # Core IMAP, by RFC9051 section (w/obsolete in relative RFC3501 section): generate_tests_from fixture_file: "rfc3501_examples.yml" + # §4.3: Strings (also §5.1, §9, and RFC6855): + generate_tests_from fixture_file: "utf8_responses.yml" + # §7.1: Generic Status Responses (OK, NO, BAD, PREAUTH, BYE, codes, text) generate_tests_from fixture_file: "resp_code_examples.yml" generate_tests_from fixture_file: "resp_cond_examples.yml"