From e3c50fadf533ee253fb6dffeba02dd433dc83928 Mon Sep 17 00:00:00 2001 From: nick evans Date: Sat, 2 May 2026 14:03:30 -0400 Subject: [PATCH 1/9] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor=20RawText,=20?= =?UTF-8?q?add=20improve=20test=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Also update internal RawText docs. --- lib/net/imap/command_data.rb | 32 ++++++++------- test/net/imap/test_command_data.rb | 63 +++++++++++++++++++++++++----- 2 files changed, 70 insertions(+), 25 deletions(-) diff --git a/lib/net/imap/command_data.rb b/lib/net/imap/command_data.rb index 9a5749b5..0ce20d65 100644 --- a/lib/net/imap/command_data.rb +++ b/lib/net/imap/command_data.rb @@ -156,36 +156,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+ data, which covers everything in the IMAP grammar, + # except for +literal+, +literal8+, and the concluding +CRLF+. # - # NOTE: The current implementation does not validate whether the connection - # currently supports UTF-8. Future versions may change. + # +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: 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: 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 diff --git a/test/net/imap/test_command_data.rb b/test/net/imap/test_command_data.rb index 59419899..dd7694e4 100644 --- a/test/net/imap/test_command_data.rb +++ b/test/net/imap/test_command_data.rb @@ -167,19 +167,53 @@ class StringFormatterTest < Net::IMAP::TestCase 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 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 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('foo "bar" (baz)'), - Output.put_string("(){}[]%*\"\\"), - Output.put_string("(((((((((((((((( unbalanced ]]]]]]]]]]]]]"), + 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 quoted specials" do + [ + '"with" "quoted" "specials"', + '\\with\\quoted\\specials\\', + %{(){}[]%*"'\\}, + ].each do |string| + imap.send_data RawText[string] + end + assert_equal [ + Output.put_string('"with" "quoted" "specials"'), + Output.put_string('\\with\\quoted\\specials\\'), + Output.put_string(%{(){}[]%*"'\\}), ], imap.output end @@ -198,6 +232,15 @@ 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 + data( "NULL" => ["with \0 NULL", /NULL\b.+\bbyte/i], "CR" => ["with \r CR", /CR\b.+\bbyte/i], From 62a0da6d1f78610b15941758db4a28f027a25666 Mon Sep 17 00:00:00 2001 From: nick evans Date: Mon, 11 May 2026 11:59:08 -0400 Subject: [PATCH 2/9] =?UTF-8?q?=F0=9F=A5=85=20Validate=20non-synchronizing?= =?UTF-8?q?=20literals=20support?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It's bad behavior to send a non-synchronizing literal that's too large. This forces the server to choose between reading but ignoring the bytes or closing the connection. But, for servers that _don't_ support non-synchronizing literals, this could be another CRLF/command injection attack vector. If a server sees the `}\r\n` but can't parse the literal bytesize, it may decide to close the connection, and all is fine. But, a server _might_ respond to any unparseable command line (ending in `CRLF`) with `BAD`, then interpret the literal as the next command. In that case, a CRLF/command injection could succeed. Fortunately, `LITERAL-` is supported by most IMAP servers. So this is not expected to be widely exploitable. --- lib/net/imap/command_data.rb | 24 ++++++++++++++---- test/net/imap/test_imap.rb | 49 ++++++++++++++++++++++++++++++++++++ 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/lib/net/imap/command_data.rb b/lib/net/imap/command_data.rb index 9a5749b5..5fd9826a 100644 --- a/lib/net/imap/command_data.rb +++ b/lib/net/imap/command_data.rb @@ -85,11 +85,18 @@ 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) synchronize do + if non_sync && !non_sync_literal_allowed?(str.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?(str.bytesize) if non_sync.nil? prefix = "~" if binary plus = "+" if non_sync @@ -113,12 +120,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) diff --git a/test/net/imap/test_imap.rb b/test/net/imap/test_imap.rb index f43bdcc7..b1df2b75 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 + ) 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 + ) 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] From 8d9397abeafc3e8319771902b7fcd73024492577 Mon Sep 17 00:00:00 2001 From: nick evans Date: Sat, 2 May 2026 14:02:18 -0400 Subject: [PATCH 3/9] =?UTF-8?q?=F0=9F=A5=85=20Validate=20QuotedString=20co?= =?UTF-8?q?ntains=20only=20valid=20bytes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This shares most of the validation implementation with RawText, via an extracted shared superclass. Please note: in currently released `net-imap`, QuotedString is _not_ used to quote regular string arguments. It's currently only used by `Net::IMAP#id` (the `ID` extension). Without this patch, `Net::IMAP#id` is vulnerable to the same CRLF injection issues in GHSA-75xq-5h9v-w6px and GHSA-hm49-wcqc-g2xg. The string will be quoted, which may limit the ability to inject some commands. Presumably, `Net::IMAP#id` is unlikely to be called with user-provided input, making this less likely to be exploitable. Nevertheless, CRLF injection should be prevented! --- lib/net/imap/command_data.rb | 29 +++++--- test/net/imap/test_command_data.rb | 102 ++++++++++++++++++++++++++--- 2 files changed, 113 insertions(+), 18 deletions(-) diff --git a/lib/net/imap/command_data.rb b/lib/net/imap/command_data.rb index 0ce20d65..95061d87 100644 --- a/lib/net/imap/command_data.rb +++ b/lib/net/imap/command_data.rb @@ -156,8 +156,8 @@ def validate end end - # Represents IMAP +text+ data, which covers everything in the IMAP grammar, - # except for +literal+, +literal8+, and the concluding +CRLF+. + # 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 @@ -170,7 +170,7 @@ def validate # # 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) unless data.encoding in Encoding::ASCII | Encoding::UTF_8 @@ -195,7 +195,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: @@ -274,10 +284,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 dd7694e4..b91416e1 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,6 +167,83 @@ 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 "allows ASCII strings with no specials" do imap.send_data( @@ -240,14 +318,20 @@ class RawTextTest < CommandDataTest 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: @@ -264,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) @@ -273,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 From 1f97168be633e3fcd6bf9c2525c493822508d910 Mon Sep 17 00:00:00 2001 From: nick evans Date: Fri, 22 May 2026 15:33:55 -0400 Subject: [PATCH 4/9] =?UTF-8?q?=F0=9F=A5=85=20Validate=20`#enable`=20argum?= =?UTF-8?q?ents=20are=20all=20atoms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This still allows the argument to be a single string with multiple space-delimited arguments. It splits the string first, and validates the substrings. --- lib/net/imap.rb | 5 +++-- test/net/imap/test_imap_enable.rb | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/lib/net/imap.rb b/lib/net/imap.rb index 8d21f400..12922658 100644 --- a/lib/net/imap.rb +++ b/lib/net/imap.rb @@ -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/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 From d6ddd294c588578f6d8dad588716c62f424af2f7 Mon Sep 17 00:00:00 2001 From: nick evans Date: Mon, 11 May 2026 08:51:25 -0400 Subject: [PATCH 5/9] =?UTF-8?q?=F0=9F=90=9B=20Prevent=20trailing=20`{0}`?= =?UTF-8?q?=20in=20RawData=20validation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zero-length literals are explicitly allowed by the RFCs and this did not catch text that ends with `{0}` or `{0+}`. This leaves RawData able to absorb the `CRLF` that ends the command, and thus absorb the following command into itself. Ultimately, we don't care if the `number64` is encoded correctly nor whether it claims to be a binary literal. So I've simplified the regexp by dropping `~?` and using `\d+` for the number. --- lib/net/imap/command_data.rb | 2 +- test/net/imap/test_command_data.rb | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/lib/net/imap/command_data.rb b/lib/net/imap/command_data.rb index 9a5749b5..f97fda44 100644 --- a/lib/net/imap/command_data.rb +++ b/lib/net/imap/command_data.rb @@ -212,7 +212,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 diff --git a/test/net/imap/test_command_data.rb b/test/net/imap/test_command_data.rb index 59419899..4d5ab858 100644 --- a/test/net/imap/test_command_data.rb +++ b/test/net/imap/test_command_data.rb @@ -366,6 +366,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( From ae9f83b59cd33265179e5ad406efe830ad246204 Mon Sep 17 00:00:00 2001 From: nick evans Date: Mon, 11 May 2026 12:14:25 -0400 Subject: [PATCH 6/9] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Extract=20str.bytesize?= =?UTF-8?q?=20lvar=20in=20send=5Fliteral?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/net/imap/command_data.rb | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/lib/net/imap/command_data.rb b/lib/net/imap/command_data.rb index 5fd9826a..d0a5fc51 100644 --- a/lib/net/imap/command_data.rb +++ b/lib/net/imap/command_data.rb @@ -90,17 +90,18 @@ def send_binary_literal(*, **) = send_literal(*, **, binary: true) # * `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 - if non_sync && !non_sync_literal_allowed?(str.bytesize) + 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?(str.bytesize) if non_sync.nil? + 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 From 07e002b5a5febbd86c82ae7d74756e9e5f9ced73 Mon Sep 17 00:00:00 2001 From: nick evans Date: Sun, 3 May 2026 14:55:34 -0400 Subject: [PATCH 7/9] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Use=20QuotedString=20i?= =?UTF-8?q?nternally=20to=20send=20quoted=20string?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This removes the duplicated string quoting implementation. It also introduces stricter enforcement of `quoted` string validation.. --- lib/net/imap/command_data.rb | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/lib/net/imap/command_data.rb b/lib/net/imap/command_data.rb index 95061d87..08c38de3 100644 --- a/lib/net/imap/command_data.rb +++ b/lib/net/imap/command_data.rb @@ -77,9 +77,7 @@ 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) From 0ea9eba30b060380efa83980ae6f62c58ffa97e7 Mon Sep 17 00:00:00 2001 From: nick evans Date: Tue, 9 Jun 2026 11:12:36 -0400 Subject: [PATCH 8/9] =?UTF-8?q?=E2=9C=85=20Fix=20flaky=20tests=20for=20Mac?= =?UTF-8?q?OS,=20TruffleRuby?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These tests can raise `Errno::ECONNREST, "Connection reset"` in the server thread. I've only seen it in TruffleRuby (semi-reliably) and MacOS (flaky), but probably it's a timing issue and can happen elsewhere too? --- test/net/imap/test_imap.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/net/imap/test_imap.rb b/test/net/imap/test_imap.rb index b1df2b75..321cc258 100644 --- a/test/net/imap/test_imap.rb +++ b/test/net/imap/test_imap.rb @@ -893,7 +893,7 @@ def imap.send_test_args(*args) = send_command("TEST", *args) 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_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 @@ -907,7 +907,7 @@ def imap.send_test_args(*args) = send_command("TEST", *args) 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_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 From 357f3b5aee4d2140635b6ba242fbb952491e2d28 Mon Sep 17 00:00:00 2001 From: nick evans Date: Fri, 15 May 2026 09:49:45 -0400 Subject: [PATCH 9/9] =?UTF-8?q?=F0=9F=94=96=20Bump=20version=20to=200.6.4.?= =?UTF-8?q?1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/net/imap.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/net/imap.rb b/lib/net/imap.rb index 12922658..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 = {