From 4389e5eb420382d525fa58d07cd404954f539df0 Mon Sep 17 00:00:00 2001 From: nick evans Date: Wed, 8 Jul 2026 14:21:31 -0400 Subject: [PATCH 1/3] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Refactor=20send=5Fstri?= =?UTF-8?q?ng=5Fdata?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This makes the logic a little bit simpler to follow, which makes UTF-8 validation bug much more obvious. --- lib/net/imap/command_data.rb | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/lib/net/imap/command_data.rb b/lib/net/imap/command_data.rb index f7f22955..32f1d8dd 100644 --- a/lib/net/imap/command_data.rb +++ b/lib/net/imap/command_data.rb @@ -72,22 +72,23 @@ def send_string_data(str, tag = nil) if str.empty? # same as send_quoted_string(str), but incompatible encoding is allowed put_string('""') - elsif str.match?(UNQUOTABLE_CHARS) - send_literal(str, tag) - elsif !str.ascii_only? - if @utf8_strings - send_quoted_string(str) - else - send_literal(str, tag) - end - elsif str.match?(ASTRING_SPECIALS) - send_quoted_string(str) - else + elsif str.ascii_only? && !str.match?(ASTRING_SPECIALS) # valid +astring+ atom: non-empty, ASCII only, no ASTRING_SPECIALS put_string(str) + elsif text_encodable?(str) && !str.match?(UNQUOTABLE_CHARS) + send_quoted_string(str) + else + send_literal(str, tag) end end + # Encodable as +text+ (which is a superset of +quoted+): + # * ASCII only (for any ASCII compatible encoding) + # * or UTF-8 has been enabled (TODO: validate UTF-8 strings) + def text_encodable?(str) + str.ascii_only? || @utf8_strings + end + def send_quoted_string(str) = QuotedString.new(data: str).send_data(self) def send_binary_literal(*, **) = send_literal(*, **, binary: true) From 4afa7d845aa1597f4c1fa3b1ad7510f117b34fb0 Mon Sep 17 00:00:00 2001 From: nick evans Date: Wed, 8 Jul 2026 14:18:40 -0400 Subject: [PATCH 2/3] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Avoid=20"historical=20?= =?UTF-8?q?binary=20regex"=20warning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/net/imap/command_data.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/net/imap/command_data.rb b/lib/net/imap/command_data.rb index 32f1d8dd..523f8836 100644 --- a/lib/net/imap/command_data.rb +++ b/lib/net/imap/command_data.rb @@ -58,8 +58,8 @@ def send_data(data, tag = nil) end end - UNQUOTABLE_CHARS = /\0\r\n/n - ASTRING_SPECIALS = /[(){ \x00-\x1f\x7f%*"\\]/n + UNQUOTABLE_CHARS = /\0\r\n/ + ASTRING_SPECIALS = /[(){ \x00-\x1f\x7f%*"\\]/ private_constant :UNQUOTABLE_CHARS, :ASTRING_SPECIALS # Sends generic strings formatted as +astring+: From 6855df1a915be1039599c65b6b2bf1be56f1597f Mon Sep 17 00:00:00 2001 From: nick evans Date: Mon, 6 Jul 2026 14:46:39 -0400 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=90=9B=20Use=20literal=20syntax=20for?= =?UTF-8?q?=20invalid=20UTF8=20or=20non-UTF8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Don't send strings as `quoted` unless they are valid UTF-8. --- lib/net/imap/command_data.rb | 6 ++++-- test/net/imap/test_imap.rb | 24 ++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/lib/net/imap/command_data.rb b/lib/net/imap/command_data.rb index 523f8836..71ab0c5b 100644 --- a/lib/net/imap/command_data.rb +++ b/lib/net/imap/command_data.rb @@ -84,9 +84,11 @@ def send_string_data(str, tag = nil) # Encodable as +text+ (which is a superset of +quoted+): # * ASCII only (for any ASCII compatible encoding) - # * or UTF-8 has been enabled (TODO: validate UTF-8 strings) + # * or valid UTF-8 (when the connection supports it) def text_encodable?(str) - str.ascii_only? || @utf8_strings + str.ascii_only? || (@utf8_strings && + str.encoding == Encoding::UTF_8 && + str.valid_encoding?) end def send_quoted_string(str) = QuotedString.new(data: str).send_data(self) diff --git a/test/net/imap/test_imap.rb b/test/net/imap/test_imap.rb index 6bd9aada..ac2b188c 100644 --- a/test/net/imap/test_imap.rb +++ b/test/net/imap/test_imap.rb @@ -994,18 +994,42 @@ def imap.test_args(*args) = send_command("TEST", *args) assert_equal("sync-literal-utf8 ({10+}\r\nαβγδε)".b, server.commands.pop.args) + imap.test_args "utf8-with-wrong-encoding", "αβγδε".b + assert_equal("utf8-with-wrong-encoding {10+}\r\nαβγδε".b, + server.commands.pop.args) + + imap.test_args "invalid-utf8", "\x80".b.force_encoding("UTF-8") + assert_equal("invalid-utf8 {1+}\r\n\x80".b, + server.commands.pop.args) + # Before enabling UTF-8 strings, without non-synchronizing literals imap.config.max_non_synchronizing_literal = -1 imap.test_args "sync-literal-utf8", ["αβγδε"] assert_equal("sync-literal-utf8 ({10}\r\nαβγδε)".b, server.commands.pop.args) + imap.test_args "utf8-with-wrong-encoding", "αβγδε".b + assert_equal("utf8-with-wrong-encoding {10}\r\nαβγδε".b, + server.commands.pop.args) + + imap.test_args "invalid-utf8", "\x80".b.force_encoding("UTF-8") + assert_equal("invalid-utf8 {1}\r\n\x80".b, + server.commands.pop.args) + # After enabling UTF-8 strings imap.enable(:utf8) server.commands.pop.args => ["UTF8=ACCEPT"] imap.test_args "quoted-utf8", "αβγδε" assert_equal 'quoted-utf8 "αβγδε"'.b, server.commands.pop.args + + imap.test_args "utf8-with-wrong-encoding", "αβγδε".b + assert_equal("utf8-with-wrong-encoding {10}\r\nαβγδε".b, + server.commands.pop.args) + + imap.test_args "invalid-utf8", "\x80".b.force_encoding("UTF-8") + assert_equal("invalid-utf8 {1}\r\n\x80".b, + server.commands.pop.args) end end