Skip to content

Commit b806b0f

Browse files
authored
🔀 Merge pull request #696 from ruby/backport/v0.5-multiple
🍒 Backports #675, #676, #677, #678, #679, #681 to v0.5
2 parents 89e31ba + 6bc8761 commit b806b0f

12 files changed

Lines changed: 170 additions & 51 deletions

File tree

lib/net/imap.rb

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2209,6 +2209,7 @@ def uid_expunge(uid_set)
22092209
# provided as an array or a string.
22102210
# See {"Argument translation"}[rdoc-ref:#search@Argument+translation]
22112211
# and {"Search criteria"}[rdoc-ref:#search@Search+criteria], below.
2212+
# <em>Please note</em> the warning for when +criteria+ is a String.
22122213
#
22132214
# +return+ options control what kind of information is returned about
22142215
# messages matching the search +criteria+. Specifying +return+ should force
@@ -2619,7 +2620,8 @@ def search(...)
26192620
# backward compatibility) but adds SearchResult#modseq when the +CONDSTORE+
26202621
# capability has been enabled.
26212622
#
2622-
# See #search for documentation of parameters.
2623+
# See #search for documentation of parameters. <em>Please note</em> the
2624+
# warning for when +criteria+ is a String.
26232625
#
26242626
# ==== Capabilities
26252627
#
@@ -2705,7 +2707,8 @@ def fetch(...)
27052707
# {SequenceSet[...]}[rdoc-ref:SequenceSet@Creating+sequence+sets].
27062708
# (For message sequence numbers, use #fetch instead.)
27072709
#
2708-
# +attr+ behaves the same as with #fetch.
2710+
# +attr+ behaves the same as with #fetch. <em>Please note</em> the #fetch
2711+
# warning on the +attr+ argument.
27092712
# >>>
27102713
# *Note:* Servers _MUST_ implicitly include the +UID+ message data item as
27112714
# part of any +FETCH+ response caused by a +UID+ command, regardless of
@@ -2917,8 +2920,10 @@ def uid_move(set, mailbox)
29172920

29182921
# Sends a {SORT command [RFC5256 §3]}[https://www.rfc-editor.org/rfc/rfc5256#section-3]
29192922
# to search a mailbox for messages that match +search_keys+ and return an
2920-
# array of message sequence numbers, sorted by +sort_keys+. +search_keys+
2921-
# are interpreted the same as for #search.
2923+
# array of message sequence numbers, sorted by +sort_keys+.
2924+
#
2925+
# +search_keys+ are interpreted the same as the +criteria+ argument for
2926+
# #search. <em>Please note</em> the #search warning for String +criteria+.
29222927
#
29232928
#--
29242929
# TODO: describe +sort_keys+
@@ -2943,8 +2948,10 @@ def sort(sort_keys, search_keys, charset)
29432948

29442949
# Sends a {UID SORT command [RFC5256 §3]}[https://www.rfc-editor.org/rfc/rfc5256#section-3]
29452950
# to search a mailbox for messages that match +search_keys+ and return an
2946-
# array of unique identifiers, sorted by +sort_keys+. +search_keys+ are
2947-
# interpreted the same as for #search.
2951+
# array of unique identifiers, sorted by +sort_keys+.
2952+
#
2953+
# +search_keys+ are interpreted the same as the +criteria+ argument for
2954+
# #search. <em>Please note</em> the #search warning for String +criteria+.
29482955
#
29492956
# Related: #sort, #search, #uid_search, #thread, #uid_thread
29502957
#
@@ -2958,8 +2965,10 @@ def uid_sort(sort_keys, search_keys, charset)
29582965

29592966
# Sends a {THREAD command [RFC5256 §3]}[https://www.rfc-editor.org/rfc/rfc5256#section-3]
29602967
# to search a mailbox and return message sequence numbers in threaded
2961-
# format, as a ThreadMember tree. +search_keys+ are interpreted the same as
2962-
# for #search.
2968+
# format, as a ThreadMember tree.
2969+
#
2970+
# +search_keys+ are interpreted the same as the +criteria+ argument for
2971+
# #search. <em>Please note</em> the #search warning for String +criteria+.
29632972
#
29642973
# The supported algorithms are:
29652974
#
@@ -2985,6 +2994,9 @@ def thread(algorithm, search_keys, charset)
29852994
# Similar to #thread, but returns unique identifiers instead of
29862995
# message sequence numbers.
29872996
#
2997+
# +search_keys+ are interpreted the same as the +criteria+ argument for
2998+
# #search. <em>Please note</em> the #search warning for String +criteria+.
2999+
#
29883000
# Related: #thread, #search, #uid_search, #sort, #uid_sort
29893001
#
29903002
# ==== Capabilities

