From a9d37516d2db7ad3befdf12d4917d21368e6f27c Mon Sep 17 00:00:00 2001 From: nick evans Date: Fri, 8 May 2026 17:26:47 -0400 Subject: [PATCH 1/9] =?UTF-8?q?=F0=9F=8D=92=20pick=205c213ab7=20(#675):=20?= =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20Allow=2064-bit=20Integer=20arguments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, integer arguments had to be 32-bit unsigned, and a special case exception was made for the number in a `["CHANGEDSINCE", number]` array. But several other command arguments can be larger: `UNCHANGEDSINCE`, quota `resource-limit`, and `tagged-ext-simple`. So, generic Integer arguments should allow 64 bit numbers. We can add specific argument validation where it makes sense to do so. --- lib/net/imap/command_data.rb | 15 ++++++++------- test/net/imap/test_imap.rb | 20 +++++++++++++------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/lib/net/imap/command_data.rb b/lib/net/imap/command_data.rb index bcc9de32..59c857ed 100644 --- a/lib/net/imap/command_data.rb +++ b/lib/net/imap/command_data.rb @@ -17,14 +17,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 diff --git a/test/net/imap/test_imap.rb b/test/net/imap/test_imap.rb index d27e3d25..e2447ca8 100644 --- a/test/net/imap/test_imap.rb +++ b/test/net/imap/test_imap.rb @@ -627,19 +627,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 @@ -653,8 +657,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 stderr = EnvUtil.verbose_warning do From 41250471764d10ea7c7e78c3474bada0803fbff7 Mon Sep 17 00:00:00 2001 From: nick evans Date: Mon, 4 May 2026 22:40:09 -0400 Subject: [PATCH 2/9] =?UTF-8?q?=F0=9F=8D=92=20pick=203fe06a4c=20(#676):=20?= =?UTF-8?q?=F0=9F=A5=85=20Ensure=20send=5Fnumber=5Fdata=20input=20is=20an?= =?UTF-8?q?=20Integer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/net/imap/command_data.rb | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/net/imap/command_data.rb b/lib/net/imap/command_data.rb index 59c857ed..5994b642 100644 --- a/lib/net/imap/command_data.rb +++ b/lib/net/imap/command_data.rb @@ -113,8 +113,9 @@ def send_literal(str, tag = nil, binary: false, non_sync: nil) end 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) From b3ce36b63554086b4f84d817450c734060564c5b Mon Sep 17 00:00:00 2001 From: nick evans Date: Thu, 14 May 2026 09:31:43 -0400 Subject: [PATCH 3/9] =?UTF-8?q?=F0=9F=8D=92=20pick=2094c79576=20(#677):=20?= =?UTF-8?q?=F0=9F=93=9A=E2=9A=A0=EF=B8=8F=20Boost=20visibility=20of=20raw?= =?UTF-8?q?=20data=20argument=20warnings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These methods share some of their argument handling, and this was documented by referencing the method they behaved like. Nevertheless, this warning can easily be missed, and it's important enough to call it out explicitly in every method where it's applicable. --- lib/net/imap.rb | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/lib/net/imap.rb b/lib/net/imap.rb index 16f05445..5b68463f 100644 --- a/lib/net/imap.rb +++ b/lib/net/imap.rb @@ -2209,6 +2209,7 @@ def uid_expunge(uid_set) # provided as an array or a string. # See {"Argument translation"}[rdoc-ref:#search@Argument+translation] # and {"Search criteria"}[rdoc-ref:#search@Search+criteria], below. + # Please note the warning for when +criteria+ is a String. # # +return+ options control what kind of information is returned about # messages matching the search +criteria+. Specifying +return+ should force @@ -2619,7 +2620,8 @@ def search(...) # backward compatibility) but adds SearchResult#modseq when the +CONDSTORE+ # capability has been enabled. # - # See #search for documentation of parameters. + # See #search for documentation of parameters. Please note the + # warning for when +criteria+ is a String. # # ==== Capabilities # @@ -2705,7 +2707,8 @@ def fetch(...) # {SequenceSet[...]}[rdoc-ref:SequenceSet@Creating+sequence+sets]. # (For message sequence numbers, use #fetch instead.) # - # +attr+ behaves the same as with #fetch. + # +attr+ behaves the same as with #fetch. Please note the #fetch + # warning on the +attr+ argument. # >>> # *Note:* Servers _MUST_ implicitly include the +UID+ message data item as # part of any +FETCH+ response caused by a +UID+ command, regardless of @@ -2917,8 +2920,10 @@ def uid_move(set, mailbox) # Sends a {SORT command [RFC5256 §3]}[https://www.rfc-editor.org/rfc/rfc5256#section-3] # to search a mailbox for messages that match +search_keys+ and return an - # array of message sequence numbers, sorted by +sort_keys+. +search_keys+ - # are interpreted the same as for #search. + # array of message sequence numbers, sorted by +sort_keys+. + # + # +search_keys+ are interpreted the same as the +criteria+ argument for + # #search. Please note the #search warning for String +criteria+. # #-- # TODO: describe +sort_keys+ @@ -2943,8 +2948,10 @@ def sort(sort_keys, search_keys, charset) # Sends a {UID SORT command [RFC5256 §3]}[https://www.rfc-editor.org/rfc/rfc5256#section-3] # to search a mailbox for messages that match +search_keys+ and return an - # array of unique identifiers, sorted by +sort_keys+. +search_keys+ are - # interpreted the same as for #search. + # array of unique identifiers, sorted by +sort_keys+. + # + # +search_keys+ are interpreted the same as the +criteria+ argument for + # #search. Please note the #search warning for String +criteria+. # # Related: #sort, #search, #uid_search, #thread, #uid_thread # @@ -2958,8 +2965,10 @@ def uid_sort(sort_keys, search_keys, charset) # Sends a {THREAD command [RFC5256 §3]}[https://www.rfc-editor.org/rfc/rfc5256#section-3] # to search a mailbox and return message sequence numbers in threaded - # format, as a ThreadMember tree. +search_keys+ are interpreted the same as - # for #search. + # format, as a ThreadMember tree. + # + # +search_keys+ are interpreted the same as the +criteria+ argument for + # #search. Please note the #search warning for String +criteria+. # # The supported algorithms are: # @@ -2985,6 +2994,9 @@ def thread(algorithm, search_keys, charset) # Similar to #thread, but returns unique identifiers instead of # message sequence numbers. # + # +search_keys+ are interpreted the same as the +criteria+ argument for + # #search. Please note the #search warning for String +criteria+. + # # Related: #thread, #search, #uid_search, #sort, #uid_sort # # ==== Capabilities From 2354465f983648993168a25c5c42f63099e395c7 Mon Sep 17 00:00:00 2001 From: nick evans Date: Wed, 15 Apr 2026 18:31:51 -0400 Subject: [PATCH 4/9] =?UTF-8?q?=F0=9F=8D=92=20pick=200d508fa7=20(#681):=20?= =?UTF-8?q?=F0=9F=A5=85=20Validate=20server's=20literal=20byte=20size=20fo?= =?UTF-8?q?rmat?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This guards against numbers like `99999999999999999999`. This isn't a security issue unless `max_response_size` is `nil`. But it's reasonable to block too large numbers in both the response reader and the response parser, regardless of that config setting. Note also that this still _allows_ strings like `000000000000000001`. This is goofy, but it's how the RFCs are written! ---- 🍒 pick note: `NumValidator` in v0.5 doesn't have `coerce_number64`. Rather than backport _that_, I opted to simply copy a simplified version into both `ResponseReader` and `ResponseParser`. --- lib/net/imap/response_parser.rb | 32 ++++++++++++------- lib/net/imap/response_reader.rb | 15 ++++++++- .../response_parser/quirky_behaviors.yml | 16 ++++++++++ test/net/imap/test_response_reader.rb | 21 ++++++++++++ 4 files changed, 71 insertions(+), 13 deletions(-) diff --git a/lib/net/imap/response_parser.rb b/lib/net/imap/response_parser.rb index 03e54b20..98871183 100644 --- a/lib/net/imap/response_parser.rb +++ b/lib/net/imap/response_parser.rb @@ -2189,10 +2189,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) @@ -2220,10 +2217,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 @@ -2249,10 +2243,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 @@ -2267,6 +2258,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 dd19d98d..590f6266 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 def line_done? = buff.end_with?(CRLF) 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 @@ -74,6 +79,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/fixtures/response_parser/quirky_behaviors.yml b/test/net/imap/fixtures/response_parser/quirky_behaviors.yml index e6bf9d48..a0145b60 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_response_reader.rb b/test/net/imap/test_response_reader.rb index d18b4065..4a0bafa6 100644 --- a/test/net/imap/test_response_reader.rb +++ b/test/net/imap/test_response_reader.rb @@ -25,6 +25,8 @@ def literal(str) = "{#{str.bytesize}}\r\n#{str}" 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, @@ -33,6 +35,8 @@ def literal(str) = "{#{str.bytesize}}\r\n#{str}" zero_literal, illegal_crs, illegal_lfs, + zero_padded, + goofy_zero, simple, ].join) rcvr = Net::IMAP::ResponseReader.new(client, io) @@ -43,6 +47,8 @@ def literal(str) = "{#{str.bytesize}}\r\n#{str}" 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 @@ -82,4 +88,19 @@ def literal(str) = "{#{str.bytesize}}\r\n#{str}" 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 From 3ebf437cbfd7beb39024489174d5ce53ec5d9164 Mon Sep 17 00:00:00 2001 From: nick evans Date: Mon, 11 May 2026 11:50:33 -0400 Subject: [PATCH 5/9] =?UTF-8?q?=F0=9F=8D=92=20pick=20b02182ce=20(#678):=20?= =?UTF-8?q?=E2=9C=85=F0=9F=90=9B=20Fix=20FakeServer=20CommandParseError=20?= =?UTF-8?q?(tests=20only)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/net/imap/fake_server/command_reader.rb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/net/imap/fake_server/command_reader.rb b/test/net/imap/fake_server/command_reader.rb index 03d1da50..64410f42 100644 --- a/test/net/imap/fake_server/command_reader.rb +++ b/test/net/imap/fake_server/command_reader.rb @@ -3,7 +3,7 @@ require "net/imap" class Net::IMAP::FakeServer - CommandParseError = RuntimeError + CommandParseError = Class.new(RuntimeError) class CommandReader attr_reader :last_command @@ -46,7 +46,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 From f0aa671da27e32b03937d634b12136cab32d572b Mon Sep 17 00:00:00 2001 From: nick evans Date: Mon, 11 May 2026 11:52:27 -0400 Subject: [PATCH 6/9] =?UTF-8?q?=F0=9F=8D=92=20pick=20ef6fde3b=20(#678):=20?= =?UTF-8?q?=E2=9C=85=20Handle=20"stream=20closed"=20as=20EOF=20(tests=20on?= =?UTF-8?q?ly)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- test/net/imap/fake_server/socket.rb | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/test/net/imap/fake_server/socket.rb b/test/net/imap/fake_server/socket.rb index 65593d86..84e94d58 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 From 8d84530f7675de54cb27accdd491235b7abb137a Mon Sep 17 00:00:00 2001 From: nick evans Date: Mon, 11 May 2026 11:56:11 -0400 Subject: [PATCH 7/9] =?UTF-8?q?=F0=9F=8D=92=20pick=20a27a0022=20(#678):=20?= =?UTF-8?q?=E2=9C=85=20Allow=20test=20server=20td=20ignore=20abrupt=20EOF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This lets us test some specific error conditions, without failing because the server is upset. --- test/net/imap/fake_server/command_reader.rb | 11 ++++++++--- test/net/imap/fake_server/configuration.rb | 3 +++ test/net/imap/fake_server/connection.rb | 2 +- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/test/net/imap/fake_server/command_reader.rb b/test/net/imap/fake_server/command_reader.rb index 64410f42..8857cd9b 100644 --- a/test/net/imap/fake_server/command_reader.rb +++ b/test/net/imap/fake_server/command_reader.rb @@ -6,10 +6,12 @@ class Net::IMAP::FakeServer 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 diff --git a/test/net/imap/fake_server/configuration.rb b/test/net/imap/fake_server/configuration.rb index 91fe72a4..fde14aa7 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 7be9902b..bd922577 100644 --- a/test/net/imap/fake_server/connection.rb +++ b/test/net/imap/fake_server/connection.rb @@ -12,7 +12,7 @@ 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 From b437552d0029594feea90a74cb94a74c168ebbf4 Mon Sep 17 00:00:00 2001 From: nick evans Date: Fri, 1 May 2026 14:41:33 -0400 Subject: [PATCH 8/9] =?UTF-8?q?=F0=9F=8D=92=20pick=2095afda8f=20(#679):=20?= =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Allow=20RawData.new=20to=20directly=20set?= =?UTF-8?q?=20parts=20array?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It's convenient that `RawData` can take a string, even though it's composed of parts. But it's surprising, IMO, to _require_ the `data` parameter be a string, when the `data` member is an array. --- lib/net/imap/command_data.rb | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/net/imap/command_data.rb b/lib/net/imap/command_data.rb index 5994b642..09add29b 100644 --- a/lib/net/imap/command_data.rb +++ b/lib/net/imap/command_data.rb @@ -192,7 +192,12 @@ def send_data(imap, tag) = imap.__send__(:put_string, data) class RawData < CommandData # :nodoc: def initialize(data:) - data = split_parts(data) + case data + in String then data = split_parts(data) + in Array if data.all? { _1 in RawText | Literal } + else + raise TypeError, "expected String or Array[#{RawText} | #{Literal}]" + end super validate end From 2876987a98a8f6c2b8021e6ce4df4bffdb3442dd Mon Sep 17 00:00:00 2001 From: nick evans Date: Fri, 1 May 2026 18:07:31 -0400 Subject: [PATCH 9/9] =?UTF-8?q?=F0=9F=8D=92=20pick=20257e51d0=20(#679):=20?= =?UTF-8?q?=E2=99=BB=EF=B8=8F=20Extract=20RawData.split(string)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/net/imap/command_data.rb | 13 ++++++++----- test/net/imap/test_command_data.rb | 17 +++++++++++++++++ 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/lib/net/imap/command_data.rb b/lib/net/imap/command_data.rb index 09add29b..09b7d939 100644 --- a/lib/net/imap/command_data.rb +++ b/lib/net/imap/command_data.rb @@ -193,7 +193,7 @@ def send_data(imap, tag) = imap.__send__(:put_string, data) class RawData < CommandData # :nodoc: def initialize(data:) case data - in String then data = split_parts(data) + in String then data = self.class.split(data) in Array if data.all? { _1 in RawText | Literal } else raise TypeError, "expected String or Array[#{RawText} | #{Literal}]" @@ -211,9 +211,11 @@ def validate 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) @@ -227,7 +229,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] @@ -235,6 +237,7 @@ def extract_literal(data, binary:, bytesize:, non_sync:) literal = data.byteslice(0, bytesize) (binary ? Literal8 : Literal).new(data: literal, non_sync:) end + private_class_method :extract_literal end class Atom < CommandData # :nodoc: diff --git a/test/net/imap/test_command_data.rb b/test/net/imap/test_command_data.rb index 3ba579f3..042cdf37 100644 --- a/test/net/imap/test_command_data.rb +++ b/test/net/imap/test_command_data.rb @@ -356,6 +356,23 @@ class RawDataTest < CommandDataTest raw = RawData.new(data: " {123} ") assert_equal [RawText[" {123} "]], 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 end