net-imap 0.6.4 → 0.6.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ffcec5e3cc42d7f72c63520110ec442f760c35472401de495001f420cfa059c5
4
- data.tar.gz: aedd703997fc651cb6c67635a3d20a4159720c6e1bda41c07322f2bd67a627d6
3
+ metadata.gz: cd2bbaf7d6564739894405f3066c772976b48062ea2c696b741d10aa34ebbed6
4
+ data.tar.gz: 1fa2b00d9869e60e44be3a3aea1e058f0974705aef5a41a62f4ea25ee4fda48d
5
5
  SHA512:
6
- metadata.gz: 1fa95302f29607d152b991535a9d14cda5dae1a1fce06b1d831dd2b09f69b2fd845367e0ebed73dd4e79d04c064a801e59865e76947060a4811304728458f3c7
7
- data.tar.gz: 2dc9a56758d6f370affc9540a8ca26b8ad866ca5ef0e5b9bc2c30ff6257e3461e5ed23173e1c5e98a2e95c1e0f0a844c96f9e34d41151e9196ab513ac6987d33
6
+ metadata.gz: dc1083920d0f196a21db61ef8282be1aa214e43b38ba18bf5ada58fa16d9b007f2169332fc0df2e5799e2ec1e76f15b1469d0c2401e851feb0409237b7f60ee2
7
+ data.tar.gz: 4b9c9ba8892b67ec86c39f4ad3f28c5dd91ad785def8b9324175a0f6bac6940dffde3499135f045a911dde98646afff687c2bd6381117f14bdf68de244f03dea
data/.simplecov ADDED
@@ -0,0 +1,39 @@
1
+ # frozen_string_literal: true
2
+
3
+ SimpleCov.configure do
4
+ formatter SimpleCov::Formatter::HTMLFormatter
5
+
6
+ enable_coverage :branch
7
+ enable_coverage :method
8
+ enable_coverage :eval
9
+
10
+ skip "/test/"
11
+ skip "/rakelib/"
12
+ cover "lib/**/*.rb"
13
+
14
+ group "Parser", %w[lib/net/imap/response_parser.rb
15
+ lib/net/imap/response_parser]
16
+
17
+ group "Client", %w[lib/net/imap.rb
18
+ lib/net/imap/connection_state.rb
19
+ lib/net/imap/deprecated_client_options.rb
20
+ lib/net/imap/errors.rb
21
+ lib/net/imap/response_reader.rb]
22
+
23
+ group "Config", %w[lib/net/imap/config.rb
24
+ lib/net/imap/config]
25
+
26
+ group "Data Types", %w[lib/net/imap/flags.rb
27
+ lib/net/imap/sequence_set.rb] +
28
+ [%r{lib/net/imap/\w*_data\.rb},
29
+ %r{lib/net/imap/\w*_result.rb},
30
+ %r{lib/net/imap/data_\w+\.rb}]
31
+
32
+ group "SASL", %w[lib/net/imap/sasl.rb
33
+ lib/net/imap/sasl
34
+ lib/net/imap/sasl_adapter.rb
35
+ lib/net/imap/authenticators.rb]
36
+
37
+ group "StringPrep", %w[lib/net/imap/stringprep.rb
38
+ lib/net/imap/stringprep]
39
+ end
data/Gemfile CHANGED
@@ -20,7 +20,5 @@ gem "benchmark-driver", require: false
20
20
  gem "vernier", require: false, platform: :mri
21
21
 
22
22
  group :test do
23
- gem "simplecov", require: false, platforms: %i[mri windows]
24
- gem "simplecov-html", require: false, platforms: %i[mri windows]
25
- gem "simplecov-json", require: false, platforms: %i[mri windows]
23
+ gem "simplecov", ">= 1.0.0", require: false, platforms: %i[mri windows]
26
24
  end
data/Rakefile CHANGED
@@ -11,3 +11,14 @@ Rake::TestTask.new(:test) do |t|
11
11
  end
12
12
 
13
13
  task :default => :test
14
+
15
+ desc "Output coverage data report, and error when threshholds aren't met"
16
+ task "coverage:report" do
17
+ require "simplecov"
18
+ SimpleCov.collate "coverage/.resultset.json" do
19
+ coverage(:line) do
20
+ minimum 90
21
+ minimum_per_file 40
22
+ end
23
+ end
24
+ end
@@ -15,15 +15,19 @@ module Net
15
15
  case data
16
16
  when nil
17
17
  when String
18
+ if data.include?("\0")
19
+ raise DataFormatError, "String argument contains NULL byte"
20
+ end
18
21
  when Integer
19
- NumValidator.ensure_number(data)
22
+ # Covers modseq-valzer, which is the largest valid IMAP integer
23
+ if data.negative?
24
+ raise DataFormatError, "Integer argument must be unsigned: #{data}"
25
+ elsif 0xffff_ffff_ffff_ffff < data
26
+ raise DataFormatError, "Integer argument must fit in 64 bits: #{data}"
27
+ end
20
28
  when Array
21
- if data[0] == 'CHANGEDSINCE'
22
- NumValidator.ensure_mod_sequence_value(data[1])
23
- else
24
- data.each do |i|
25
- validate_data(i)
26
- end
29
+ data.each do |i|
30
+ validate_data(i)
27
31
  end
28
32
  when Time, Date, DateTime
29
33
  when Symbol
@@ -54,45 +58,62 @@ module Net
54
58
  end
55
59
  end
56
60
 
