diff --git a/lib/net/imap.rb b/lib/net/imap.rb index 8d21f400..0786966b 100644 --- a/lib/net/imap.rb +++ b/lib/net/imap.rb @@ -813,7 +813,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.6.4" + VERSION = "0.6.4.1" # Aliases for supported capabilities, to be used with the #enable command. ENABLE_ALIASES = { @@ -3154,10 +3154,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 9a5749b5..8ee27b67 100644 --- a/lib/net/imap/command_data.rb +++ b/lib/net/imap/command_data.rb @@ -77,23 +77,29 @@ def send_string_data(str, tag = nil) end end - def send_quoted_string(str) - put_string('"' + str.gsub(/["\\]/, "\\\\\\&") + '"') - end + def send_quoted_string(str) = QuotedString.new(data: str).send_data(self) def send_binary_literal(*, **) = send_literal(*, **, binary: true) # `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). def send_literal(str, tag = nil, binary: false, non_sync: nil) + bytesize = str.bytesize synchronize do - non_sync = non_sync_literal?(str.bytesize) if non_sync.nil? + 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 + non_sync = non_sync_literal?(bytesize) if non_sync.nil? 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 @@ -113,12 +119,19 @@ def send_literal(str, tag = nil, binary: false, non_sync: nil) end def non_sync_literal?(bytesize) - capabilities_cached? && - bytesize <= config.max_non_synchronizing_literal && - (capable?("LITERAL+") || - bytesize <= 4096 && (capable?("IMAP4rev2") || capable?("LITERAL-"))) + bytesize <= config.max_non_synchronizing_literal \ + && non_sync_literal_allowed?(bytesize) + 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") + # NOTE: +num+ should already be an Integer def send_number_data(num) put_string(Integer(num).to_s) @@ -156,36 +169,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 data.encoding in Encoding::ASCII | Encoding::UTF_8 - -data - elsif data.ascii_only? - -(data.dup.force_encoding("ASCII")) - else - -(data.dup.force_encoding("UTF-8")) + unless data.encoding in Encoding::ASCII | Encoding::UTF_8 + 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 !(data.encoding in Encoding::ASCII | Encoding::UTF_8) + 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 @@ -193,7 +208,17 @@ def validate def ascii_only? = data.ascii_only? - def send_data(imap, tag) = imap.__send__(:put_string, data) + def send_data(imap, tag = nil) = imap.__send__(:put_string, formatted) + 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: @@ -212,7 +237,7 @@ def send_data(imap, tag) = data.each do _1.send_data(imap, tag) end def validate return unless data.last in RawText(data: text) - if text.rindex(/~?\{[1-9]\d*\+?\}\z/n) + if text.rindex(/\{\d+\+?\}\z/n) raise DataFormatError, "RawData cannot end with literal continuation" end end @@ -272,10 +297,13 @@ def send_data(imap, tag) end end - class QuotedString < CommandData # :nodoc: - def send_data(imap, tag) - imap.__send__(:send_quoted_string, 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 class Literal < Data.define(:data, :non_sync) # :nodoc: diff --git a/test/net/imap/test_command_data.rb b/test/net/imap/test_command_data.rb index 59419899..dafa6a08 100644 --- a/test/net/imap/test_command_data.rb +++ b/test/net/imap/test_command_data.rb @@ -8,6 +8,7 @@ class CommandDataTest < Net::IMAP::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 @@ -166,20 +167,131 @@ class StringFormatterTest < Net::IMAP::TestCase end 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 @@ -198,13 +310,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: @@ -221,7 +348,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) @@ -230,15 +361,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 @@ -366,6 +491,13 @@ 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( diff --git a/test/net/imap/test_imap.rb b/test/net/imap/test_imap.rb index f43bdcc7..321cc258 100644 --- a/test/net/imap/test_imap.rb +++ b/test/net/imap/test_imap.rb @@ -869,6 +869,55 @@ 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) + server.on "TEST", &:done_ok + + imap.config.max_non_synchronizing_literal = 5_000 + 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) + 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) + 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] diff --git a/test/net/imap/test_imap_enable.rb b/test/net/imap/test_imap_enable.rb index 9845464d..75dd7f20 100644 --- a/test/net/imap/test_imap_enable.rb +++ b/test/net/imap/test_imap_enable.rb @@ -27,6 +27,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