lib/net/imap/command_data.rb

Lines changed: 23 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,15 @@ def validate_data(data)
1717
when nil
1818
when String
1919
when Integer
20-
NumValidator.ensure_number(data)
20+
# Covers modseq-valzer, which is the largest valid IMAP integer
21+
if data.negative?
22+
raise DataFormatError, "Integer argument must be unsigned: #{data}"
23+
elsif 0xffff_ffff_ffff_ffff < data
24+
raise DataFormatError, "Integer argument must fit in 64 bits: #{data}"
25+
end
2126
when Array
22-
if data[0] == 'CHANGEDSINCE'
23-
NumValidator.ensure_mod_sequence_value(data[1])
24-
else
25-
data.each do |i|
26-
validate_data(i)
27-
end
27+
data.each do |i|
28+
validate_data(i)
2829
end
2930
when Time, Date, DateTime
3031
when Symbol
@@ -112,8 +113,9 @@ def send_literal(str, tag = nil, binary: false, non_sync: nil)
112113
end
113114
end
114115

116+
# NOTE: +num+ should already be an Integer
115117
def send_number_data(num)
116-
put_string(num.to_s)
118+
put_string(Integer(num).to_s)
117119
end
118120

119121
def send_list_data(list, tag = nil)
@@ -190,7 +192,12 @@ def send_data(imap, tag) = imap.__send__(:put_string, data)
190192

191193
class RawData < CommandData # :nodoc:
192194
def initialize(data:)
193-
data = split_parts(data)
195+
case data
196+
in String then data = self.class.split(data)
197+
in Array if data.all? { _1 in RawText | Literal }
198+
else
199+
raise TypeError, "expected String or Array[#{RawText} | #{Literal}]"
200+
end
194201
super
195202
validate
196203
end
@@ -204,9 +211,11 @@ def validate
204211
end
205212
end
206213

207-
private
208-
209-
def split_parts(data)
214+
# Splits an input +string+ into an array of RawText and Literal/Literal8.
215+
#
216+
# NOTE: unlike RawData#validate, this does not prevent the final RawText
217+
# from ending with a literal prefix.
218+
def self.split(data)
210219
data = data.b # dups and ensures BINARY encoding
211220
parts = []
212221
while data.match(/(~)?\{(0|[1-9]\d*)(\+)?\}\r\n/n)
@@ -220,14 +229,15 @@ def split_parts(data)
220229
parts
221230
end
222231

223-
def extract_literal(data, binary:, bytesize:, non_sync:)
232+
def self.extract_literal(data, binary:, bytesize:, non_sync:)
224233
if data.bytesize < bytesize
225234
raise DataFormatError, "Too few bytes in string for literal, " \
226235
"expected: %s, remaining: %s" % [bytesize, data.bytesize]
227236
end
228237
literal = data.byteslice(0, bytesize)
229238
(binary ? Literal8 : Literal).new(data: literal, non_sync:)
230239
end
240+
private_class_method :extract_literal
231241
end
232242

233243
class Atom < CommandData # :nodoc:

lib/net/imap/response_parser.rb

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2189,10 +2189,7 @@ def next_token
21892189
if $1
21902190
return Token.new(T_SPACE, $+)
21912191
elsif $2
2192-
len = $+.to_i
2193-
val = @str[@pos, len]
2194-
@pos += len
2195-
return Token.new(T_LITERAL8, val)
2192+
literal_token($+, T_LITERAL8)
21962193
elsif $3 && $7
21972194
# greedily match ATOM, prefixed with NUMBER, NIL, or PLUS.
21982195
return Token.new(T_ATOM, $3)
@@ -2220,10 +2217,7 @@ def next_token
22202217
elsif $15
22212218
return Token.new(T_RBRA, $+)
22222219
elsif $16
2223-
len = $+.to_i
2224-
val = @str[@pos, len]
2225-
@pos += len
2226-
return Token.new(T_LITERAL, val)
2220+
literal_token($+)
22272221
elsif $17
22282222
return Token.new(T_PERCENT, $+)
22292223
elsif $18
@@ -2249,10 +2243,7 @@ def next_token
22492243
elsif $4
22502244
return Token.new(T_QUOTED, Patterns.unescape_quoted($+))
22512245
elsif $5
2252-
len = $+.to_i
2253-
val = @str[@pos, len]
2254-
@pos += len
2255-
return Token.new(T_LITERAL, val)
2246+
literal_token($+)
22562247
elsif $6
22572248
return Token.new(T_LPAR, $+)
22582249
elsif $7
@@ -2267,6 +2258,23 @@ def next_token
22672258
else
22682259
parse_error("invalid @lex_state - %s", @lex_state.inspect)
22692260
end
2261+
rescue DataFormatError => error
2262+
parse_error error.message
2263+
end
2264+
2265+
def literal_token(len, type = T_LITERAL)
2266+
len = coerce_number64 len.to_i
2267+
val = @str[@pos, len]
2268+
@pos += len
2269+
Token.new(type, val)
2270+
end
2271+
2272+
# copied/adapted from NumValidator in v0.6
2273+
def coerce_number64(num)
2274+
int = num.to_i
2275+
return int if 0 <= int && int <= 0x7fff_ffff_ffff_ffff
2276+
raise DataFormatError,
2277+
"number64 must be unsigned 63-bit integer: #{num}"
22702278
end
22712279