61
+ UNQUOTABLE_CHARS = /\0\r\n/
62
+ ASTRING_SPECIALS = /[(){ \x00-\x1f\x7f%*"\\]/
63
+ private_constant :UNQUOTABLE_CHARS, :ASTRING_SPECIALS
64
+
65
+ # Sends generic strings formatted as +astring+:
66
+ # * as an atom (note: this is based on +ASTRING-CHAR+, not +ATOM-CHAR+)
67
+ # * as a quoted string
68
+ # * as a literal (may send non-synchronizing)
69
+ #
70
+ # NOTE: This does not validate that +str+ contains no +NULL+ bytes.
57
71
  def send_string_data(str, tag = nil)
58
72
  if str.empty?
73
+ # same as send_quoted_string(str), but incompatible encoding is allowed
59
74
  put_string('""')
60
- elsif str.match?(/[\r\n]/n)
61
- # literal, because multiline
62
- send_literal(str, tag)
63
- elsif !str.ascii_only?
64
- if @utf8_strings
65
- # quoted string
66
- send_quoted_string(str)
67
- else
68
- # literal, because of non-ASCII bytes
69
- send_literal(str, tag)
70
- end
71
- elsif str.match?(/[(){ \x00-\x1f\x7f%*"\\]/n)
72
- # quoted string
75
+ elsif str.ascii_only? && !str.match?(ASTRING_SPECIALS)
76
+ # valid +astring+ atom: non-empty, ASCII only, no ASTRING_SPECIALS
77
+ put_string(str)
78
+ elsif text_encodable?(str) && !str.match?(UNQUOTABLE_CHARS)
73
79
  send_quoted_string(str)
74
80
  else
75
- put_string(str)
81
+ send_literal(str, tag)
76
82
  end
77
83
  end
78
84
 
79
- def send_quoted_string(str)
80
- put_string('"' + str.gsub(/["\\]/, "\\\\\\&") + '"')
85
+ # Encodable as +text+ (which is a superset of +quoted+):
86
+ # * ASCII only (for any ASCII compatible encoding)
87
+ # * or valid UTF-8 (when the connection supports it)
88
+ def text_encodable?(str)
89
+ str.ascii_only? || (utf8_enabled? &&
90
+ str.encoding == Encoding::UTF_8 &&
91
+ str.valid_encoding?)
81
92
  end
82
93
 
94
+ def send_quoted_string(str) = QuotedString.new(data: str).send_data(self)
95
+
83
96
  def send_binary_literal(*, **) = send_literal(*, **, binary: true)
84
97
 
85
98
  # `non_sync` is an optional tri-state flag:
86
99
  # * `true` -> Force non-synchronizing `LITERAL+`/`LITERAL-` behavior.
87
- # TODO: raise or warn when capabilities don't allow non_sync.
100
+ # NOTE: raises DataFormatError when server doesn't support
101
+ # non-synchronizing literal, or literal is too large for LITERAL-.
88
102
  # * `false` -> Force normal synchronizing literal behavior.
89
103
  # * `nil` -> (default) Currently behaves like `false` (will be dynamic).
90
104
  def send_literal(str, tag = nil, binary: false, non_sync: nil)
105
+ bytesize = str.bytesize
91
106
  synchronize do
92
- non_sync = non_sync_literal?(str.bytesize) if non_sync.nil?
107
+ if non_sync && !non_sync_literal_allowed?(bytesize)
108
+ # TODO: check in Printer, so we don't need to close the connection.
109
+ @sock.close
110
+ raise DataFormatError, "Connection closed: " \
111
+ "Cannot send non-synchronizing literal without known server support"
112
+ end
113
+ non_sync = non_sync_literal?(bytesize) if non_sync.nil?
93
114
  prefix = "~" if binary
94
115
  plus = "+" if non_sync
95
- put_string("#{prefix}{#{str.bytesize}#{plus}}\r\n")
116
+ put_string("#{prefix}{#{bytesize}#{plus}}\r\n")
96
117
  if non_sync
97
118
  put_string(str)
98
119
  return
@@ -101,8 +122,7 @@ module Net
101
122
  @continuation_request_exception = nil
102
123
  begin
103
124
  @continuation_request_arrival.wait
104
- e = @continuation_request_exception || @exception
105
- raise e if e
125
+ reraise @continuation_request_exception || @exception
106
126
  put_string(str)
107
127
  ensure
108
128
  @continued_command_tag = nil
@@ -112,14 +132,22 @@ module Net
112
132
  end
113
133
 
114
134
  def non_sync_literal?(bytesize)
115
- capabilities_cached? &&
116
- bytesize <= config.max_non_synchronizing_literal &&
117
- (capable?("LITERAL+") ||
118
- bytesize <= 4096 && (capable?("IMAP4rev2") || capable?("LITERAL-")))
135
+ bytesize <= config.max_non_synchronizing_literal \
136
+ && non_sync_literal_allowed?(bytesize)
137
+ end
138
+
139
+ def non_sync_literal_allowed?(bytesize)
140
+ return unless capabilities_cached?
141
+ return "+" if capable?("LITERAL+")
142
+ return "-" if capable_literal_minus? && bytesize <= 4096
143
+ false
119
144
  end
120
145
 
146
+ def capable_literal_minus? = capable?("LITERAL-") || capable?("IMAP4rev2")
147
+
148
+ # NOTE: +num+ should already be an Integer
121
149
  def send_number_data(num)
122
- put_string(num.to_s)
150
+ put_string(Integer(num).to_s)
123
151
  end
124
152
 
125
153
  def send_list_data(list, tag = nil)
@@ -139,6 +167,9 @@ module Net
139
167
  def send_date_data(date) put_string Net::IMAP.encode_date(date) end
140
168
  def send_time_data(time) put_string Net::IMAP.encode_time(time) end
141
169
 
170
+ # NOTE: Currently for internal use only. The API is expected to change.
171
+ Command = Data.define(:tag, :name) # :nodoc:
172
+
142
173
  CommandData = Data.define(:data) do # :nodoc:
143
174
  def self.validate(...)
144
175
  data = new(...)
@@ -154,36 +185,38 @@ module Net
154
185
  end
155
186
  end
156
187
 
157
- # Represents IMAP +text+ data, which may contain any 7-bit ASCII character,
158
- # except for +NULL+, +CR+, or +LF+. +text+ is extended to allow any
159
- # multibyte +UTF-8+ character when either +UTF8=ACCEPT+ or +IMAP4rev2+ have
160
- # been enabled, or when the server supports only +IMAP4rev2+ and not earlier
161
- # IMAP revisions, or when the server advertises +UTF8=ONLY+.
188
+ # Represents IMAP +text+ or +quoted+ data, which share the same
189
+ # validations of decoded #data, and differ only in how they are formatted.
190
+ #
191
+ # +data+ may contain any 7-bit ASCII character except +NULL+, +CR+, or +LF+.
192
+ # Any multibyte +UTF-8+ character is also allowed when the connection
193
+ # supports UTF8: either +UTF8=ACCEPT+ or +IMAP4rev2+ have been enabled, or
194
+ # the server supports only +IMAP4rev2+ and not earlier IMAP revisions, or
195
+ # the server advertises +UTF8=ONLY+.
162
196
  #
163
- # NOTE: The current implementation does not validate whether the connection
164
- # currently supports UTF-8. Future versions may change.
197
+ # NOTE: This does not verify whether the connection supports UTF-8, but that
198
+ # may change in future versions.
165
199
  #
166
200
  # The string's bytes must be valid ASCII or valid UTF-8. The string's
167
201
  # reported encoding is ignored, but the string is _not_ transcoded.
168
- class RawText < CommandData # :nodoc:
202
+ class ValidNonLiteralData < CommandData
169
203
  def initialize(data:)
170
204
  data = String(data.to_str)
171
- data = if data.encoding in Encoding::ASCII | Encoding::UTF_8
172
- -data
173
- elsif data.ascii_only?
174
- -(data.dup.force_encoding("ASCII"))
175
- else
176
- -(data.dup.force_encoding("UTF-8"))
205
+ unless data.encoding in Encoding::ASCII | Encoding::UTF_8
206
+ data = data.dup.force_encoding(data.ascii_only? ? "ASCII" : "UTF-8")
177
207
  end
208
+ data = -data
178
209
  super
179
210
  validate
180
211
  end
181
212
 
182
213
  def validate
183
- if data.include?("\0")
184
- raise DataFormatError, "NULL byte must be binary literal encoded"
214
+ if !(data.encoding in Encoding::ASCII | Encoding::UTF_8)
215
+ raise DataFormatError, "must use ASCII or UTF-8 encoding"
185
216
  elsif !data.valid_encoding?
186
217
  raise DataFormatError, "invalid UTF-8 must be literal encoded"
218
+ elsif data.include?("\0")
219
+ raise DataFormatError, "NULL byte must be binary literal encoded"
187
220
  elsif /[\r\n]/.match?(data)
188
221
  raise DataFormatError, "CR and LF bytes must be literal encoded"
189
222
  end
@@ -191,12 +224,27 @@ module Net
191
224
 
192
225
  def ascii_only? = data.ascii_only?
193
226
 
194
- def send_data(imap, tag) = imap.__send__(:put_string, data)
227
+ def send_data(imap, tag = nil) = imap.__send__(:put_string, formatted)
228
+ end
229
+
230
+ # Represents IMAP +text+ data, which covers everything in the IMAP grammar,
231
+ # except for +literal+, +literal8+, and the concluding +CRLF+.
232
+ #
233
+ # NOTE: The current implementation does not verify that the connection
234
+ # supports UTF-8. Future versions may validate this.
235
+ class RawText < ValidNonLiteralData # :nodoc:
236
+ # raw: no formatting necessary
237
+ alias formatted data
195
238
  end
196
239
 
197
240
  class RawData < CommandData # :nodoc:
198
241
  def initialize(data:)
199
- data = split_parts(data)
242
+ case data
243
+ in String then data = self.class.split(data)
244
+ in Array if data.all? { _1 in RawText | Literal }
245
+ else
246
+ raise TypeError, "expected String or Array[#{RawText} | #{Literal}]"
247
+ end
200
248
  super
201
249
  validate
202
250
  end
@@ -205,14 +253,16 @@ module Net
205
253
 
206
254
  def validate
207
255
  return unless data.last in RawText(data: text)
208
- if text.rindex(/~?\{[1-9]\d*\+?\}\z/n)
256
+ if text.rindex(/\{\d+\+?\}\z/n)
209
257
  raise DataFormatError, "RawData cannot end with literal continuation"
210
258
  end
211
259
  end
212
260
 
213
- private
214
-
215
- def split_parts(data)
261
+ # Splits an input +string+ into an array of RawText and Literal/Literal8.
262
+ #
263
+ # NOTE: unlike RawData#validate, this does not prevent the final RawText
264
+ # from ending with a literal prefix.
265
+ def self.split(data)
216
266
  data = data.b # dups and ensures BINARY encoding
217
267
  parts = []
218
268
  while data.match(/(~)?\{(0|[1-9]\d*)(\+)?\}\r\n/n)
@@ -226,7 +276,7 @@ module Net
226
276
  parts
227
277
  end
228
278
 
229
- def extract_literal(data, binary:, bytesize:, non_sync:)
279
+ def self.extract_literal(data, binary:, bytesize:, non_sync:)
230
280
  if data.bytesize < bytesize
231
281
  raise DataFormatError, "Too few bytes in string for literal, " \
232
282
  "expected: %s, remaining: %s" % [bytesize, data.bytesize]
@@ -234,8 +284,20 @@ module Net
234
284
  literal = data.byteslice(0, bytesize)
235
285
  (binary ? Literal8 : Literal).new(data: literal, non_sync:)
236
286
  end
287
+ private_class_method :extract_literal
237
288
  end
238
289
 
290
+ # Please note that +ATOM-CHAR+ and +atom+ are not the same as +ASTRING-char+
291
+ # and the atom form of +astring+. +astring+ allows <tt>]</tt>, but +atom+
292
+ # does not.
293
+ #
294
+ # Generic string arguments in Net::IMAP use +astring+, so they will send as
295
+ # atoms even if they contain <tt>]</tt>.
296
+ #
297
+ # This class is used by:
298
+ # * to validate generic symbol arguments, as the superclass of Flag
299
+ # * to validate #enable +capabilities+
300
+ # * to validate #store (and #uid_store) +attr+
239
301
  class Atom < CommandData # :nodoc:
240
302
  def initialize(**)
241
303
  super
@@ -247,6 +309,8 @@ module Net
247
309
  or raise DataFormatError, "#{self.class} must be ASCII only"
248
310
  data.match?(ResponseParser::Patterns::ATOM_SPECIALS) \
249
311
  and raise DataFormatError, "#{self.class} must not contain atom-specials"
312
+ data.empty? \
313
+ and raise DataFormatError, "#{self.class} must not be empty"
250
314
  end
251
315
 
252
316
  def send_data(imap, tag)
@@ -260,10 +324,13 @@ module Net
260
324
  end
261
325
  end
262
326
 
263
- class QuotedString < CommandData # :nodoc:
264
- def send_data(imap, tag)
265
- imap.__send__(:send_quoted_string, data)
266
- end
327
+ # Represents a IMAP +quoted+ string, which can encode any valid ASCII or
328
+ # UTF-8 string, unless it contains any +CR+, +LF+, or +NULL+ bytes.
329
+ #
330
+ # NOTE: The current implementation does not verify that the connection
331
+ # supports UTF-8. Future versions may validate this.
332
+ class QuotedString < ValidNonLiteralData # :nodoc:
333
+ def formatted = %("#{data.gsub(/["\\]/, "\\\\\\&")}")
267
334
  end
268
335
 
269
336
  class Literal < Data.define(:data, :non_sync) # :nodoc:
@@ -310,7 +310,7 @@ module Net
310
310
  #
311
311
  # * original: +-1+ (_never_ send non-synchronizing literals)
312
312
  # * +0.6+: 16 KiB
313
- attr_accessor :max_non_synchronizing_literal, type: Integer?, defaults: {
313
+ attr_accessor :max_non_synchronizing_literal, type: Integer, defaults: {
314
314
  0.0r => -1,
315
315
  0.6r => 16 << 16, # 16 KiB
316
316
  }
@@ -155,7 +155,8 @@ module Net
155
155
 
156
156
  # Common validators of number and nz_number types
157
157
  module NumValidator # :nodoc
158
- NUMBER_RE = /\A(?:0|[1-9]\d*)\z/
158
+ NUMBER_RE = /\A\d+\z/
159
+ NZ_NUMBER_RE = /\A[1-9]\d*\z/
159
160
  module_function
160
161
 
161
162
  # Check if argument is a valid 'number' according to RFC 3501
@@ -251,7 +252,7 @@ module Net
251
252
  def coerce_number(num)
252
253
  case num
253
254
  when Integer then ensure_number num
254
- when NUMBER_RE then ensure_number Integer num
255
+ when NUMBER_RE then ensure_number num.to_i
255
256
  else
256
257
  raise DataFormatError, "%p is not a valid number" % [num]
257
258
  end
@@ -260,8 +261,8 @@ module Net
260
261
  # Like #ensure_nz_number, but usable with numeric String input.
261
262
  def coerce_nz_number(num)
262
263
  case num
263
- when Integer then ensure_nz_number num
264
- when NUMBER_RE then ensure_nz_number Integer num
264
+ when Integer then ensure_nz_number num
265
+ when NZ_NUMBER_RE then ensure_nz_number num.to_i
265
266
  else
266
267
  raise DataFormatError, "%p is not a valid nz-number" % [num]
267
268
  end
@@ -271,7 +272,7 @@ module Net
271
272
  def coerce_number64(num)
272
273
  case num
273
274
  when Integer then ensure_number64 num
274
- when NUMBER_RE then ensure_number64 Integer num
275
+ when NUMBER_RE then ensure_number64 num.to_i
275
276
  else
276
277
  raise DataFormatError, "%p is not a valid number64" % [num]
277
278
  end
@@ -280,8 +281,8 @@ module Net
280
281
  # Like #ensure_nz_number64, but usable with numeric String input.
281
282
  def coerce_nz_number64(num)
282
283
  case num
283
- when Integer then ensure_nz_number64 num
284
- when NUMBER_RE then ensure_nz_number64 Integer num
284
+ when Integer then ensure_nz_number64 num
285
+ when NZ_NUMBER_RE then ensure_nz_number64 num.to_i
285
286
  else
286
287
  raise DataFormatError, "%p is not a valid nz-number64" % [num]
287
288
  end
@@ -291,7 +292,7 @@ module Net
291
292
  def coerce_mod_sequence_value(num)
292
293
  case num
293
294
  when Integer then ensure_mod_sequence_value num
294
- when NUMBER_RE then ensure_mod_sequence_value Integer num
295
+ when NUMBER_RE then ensure_mod_sequence_value num.to_i
295
296
  else
296
297
  raise DataFormatError, "%p is not a valid mod-sequence-value" % [num]
297
298
  end
@@ -301,7 +302,7 @@ module Net
301
302
  def coerce_mod_sequence_valzer(num)
302
303
  case num
303
304
  when Integer then ensure_mod_sequence_valzer num
304
- when NUMBER_RE then ensure_mod_sequence_valzer Integer num
305
+ when NUMBER_RE then ensure_mod_sequence_valzer num.to_i
305
306
  else
306
307
  raise DataFormatError, "%p is not a valid mod-sequence-valzer" % [num]
307
308
  end
@@ -279,7 +279,11 @@ module Net
279
279
  #
280
280
  # This is different from UnknownResponseError: the response has been
281
281
  # rejected. Although it may be parsable, the server is forbidden from
282
- # sending it in the current context. The client should automatically
282
+ # sending it in the current context.
283
+ #
284
+ # This could be caused by a bug in the server or in Net::IMAP. Or it
285
+ # might indicate a malicious server, a man-in-the-middle attack, or
286
+ # client-side command injection. So the client should automatically
283
287
  # disconnect, abruptly (without logout).
284
288
  #
285
289
  # Note that InvalidResponseError does not inherit from ResponseError: it
@@ -288,6 +292,67 @@ module Net
288
292
  class InvalidResponseError < Error
289
293
  end
290
294
 
295
+ # Error raised when the server sends a tagged #response that is invalid for
296
+ # the #command #state.
297
+ #
298
+ # This could be caused by a bug in the server or in Net::IMAP. Or it
299
+ # might indicate a malicious server, a man-in-the-middle attack, or
300
+ # client-side command injection, so the client should disconnect
301
+ # automatically and abruptly (without logout).
302
+ class InvalidTaggedResponseError < InvalidResponseError
303
+ # A symbol representing the state of the matching tagged command.
304
+ #
305
+ # +:unknown+::
306
+ # #response does not match any known command (#command will be +nil+).
307
+ # +:unstarted+::
308
+ # Any tagged #response is invalid before #command starts sending.
309
+ # +:incomplete+::
310
+ # A tagged +OK+ #response is invalid before #command is fully sent.
311
+ # +:completed+::
312
+ # Multiple tagged responses were received for the same command.
313
+ #
314
+ # NOTE: Command state is neither anticipated nor remembered indefinitely.
315
+ # +:unknown+ is used before the matching command is called and after the
316
+ # it is forgotten.
317
+ #
318
+ # NOTE: This version of Net::IMAP does not detect or raise an exception
319
+ # for all of these states.
320
+ attr_reader :state
321
+
322
+ # Metadata about the matching IMAP command.
323
+ #
324
+ # Returns +nil+ when #state is +:unknown+.
325
+ #
326
+ # NOTE: The non-nil return type is an unstable API, for debug only. It
327
+ # may be changed by any release, without warning or deprecation.
328
+ attr_reader :command
329
+
330
+ # The TaggedResponse which triggered this error
331
+ attr_reader :response
332
+
333
+ def initialize(state, response:, command: nil)
334
+ response => TaggedResponse[tag:, name: status]
335
+ case [state, status, command]
336
+ in :unknown, _, nil
337
+ in :incomplete, "OK", {tag: ^tag, name:}
338
+ in :unstarted | :completed, _, {tag: ^tag, name:}
339
+ end
340
+ @state, @command, @response = state, command, response
341
+ cmd_desc = name ? "#{state} #{name}" : state
342
+ super "Received tagged #{status} to #{cmd_desc} command (tag=#{tag})"
343
+ rescue NoMatchingPatternError => err
344
+ raise ArgumentError, err.message
345
+ end
346
+
347
+ def detailed_message(**)
348
+ "#{message}.\n" \
349
+ "Disconnecting: This could indicate a malicious server, a " \
350
+ "man-in-the-middle attack, a client-side command injection, or " \
351
+ "a bug in net-imap.\n" \
352
+ "response.data=#{response.data.inspect}"
353
+ end
354
+ end
355
+
291
356
  # Error raised upon an unknown response from the server.
292
357
  #
293
358
  # This is different from InvalidResponseError: the response may be a
@@ -2212,10 +2212,7 @@ module Net
2212
2212
  if $1
2213
2213
  return Token.new(T_SPACE, $+)
2214
2214
  elsif $2
2215
- len = $+.to_i
2216
- val = @str[@pos, len]
2217
- @pos += len
2218
- return Token.new(T_LITERAL8, val)
2215
+ literal_token($+, T_LITERAL8)
2219
2216
  elsif $3 && $7
2220
2217
  # greedily match ATOM, prefixed with NUMBER, NIL, or PLUS.
2221
2218
  return Token.new(T_ATOM, $3)
@@ -2243,10 +2240,7 @@ module Net
2243
2240
  elsif $15
2244
2241
  return Token.new(T_RBRA, $+)
2245
2242
  elsif $16
2246
- len = $+.to_i
2247
- val = @str[@pos, len]
2248
- @pos += len
2249
- return Token.new(T_LITERAL, val)
2243
+ literal_token($+)
2250
2244
  elsif $17
2251
2245
  return Token.new(T_PERCENT, $+)
2252
2246
  elsif $18
@@ -2272,10 +2266,7 @@ module Net
2272
2266
  elsif $4
2273
2267
  return Token.new(T_QUOTED, Patterns.unescape_quoted($+))
2274
2268
  elsif $5
2275
- len = $+.to_i
2276
- val = @str[@pos, len]
2277
- @pos += len
2278
- return Token.new(T_LITERAL, val)
2269
+ literal_token($+)
2279
2270
  elsif $6
2280
2271
  return Token.new(T_LPAR, $+)
2281
2272
  elsif $7
@@ -2290,6 +2281,15 @@ module Net
2290
2281
  else
2291
2282
  parse_error("invalid @lex_state - %s", @lex_state.inspect)
2292
2283
  end
2284
+ rescue DataFormatError => error
2285
+ parse_error error.message
2286
+ end
2287
+
2288
+ def literal_token(len, type = T_LITERAL)
2289
+ len = NumValidator.coerce_number64 len
2290
+ val = @str[@pos, len]
2291
+ @pos += len
2292
+ Token.new(type, val)
2293
2293
  end
2294
2294
 
2295
2295
  end
@@ -4,6 +4,8 @@ module Net
4
4
  class IMAP
5
5
  # See https://www.rfc-editor.org/rfc/rfc9051#section-2.2.2
6
6
  class ResponseReader # :nodoc:
7
+ include NumValidator
8
+
7
9
  attr_reader :client
8
10
 
9
11
  def initialize(client, sock)
@@ -46,7 +48,10 @@ module Net
46
48
  def line_done? = buff.end_with?(CRLF)
47
49
 
48
50
  def get_literal_size(buff)
49
- buff.end_with?("}\r\n") && buff.rindex(/\{(\d+)\}\r\n\z/n) && $1.to_i
51
+ buff.end_with?("}\r\n") && buff.rindex(/\{(\d+)\}\r\n\z/n) &&
52
+ coerce_number64($1)
53
+ rescue DataFormatError
54
+ raise DataFormatError, format("invalid response literal size (%s)", $1)
50
55
  end
51
56
 
52
57
  def read_line
data/lib/net/imap.rb CHANGED
@@ -326,6 +326,11 @@ module Net
326
326
  #
327
327
  # <em>In general, #capable? should be used rather than explicitly sending a
328
328
  # +CAPABILITY+ command to the server.</em>
329
+ # - #enable: Enables backwards incompatible server extensions.
330
+ # <em>Requires the +ENABLE+ or +IMAP4rev2+ capability.</em>
331
+ # - #enabled: Returns a set of enabled server extensions.
332
+ # - #enabled?: Returns whether a server extension has been enabled.
333
+ # - #utf8_enabled?: Returns whether UTF-8 string encoding has been enabled.
329
334
  #
330
335
  # === Handling server responses
331
336
  #
@@ -552,6 +557,7 @@ module Net
552
557
  # ==== RFC6855: <tt>UTF8=ACCEPT</tt>, <tt>UTF8=ONLY</tt>
553
558
  #
554
559
  # - See #enable for information about support for UTF-8 string encoding.
560
+ # - #utf8_enabled?: Returns whether UTF-8 string encoding has been enabled.
555
561
  #
556
562
  # ==== RFC7162: +CONDSTORE+
557
563
  #
@@ -813,7 +819,7 @@ module Net
813
819
  # * {IMAP URLAUTH Authorization Mechanism Registry}[https://www.iana.org/assignments/urlauth-authorization-mechanism-registry/urlauth-authorization-mechanism-registry.xhtml]
814
820
  #
815
821
  class IMAP < Protocol
816
- VERSION = "0.6.4"
822
+ VERSION = "0.6.5"
817
823
 
818
824
  # Aliases for supported capabilities, to be used with the #enable command.
819
825
  ENABLE_ALIASES = {
@@ -821,6 +827,11 @@ module Net
821
827
  "UTF8=ONLY" => "UTF8=ACCEPT",
822
828
  }.freeze
823
829
 
830
+ # Aliases for supported capabilities, to be used with #enabled?.
831
+ ENABLED_ALIASES = {
832
+ utf8: Set.new(%w[UTF8=ACCEPT IMAP4REV2]).freeze,
833
+ }.freeze
834
+
824
835
  dir = File.expand_path("imap", __dir__)
825
836
  autoload :ConnectionState, "#{dir}/connection_state"
826
837
  autoload :ResponseReader, "#{dir}/response_reader"
@@ -1110,11 +1121,11 @@ module Net
1110
1121
  @ssl_ctx_params, @ssl_ctx = build_ssl_ctx(ssl)
1111
1122
 
1112
1123
  # Basic Client State
1113
- @utf8_strings = false
1114
1124
  @debug_output_bol = true
1115
1125
  @exception = nil
1116
1126
  @greeting = nil
1117
1127
  @capabilities = nil
1128
+ @enabled = Set.new
1118
1129
  @tls_verified = false
1119
1130
  @connection_state = ConnectionState::NotAuthenticated.new
1120
1131
 
@@ -1160,16 +1171,29 @@ module Net
1160
1171
  # imap.logout
1161
1172
  # imap.inspect #=> "#<Net::IMAP imap.example.net:993 TLS logout>"
1162
1173
  #
1174
+ # imap = Net::IMAP.new(hostname, ssl: false)
1175
+ # imap.inspect #=> "#<Net::IMAP imap.example.net:143 PLAINTEXT not_authenticated>"
1176
+ #
1177
+ # imap.starttls verify_mode: OpenSSL::SSL::VERIFY_NONE
1178
+ # imap.inspect #=> "#<Net::IMAP imap.example.net:993 TLS (NOT VERIFIED) not_authenticated>"
1179
+ #
1163
1180
  def inspect
1164
- tls_state = tls_verified? ? "TLS" :
1165
- ssl_ctx ? "TLS (NOT VERIFIED)" :
1166
- "PLAINTEXT"
1167
1181
  conn_state = disconnected? ? "disconnected" : connection_state.to_sym
1168
1182
  "#<%s:0x%08x %s:%s %s %s>" % [
1169
- self.class.name, __id__, host, port, tls_state, conn_state
1183
+ self.class.name, __id__, host, port, inspect_tls_state, conn_state
1170
1184
  ]
1171
1185
  end
1172
1186
 
1187
+ private def inspect_tls_state
1188
+ if tls_verified?
1189
+ "TLS"
1190
+ elsif ssl_ctx && @sock.kind_of?(OpenSSL::SSL::SSLSocket)
1191
+ "TLS (#{@sock.session ? "NOT VERIFIED" : "NOT ESTABLISHED"})"
1192
+ else
1193
+ "PLAINTEXT#{" (TLS NOT STARTED)" if ssl_ctx}"
1194
+ end
1195
+ end
1196
+
1173
1197
  # Returns true after the TLS negotiation has completed and the remote
1174
1198
  # hostname has been verified. Returns false when TLS has been established
1175
1199
  # but peer verification was disabled.
@@ -1177,22 +1201,25 @@ module Net
1177
1201
 
1178
1202
  # Disconnects from the server.
1179
1203
  #
1180
- # Waits for receiver thread to close before returning. Slow or stuck
1181
- # response handlers can cause #disconnect to hang until they complete.
1204
+ # Waits for receiver thread to close before returning, except when called
1205
+ # from inside the connection mutex such as from a response handler. Slow or
1206
+ # stuck response handlers can cause #disconnect to hang until they complete.
1207
+ # Use +timeout+ to limit how long to wait for the receiver thread to exit.
1182
1208
  #
1183
1209
  # Related: #logout, #logout!
1184
- def disconnect
1210
+ def disconnect(timeout: nil)
1185
1211
  in_logout_state = try_state_logout?
1186
1212
  return if disconnected?
1213
+ in_receiver_thread = Thread.current == @receiver_thread
1187
1214
  begin
1188
1215
  @sock.to_io.shutdown
1189
1216
  rescue Errno::ENOTCONN
1190
1217
  # ignore `Errno::ENOTCONN: Socket is not connected' on some platforms.
1191
1218
  rescue Exception => e
1192
- @receiver_thread.raise(e)
1219
+ @receiver_thread.raise(e) unless in_receiver_thread
1193
1220
  end
1194
1221
  @sock.close
1195
- @receiver_thread.join
1222
+ @receiver_thread.join(timeout) unless mon_owned? || in_receiver_thread
1196
1223
  raise e if e
1197
1224
  ensure
1198
1225
  # Try again after shutting down the receiver thread. With no reciever
@@ -1454,7 +1481,7 @@ module Net
1454
1481
  end
1455
1482
  if error
1456
1483
  disconnect
1457
- raise error
1484
+ reraise error
1458
1485
  end
1459
1486
  unless handled
1460
1487
  disconnect
@@ -2242,6 +2269,7 @@ module Net
2242
2269
  # provided as an array or a string.
2243
2270
  # See {"Argument translation"}[rdoc-ref:#search@Argument+translation]
2244
2271
  # and {"Search criteria"}[rdoc-ref:#search@Search+criteria], below.
2272
+ # <em>Please note</em> the warning for when +criteria+ is a String.
2245
2273
  #
2246
2274
  # +return+ options control what kind of information is returned about
2247
2275
  # messages matching the search +criteria+. Specifying +return+ should force
@@ -2652,7 +2680,8 @@ module Net
2652
2680
  # backward compatibility) but adds SearchResult#modseq when the +CONDSTORE+
2653
2681
  # capability has been enabled.
2654
2682
  #
2655
- # See #search for documentation of parameters.
2683
+ # See #search for documentation of parameters. <em>Please note</em> the
2684
+ # warning for when +criteria+ is a String.
2656
2685
  #
2657
2686
  # ==== Capabilities
2658
2687
  #
@@ -2738,7 +2767,8 @@ module Net
2738
2767
  # {SequenceSet[...]}[rdoc-ref:SequenceSet@Creating+sequence+sets].
2739
2768
  # (For message sequence numbers, use #fetch instead.)
2740
2769
  #
2741
- # +attr+ behaves the same as with #fetch.
2770
+ # +attr+ behaves the same as with #fetch. <em>Please note</em> the #fetch
2771
+ # warning on the +attr+ argument.
2742
2772
  # >>>
2743
2773
  # *Note:* Servers _MUST_ implicitly include the +UID+ message data item as
2744
2774
  # part of any +FETCH+ response caused by a +UID+ command, regardless of
@@ -2950,8 +2980,10 @@ module Net
2950
2980
 
2951
2981
  # Sends a {SORT command [RFC5256 §3]}[https://www.rfc-editor.org/rfc/rfc5256#section-3]
2952
2982
  # to search a mailbox for messages that match +search_keys+ and return an
2953
- # array of message sequence numbers, sorted by +sort_keys+. +search_keys+
2954
- # are interpreted the same as for #search.
2983
+ # array of message sequence numbers, sorted by +sort_keys+.
2984
+ #
2985
+ # +search_keys+ are interpreted the same as the +criteria+ argument for
2986
+ # #search. <em>Please note</em> the #search warning for String +criteria+.
2955
2987
  #
2956
2988
  #--
2957
2989
  # TODO: describe +sort_keys+
@@ -2976,8 +3008,10 @@ module Net
2976
3008
 
2977
3009
  # Sends a {UID SORT command [RFC5256 §3]}[https://www.rfc-editor.org/rfc/rfc5256#section-3]
2978
3010
  # to search a mailbox for messages that match +search_keys+ and return an
2979
- # array of unique identifiers, sorted by +sort_keys+. +search_keys+ are
2980
- # interpreted the same as for #search.
3011
+ # array of unique identifiers, sorted by +sort_keys+.
3012
+ #
3013
+ # +search_keys+ are interpreted the same as the +criteria+ argument for
3014
+ # #search. <em>Please note</em> the #search warning for String +criteria+.
2981
3015
  #
2982
3016
  # Related: #sort, #search, #uid_search, #thread, #uid_thread
2983
3017
  #
@@ -2991,8 +3025,10 @@ module Net
2991
3025
 
2992
3026
  # Sends a {THREAD command [RFC5256 §3]}[https://www.rfc-editor.org/rfc/rfc5256#section-3]
2993
3027
  # to search a mailbox and return message sequence numbers in threaded
2994
- # format, as a ThreadMember tree. +search_keys+ are interpreted the same as
2995
- # for #search.
3028
+ # format, as a ThreadMember tree.
3029
+ #
3030
+ # +search_keys+ are interpreted the same as the +criteria+ argument for
3031
+ # #search. <em>Please note</em> the #search warning for String +criteria+.
2996
3032
  #
2997
3033
  # The supported algorithms are:
2998
3034
  #
@@ -3018,6 +3054,9 @@ module Net
3018
3054
  # Similar to #thread, but returns unique identifiers instead of
3019
3055
  # message sequence numbers.
3020
3056
  #
3057
+ # +search_keys+ are interpreted the same as the +criteria+ argument for
3058
+ # #search. <em>Please note</em> the #search warning for String +criteria+.
3059
+ #
3021
3060
  # Related: #thread, #search, #uid_search, #sort, #uid_sort
3022
3061
  #
3023
3062
  # ==== Capabilities
@@ -3127,17 +3166,54 @@ module Net
3127
3166
  capabilities = capabilities
3128
3167
  .flatten
3129
3168
  .map {|e| ENABLE_ALIASES[e] || e }
3169
+ .flat_map { _1.is_a?(String) && !_1.empty? ? _1.split(/ /, -1) : [_1] }
3130
3170
  .uniq
3131
- .join(' ')
3171
+ .map { Atom[_1] }
3132
3172
  synchronize do
3133
- send_command("ENABLE #{capabilities}")
3173
+ send_command("ENABLE", *capabilities)
3134
3174
  result = clear_responses("ENABLED").last || []
3135
- @utf8_strings ||= result.include? "UTF8=ACCEPT"
3136
- @utf8_strings ||= result.include? "IMAP4REV2"
3175
+ @enabled.merge(result.map(&:upcase))
3137
3176
  result
3138
3177
  end
3139
3178
  end
3140
3179
 
3180
+ # Returns whether UTF-8 string encoding has been enabled for the connection.
3181
+ #
3182
+ # This is currently identical to calling #enabled? with +:utf8+.
3183
+ #
3184
+ # See #enable and #enabled?.
3185
+ def utf8_enabled?; enabled?(:utf8) end
3186
+
3187
+ # Returns whether +capability+ is in the set of #enabled capabilities,
3188
+ # matched case-insensitively.
3189
+ #
3190
+ # When +capability+ is +:utf8+, it matches if _either_
3191
+ # <tt>"UTF8=ACCEPT"</tt> or <tt>"IMAP4REV2"</tt> is enabled.
3192
+ #
3193
+ # See #enable and #utf8_enabled?.
3194
+ def enabled?(capability)
3195
+ case capability
3196
+ in String
3197
+ capability = capability.upcase
3198
+ synchronize { @enabled.include?(capability) }
3199
+ in Symbol
3200
+ capabilities = ENABLED_ALIASES[capability] or
3201
+ raise ArgumentError, "unknown capability alias: #{capability}"
3202
+ synchronize { @enabled.intersect?(capabilities) }
3203
+ else
3204
+ raise TypeError, "expected capability to be String or Symbol, " \
3205
+ "got %s" % [capability.class]
3206
+ end
3207
+ end
3208
+
3209
+ # Returns a set of enabled capabilities for the connection, as upper-cased
3210
+ # strings.
3211
+ #
3212
+ # See #enable and #enabled?.
3213
+ def enabled
3214
+ synchronize { @enabled.dup }
3215
+ end
3216
+
3141
3217
  # Sends an {IDLE command [RFC2177 §3]}[https://www.rfc-editor.org/rfc/rfc6851#section-3]
3142
3218
  # {[IMAP4rev2 §6.3.13]}[https://www.rfc-editor.org/rfc/rfc9051#section-6.3.13]
3143
3219
  # that waits for notifications of new or expunged messages. Yields
@@ -3174,8 +3250,9 @@ module Net
3174
3250
 
3175
3251
  synchronize do
3176
3252
  tag = Thread.current[:net_imap_tag] = generate_tag
3177
- guard_against_tagged_response_skipping_handler!(tag, "IDLE")
3253
+ command = Command[tag:, name: "IDLE"]
3178
3254
  put_string("#{tag} IDLE#{CRLF}")
3255
+ finish_sending_command(command)
3179
3256
 
3180
3257
  begin
3181
3258
  add_response_handler(&response_handler)
@@ -3183,7 +3260,7 @@ module Net
3183
3260
  @idle_done_cond.wait(timeout)
3184
3261
  @idle_done_cond = nil
3185
3262
  if @receiver_thread_terminating
3186
- raise @exception || Net::IMAP::Error.new("connection closed")
3263
+ reraise @exception || Net::IMAP::Error.new("connection closed")
3187
3264
  end
3188
3265
  ensure
3189
3266
  remove_response_handler(response_handler)
@@ -3192,6 +3269,9 @@ module Net
3192
3269
  response = get_tagged_response(tag, "IDLE", idle_response_timeout)
3193
3270
  end
3194
3271
  end
3272
+ rescue InvalidResponseError
3273
+ disconnect
3274
+ raise
3195
3275
  end
3196
3276
 
3197
3277
  return response
@@ -3483,67 +3563,28 @@ module Net
3483
3563
  end
3484
3564
 
3485
3565
  def receive_responses
3486
- connection_closed = false
3487
- until connection_closed
3488
- synchronize do
3489
- @exception = nil
3490
- end
3491
- begin
3492
- resp = get_response
3493
- rescue Exception => e
3494
- synchronize do
3495
- state_logout!
3496
- @sock.close
3497
- @exception = e
3498
- end
3499
- break
3500
- end
3501
- unless resp
3502
- synchronize do
3503
- @exception = EOFError.new("end of file reached")
3504
- end
3505
- break
3506
- end
3507
- begin
3508
- synchronize do
3509
- case resp
3510
- when TaggedResponse
3511
- @tagged_responses[resp.tag] = resp
3512
- @tagged_response_arrival.broadcast
3513
- case resp.tag
3514
- when @logout_command_tag
3515
- state_logout!
3516
- return
3517
- when @continued_command_tag
3518
- @continuation_request_exception =
3519
- RESPONSE_ERRORS[resp.name].new(resp)
3520
- @continuation_request_arrival.signal
3521
- end
3522
- when UntaggedResponse
3523
- record_untagged_response(resp)
3524
- if resp.name == "BYE" && @logout_command_tag.nil?
3525
- state_logout!
3526
- @sock.close
3527
- @exception = ByeResponseError.new(resp)
3528
- connection_closed = true
3529
- end
3530
- when ContinuationRequest
3531
- @continuation_request_arrival.signal
3532
- end
3533
- state_unselected! if resp in {data: {code: {name: "CLOSED"}}}
3534
- @response_handlers.each do |handler|
3535
- handler.call(resp)
3536
- end
3537
- end
3538
- rescue Exception => e
3539
- @exception = e
3540
- synchronize do
3541
- @tagged_response_arrival.broadcast
3542
- @continuation_request_arrival.broadcast
3543
- end
3544
- end
3566
+ exception = nil
3567
+ loop do
3568
+ resp = get_response or raise EOFError, "end of file reached"
3569
+ handle_response(resp) or return
3545
3570
  end
3571
+ rescue Exception => exception
3572
+ # NOTE: this rescue clause is only capturing the exception for the ensure
3573
+ # clause. Using `$!` in the ensure clause seems to trigger a weird
3574
+ # TruffleRuby bug: https://github.com/truffleruby/truffleruby/issues/4308
3575
+ #
3576
+ # We don't assign @exception directly here, because we want that to be
3577
+ # atomically synchronized with all of the other changes in `ensure`.
3578
+ raise
3579
+ ensure
3546
3580
  synchronize do
3581
+ if exception
3582
+ # Handling exceptions here, not in a rescue clause, so the lock isn't
3583
+ # released and reacquired between rescue and ensure clauses.
3584
+ @exception ||= exception
3585
+ @sock.close
3586
+ end
3587
+ state_logout!
3547
3588
  @receiver_thread_terminating = true
3548
3589
  @tagged_response_arrival.broadcast
3549
3590
  @continuation_request_arrival.broadcast
@@ -3551,36 +3592,6 @@ module Net
3551
3592
  @idle_done_cond.signal
3552
3593
  end
3553
3594
  end
3554
- ensure
3555
- state_logout!
3556
- end
3557
-
3558
- def get_tagged_response(tag, cmd, timeout = nil)
3559
- if timeout
3560
- deadline = Time.now + timeout
3561
- end
3562
- until @tagged_responses.key?(tag)
3563
- raise @exception if @exception
3564
- if timeout
3565
- timeout = deadline - Time.now
3566
- if timeout <= 0
3567
- return nil
3568
- end
3569
- end
3570
- @tagged_response_arrival.wait(timeout)
3571
- end
3572
- resp = @tagged_responses.delete(tag)
3573
- case resp.name
3574
- when /\A(?:OK)\z/ni
3575
- return resp
3576
- when /\A(?:NO)\z/ni
3577
- raise NoResponseError, resp
3578
- when /\A(?:BAD)\z/ni
3579
- raise BadResponseError, resp
3580
- else
3581
- disconnect
3582
- raise InvalidResponseError, "invalid tagged resp: %p" % [resp.raw_data.chomp]
3583
- end
3584
3595
  end
3585
3596
 
3586
3597
  def get_response
@@ -3593,6 +3604,47 @@ module Net
3593
3604
  #############################
3594
3605
  # built-in response handlers
3595
3606
 
3607
+ def handle_response(resp)
3608
+ connection_closed = false
3609
+ synchronize do
3610
+ case resp
3611
+ when TaggedResponse
3612
+ @tagged_responses[resp.tag] = resp
3613
+ @tagged_response_arrival.broadcast
3614
+ case resp.tag
3615
+ when @logout_command_tag
3616
+ state_logout!
3617
+ return
3618
+ when @continued_command_tag
3619
+ @continuation_request_exception =
3620
+ RESPONSE_ERRORS[resp.name].new(resp)
3621
+ @continuation_request_arrival.signal
3622
+ end
3623
+ when UntaggedResponse
3624
+ record_untagged_response(resp)
3625
+ if resp.name == "BYE" && @logout_command_tag.nil?
3626
+ state_logout!
3627
+ @sock.close
3628
+ @exception = ByeResponseError.new(resp)
3629
+ connection_closed = true
3630
+ end
3631
+ when ContinuationRequest
3632
+ @continuation_request_arrival.signal
3633
+ end
3634
+ state_unselected! if resp in {data: {code: {name: "CLOSED"}}}
3635
+ @response_handlers.each do |handler|
3636
+ handler.call(resp)
3637
+ end
3638
+ rescue Exception => e
3639
+ @exception = e
3640
+ @tagged_response_arrival.broadcast
3641
+ @continuation_request_arrival.broadcast
3642
+ ensure
3643
+ @exception = nil unless connection_closed
3644
+ end
3645
+ !connection_closed
3646
+ end
3647
+
3596
3648
  # store name => [..., data]
3597
3649
  def record_untagged_response(resp)
3598
3650
  @responses[resp.name] << resp.data
@@ -3629,18 +3681,17 @@ module Net
3629
3681
  end
3630
3682
 
3631
3683
  def send_command(cmd, *args, &block)
3684
+ args.each do validate_data _1 end
3632
3685
  synchronize do
3633
- args.each do |i|
3634
- validate_data(i)
3635
- end
3636
3686
  tag = generate_tag
3687
+ command = Command[tag:, name: cmd]
3637
3688
  put_string(tag + " " + cmd)
3638
3689
  args.each do |i|
3639
3690
  put_string(" ")
3640
3691
  send_data(i, tag)
3641
3692
  end
3642
3693
  @logout_command_tag = tag if cmd == "LOGOUT"
3643
- guard_against_tagged_response_skipping_handler!(tag, cmd)
3694
+ finish_sending_command(command)
3644
3695
  add_response_handler(&block) if block
3645
3696
  begin
3646
3697
  put_string(CRLF)
@@ -3648,20 +3699,78 @@ module Net
3648
3699
  ensure
3649
3700
  remove_response_handler(block) if block
3650
3701
  end
3702
+ rescue InvalidResponseError
3703
+ disconnect
3704
+ raise
3705
+ end
3706
+ end
3707
+
3708
+ # NOTE: This must be synchronized with sending the command's final CRLF and
3709
+ # adding any command-related response handlers.
3710
+ def finish_sending_command(command)
3711
+ if (response = @tagged_responses[command.tag])&.name&.casecmp?("OK")
3712
+ raise InvalidTaggedResponseError.new(:incomplete, command:, response:)
3651
3713
  end
3652
- rescue InvalidResponseError
3653
- disconnect
3654
- raise
3655
3714
  end
3656
3715
 
3657
- def guard_against_tagged_response_skipping_handler!(tag, cmd)
3658
- return unless (resp = @tagged_responses[tag])&.name&.upcase == "OK"
3659
- raise InvalidResponseError, format(
3660
- "Received tagged 'OK' to incomplete %s command (tag=%s). " \
3661
- "This could indicate a malicious server, a man-in-the-middle, or " \
3662
- "client-side command injection. Disconnecting.",
3663
- cmd, tag
3664
- )
3716
+ def get_tagged_response(tag, cmd, timeout = nil)
3717
+ if timeout
3718
+ deadline = Time.now + timeout
3719
+ end
3720
+ until @tagged_responses.key?(tag)
3721
+ reraise @exception
3722
+ if timeout
3723
+ timeout = deadline - Time.now
3724
+ if timeout <= 0
3725
+ return nil
3726
+ end
3727
+ end
3728
+ @tagged_response_arrival.wait(timeout)
3729
+ end
3730
+ resp = @tagged_responses.delete(tag)
3731
+ case resp.name
3732
+ when /\A(?:OK)\z/ni
3733
+ return resp
3734
+ when /\A(?:NO)\z/ni
3735
+ raise NoResponseError, resp
3736
+ when /\A(?:BAD)\z/ni
3737
+ raise BadResponseError, resp
3738
+ else
3739
+ disconnect
3740
+ raise InvalidResponseError, "invalid tagged resp: %p" % [resp.raw_data.chomp]
3741
+ end
3742
+ end
3743
+
3744
+ # Raises a copy of +exception+ with an updated +backtrace+ and +cause+, or
3745
+ # returns +nil+ when +exception+ is falsey.
3746
+ #
3747
+ # +backtrace+ is set to reflect the current caller.
3748
+ #
3749
+ # +cause+ is set to the original exception, _if_ the original has its own
3750
+ # backtrace or cause. Otherwise, the original was never raised and the
3751
+ # default cause is used.
3752
+ #
3753
+ # This is meant for exceptions that may have been created by another thread.
3754
+ # Raising them directly gives a misleading backtrace, making it hard to
3755
+ # discover where the errors originate. Although it is sometimes more
3756
+ # appropriate to raise a wrapping exception, that changes the API and forces
3757
+ # updates to users' `rescue` pattern matching.
3758
+ def reraise(exception)
3759
+ return unless exception
3760
+ copy = exception.dup
3761
+ # NOTE: set_backtrace(caller_locations) doesn't work for CRuby <= 3.3.
3762
+ # and set_backtrace(nil) doesn't work for TruffleRuby 34.0.0:
3763
+ # https://github.com/truffleruby/truffleruby/issues/4296
3764
+ if RUBY_ENGINE == "truffleruby"
3765
+ copy.set_backtrace caller_locations
3766
+ else
3767
+ copy.set_backtrace nil
3768
+ end
3769
+ if exception.cause || exception.backtrace
3770
+ raise copy, cause: exception
3771
+ else
3772
+ raise copy
3773
+ end
3665
3774
  end
3666
3775
 
3667
3776
  def generate_tag
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: net-imap
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.4
4
+ version: 0.6.5
5
5
  platform: ruby
6
6
  authors:
7
7
  - Shugo Maeda
@@ -48,6 +48,7 @@ extra_rdoc_files: []
48
48
  files:
49
49
  - ".document"
50
50
  - ".rdoc_options"
51
+ - ".simplecov"
51
52
  - BSDL
52
53
  - COPYING
53
54
  - Gemfile
@@ -131,7 +132,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
131
132
  - !ruby/object:Gem::Version
132
133
  version: '0'
133
134
  requirements: []
134
- rubygems_version: 4.0.10
135
+ rubygems_version: 4.0.17
135
136
  specification_version: 4
136
137
  summary: Ruby client api for Internet Message Access Protocol
137
138
  test_files: []