22722280
end

lib/net/imap/response_reader.rb

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ module Net
44
class IMAP
55
# See https://www.rfc-editor.org/rfc/rfc9051#section-2.2.2
66
class ResponseReader # :nodoc:
7+
include NumValidator
8+
79
attr_reader :client
810

911
def initialize(client, sock)
@@ -35,7 +37,10 @@ def done? = line_done? && !literal_size
3537
def line_done? = buff.end_with?(CRLF)
3638

3739
def get_literal_size(buff)
38-
buff.end_with?("}\r\n") && buff.rindex(/\{(\d+)\}\r\n\z/n) && $1.to_i
40+
buff.end_with?("}\r\n") && buff.rindex(/\{(\d+)\}\r\n\z/n) &&
41+
coerce_number64($1)
42+
rescue DataFormatError
43+
raise DataFormatError, format("invalid response literal size (%s)", $1)
3944
end
4045

4146
def read_line
@@ -74,6 +79,14 @@ def max_response_remaining!
7479
)
7580
end
7681

82+
# copied/adapted from NumValidator in v0.6
83+
def coerce_number64(num)
84+
int = num.to_i
85+
return int if 0 <= int && int <= 0x7fff_ffff_ffff_ffff
86+
raise DataFormatError,
87+
"number64 must be unsigned 63-bit integer: #{num}"
88+
end
89+
7790
end
7891
end
7992
end

test/net/imap/fake_server/command_reader.rb

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33
require "net/imap"
44

55
class Net::IMAP::FakeServer
6-
CommandParseError = RuntimeError
6+
CommandParseError = Class.new(RuntimeError)
77

88
class CommandReader
9+
attr_reader :config
910
attr_reader :last_command
1011
attr_accessor :literal_acceptor
1112

12-
def initialize(socket)
13+
def initialize(socket, config:)
14+
@config = config
1315
@socket = socket
1416
@last_command = nil
1517
@literal_acceptor = proc {|buff, size| true }
@@ -35,8 +37,11 @@ def get_command
3537
end
3638
throw :eof if buf.empty?
3739
@last_command = parse(buf)
38-
rescue CommandParseError => err
39-
raise IOError, err.message if socket.eof? && !buf.end_with?("\r\n")
40+
rescue CommandParseError
41+
if config.ignore_abrupt_eof? && socket.eof? && !buf.end_with?("\r\n")
42+
throw :eof
43+
end
44+
raise
4045
end
4146

4247
private
@@ -46,7 +51,7 @@ def get_command
4651
# TODO: convert bad command exception to tagged BAD response, when possible
4752
def parse(buf)
4853
/\A([^ ]+) ((?:UID )?\w+)(?: (.+))?\r\n\z/min =~ buf or
49-
raise CommandParseError, "bad request: %p" [buf]
54+
raise CommandParseError, "bad request: %p" % [buf]
5055
case $2.upcase
5156
when "LOGIN", "SELECT", "EXAMINE", "ENABLE", "AUTHENTICATE"
5257
Command.new $1, $2, scan_astrings($3), buf

test/net/imap/fake_server/configuration.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ class Configuration
4545
mailboxes: {
4646
"INBOX" => { name: "INBOX" }.freeze,
4747
}.freeze,
48+
49+
ignore_abrupt_eof: false,
4850
}
4951

5052
def initialize(with_extensions: [], without_extensions: [], **opts, &block)
@@ -68,6 +70,7 @@ def initialize(with_extensions: [], without_extensions: [], **opts, &block)
6870
alias greeting_bye? greeting_bye
6971
alias greeting_capabilities? greeting_capabilities
7072
alias sasl_ir? sasl_ir
73+
alias ignore_abrupt_eof? ignore_abrupt_eof
7174

7275
def on(event, &handler)
7376
handler or raise ArgumentError

test/net/imap/fake_server/connection.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ def initialize(server, tcp_socket:)
1212
@config = server.config
1313
@socket = Socket.new tcp_socket, config: config
1414
@state = ConnectionState.new socket: socket, config: config
15-
@reader = CommandReader.new socket
15+
@reader = CommandReader.new socket, config: config
1616
@writer = ResponseWriter.new socket, config: config, state: state
1717
@router = CommandRouter.new writer, config: config, state: state
1818
@mutex = Thread::Mutex.new

test/net/imap/fake_server/socket.rb

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,10 @@ def initialize(tcp_socket, config:)
1818
def tls?; !!@tls_socket end
1919
def closed?; @closed end
2020

21-
def eof?; socket.eof? end
22-
def gets(...) socket.gets(...) end
23-
def read(...) socket.read(...) end
24-
def print(...) socket.print(...) end
21+
def eof?; ignore_closed?(true) { socket.eof? } end
22+
def gets(...) ignore_closed?(nil) { socket.gets(...) } end
23+
def read(...) ignore_closed?(nil) { socket.read(...) } end
24+
def print(...) ignore_closed?(nil) { socket.print(...) } end
2525

2626
def use_tls
2727
@tls_socket ||= OpenSSL::SSL::SSLSocket.new(tcp_socket, ssl_ctx).tap do |s|
@@ -48,5 +48,13 @@ def ssl_ctx
4848
end
4949
end
5050

51+
def ignore_closed?(fallback)
52+
yield
53+
rescue IOError => err
54+
close if !closed? && (@tcp_socket.closed? || @tls_socket.closed?)
55+
return fallback if err.message.match?(/stream closed|closed stream/i)
56+
raise
57+
end
58+
5159
end
5260
end

test/net/imap/fixtures/response_parser/quirky_behaviors.yml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,22 @@
77
data:
88
raw_data: "* NOOP\r\n"
99

10+
"literal numeric formatted with zero-prefix":
11+
:response: "* 20367 FETCH (BODY[HEADER.FIELDS (Foo)] {012}\r\nFoo: bar\r\n\r\n)\r\n"
12+
:expected: !ruby/struct:Net::IMAP::UntaggedResponse
13+
name: FETCH
14+
data: !ruby/struct:Net::IMAP::FetchData
15+
seqno: 20367
16+
attr:
17+
BODY[HEADER.FIELDS (Foo)]: "Foo: bar\r\n\r\n"
18+
raw_data: "* 20367 FETCH (BODY[HEADER.FIELDS (Foo)] {012}\r\nFoo: bar\r\n\r\n)\r\n"
19+
20+
"invalid literal numeric format (too large)":
21+
:test_type: :assert_parse_failure
22+
:message: "number64 must be unsigned 63-bit integer: 99999999999999999999"
23+
:response:
24+
"* 20367 FETCH (BODY[] {99999999999999999999}\r\nwon't parse this)\r\n"
25+
1026
test_invalid_noop_response_with_unparseable_data:
1127
:response: "* NOOP froopy snood\r\n"
1228
:expected: !ruby/struct:Net::IMAP::IgnoredResponse

test/net/imap/test_command_data.rb

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,23 @@ class RawDataTest < CommandDataTest
356356
raw = RawData.new(data: " {123} ")
357357
assert_equal [RawText[" {123} "]], raw.data
358358
end
359+
360+
data(
361+
"simple raw text" => 'hello "world"',
362+
"text, literal, text" => "OK {5}\r\nhello {5}\r\nworld",
363+
"empty literals" => "{0}\r\n{0+}\r\n~{0}\r\n~{0+}\r\n",
364+
"binary and regular" => "foo ~{7}\r\n\0bar\r\nbaz {4}\r\nquux",
365+
)
366+
test ".split" do |string|
367+
assert_equal(RawData[string].data, RawData.split(string))
368+
end
369+
370+
test ".split allows final literal prefix" do
371+
assert_equal [RawText["text {123}"]], RawData.split("text {123}")
372+
assert_equal [RawText["text+ {123+}"]], RawData.split("text+ {123+}")
373+
assert_equal [RawText["~text ~{123}"]], RawData.split("~text ~{123}")
374+
assert_equal [RawText["~text+ ~{123+}"]], RawData.split("~text+ ~{123+}")
375+
end
359376
end
360377

361378
end

0 commit comments

Comments
 (0)