net-imap 0.6.4.1 → 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: 032600f694434b77edab2496c4bff9a7adc5c912301c0a7b386706dfccdc2345
4
- data.tar.gz: 69dd23250cda6c1eb7a9bbeabe10969389c10047f12610d6bec3407d23e16854
3
+ metadata.gz: cd2bbaf7d6564739894405f3066c772976b48062ea2c696b741d10aa34ebbed6
4
+ data.tar.gz: 1fa2b00d9869e60e44be3a3aea1e058f0974705aef5a41a62f4ea25ee4fda48d
5
5
  SHA512:
6
- metadata.gz: 98ede1ee28c608ceea04bd5a00a114029da96eae454dc222cb046308c625241df423ceac7811322e6f641f3899ba1ca7a7d3f1bbfbf839632d2cf1a01c963c59
7
- data.tar.gz: 810b3514de99b738216d7d49c917a05ee0c73211530bd81fe55540d158736dd7ba84f841d13f6d959b3dca01c3e32d792e500e7ee8f8eb5a2ab6899a53bf095e
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,6 +15,9 @@ 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
22
  # Covers modseq-valzer, which is the largest valid IMAP integer
20
23
  if data.negative?
@@ -55,28 +58,39 @@ module Net
55
58
  end
56
59
  end
57
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.
58
71
  def send_string_data(str, tag = nil)
59
72
  if str.empty?
73
+ # same as send_quoted_string(str), but incompatible encoding is allowed
60
74
  put_string('""')
61
- elsif str.match?(/[\r\n]/n)
62
- # literal, because multiline
63
- send_literal(str, tag)
64
- elsif !str.ascii_only?
65
- if @utf8_strings
66
- # quoted string
67
- send_quoted_string(str)
68
- else
69
- # literal, because of non-ASCII bytes
70
- send_literal(str, tag)
71
- end
72
- elsif str.match?(/[(){ \x00-\x1f\x7f%*"\\]/n)
73
- # 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)
74
79
  send_quoted_string(str)
75
80
  else
76
- put_string(str)
81
+ send_literal(str, tag)
77
82
  end
78
83
  end
79
84
 
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?)
92
+ end
93
+
80
94
  def send_quoted_string(str) = QuotedString.new(data: str).send_data(self)
81
95
 
82
96
  def send_binary_literal(*, **) = send_literal(*, **, binary: true)
@@ -108,8 +122,7 @@ module Net
108
122
  @continuation_request_exception = nil
109
123
  begin
110
124
  @continuation_request_arrival.wait
111
- e = @continuation_request_exception || @exception
112
- raise e if e
125
+ reraise @continuation_request_exception || @exception
113
126
  put_string(str)
114
127
  ensure
115
128
  @continued_command_tag = nil
@@ -154,6 +167,9 @@ module Net
154
167
  def send_date_data(date) put_string Net::IMAP.encode_date(date) end
155
168
  def send_time_data(time) put_string Net::IMAP.encode_time(time) end
156
169
 
170
+ # NOTE: Currently for internal use only. The API is expected to change.
171
+ Command = Data.define(:tag, :name) # :nodoc:
172
+
157
173
  CommandData = Data.define(:data) do # :nodoc:
158
174
  def self.validate(...)
159
175
  data = new(...)
@@ -271,6 +287,17 @@ module Net
271
287
  private_class_method :extract_literal
272
288
  end
273
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+
274
301
  class Atom < CommandData # :nodoc:
275
302
  def initialize(**)
276
303
  super
@@ -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
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.1"
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
 
@@ -1193,9 +1204,10 @@ module Net
1193
1204
  # Waits for receiver thread to close before returning, except when called
1194
1205
  # from inside the connection mutex such as from a response handler. Slow or
1195
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.
1196
1208
  #
1197
1209
  # Related: #logout, #logout!
1198
- def disconnect
1210
+ def disconnect(timeout: nil)
1199
1211
  in_logout_state = try_state_logout?
1200
1212
  return if disconnected?
1201
1213
  in_receiver_thread = Thread.current == @receiver_thread
@@ -1207,7 +1219,7 @@ module Net
1207
1219
  @receiver_thread.raise(e) unless in_receiver_thread
1208
1220
  end
1209
1221
  @sock.close
1210
- @receiver_thread.join unless mon_owned? || in_receiver_thread
1222
+ @receiver_thread.join(timeout) unless mon_owned? || in_receiver_thread
1211
1223
  raise e if e
1212
1224
  ensure
1213
1225
  # Try again after shutting down the receiver thread. With no reciever
@@ -1469,7 +1481,7 @@ module Net
1469
1481
  end
1470
1482
  if error
1471
1483
  disconnect
1472
- raise error
1484
+ reraise error
1473
1485
  end
1474
1486
  unless handled
1475
1487
  disconnect
@@ -3160,12 +3172,48 @@ module Net
3160
3172
  synchronize do
3161
3173
  send_command("ENABLE", *capabilities)
3162
3174
  result = clear_responses("ENABLED").last || []
3163
- @utf8_strings ||= result.include? "UTF8=ACCEPT"
3164
- @utf8_strings ||= result.include? "IMAP4REV2"
3175
+ @enabled.merge(result.map(&:upcase))
3165
3176
  result
3166
3177
  end
3167
3178
  end
3168
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
+
3169
3217
  # Sends an {IDLE command [RFC2177 §3]}[https://www.rfc-editor.org/rfc/rfc6851#section-3]
3170
3218
  # {[IMAP4rev2 §6.3.13]}[https://www.rfc-editor.org/rfc/rfc9051#section-6.3.13]
3171
3219
  # that waits for notifications of new or expunged messages. Yields
@@ -3202,8 +3250,9 @@ module Net
3202
3250
 
3203
3251
  synchronize do
3204
3252
  tag = Thread.current[:net_imap_tag] = generate_tag
3205
- guard_against_tagged_response_skipping_handler!(tag, "IDLE")
3253
+ command = Command[tag:, name: "IDLE"]
3206
3254
  put_string("#{tag} IDLE#{CRLF}")
3255
+ finish_sending_command(command)
3207
3256
 
3208
3257
  begin
3209
3258
  add_response_handler(&response_handler)
@@ -3211,7 +3260,7 @@ module Net
3211
3260
  @idle_done_cond.wait(timeout)
3212
3261
  @idle_done_cond = nil
3213
3262
  if @receiver_thread_terminating
3214
- raise @exception || Net::IMAP::Error.new("connection closed")
3263
+ reraise @exception || Net::IMAP::Error.new("connection closed")
3215
3264
  end
3216
3265
  ensure
3217
3266
  remove_response_handler(response_handler)
@@ -3220,6 +3269,9 @@ module Net
3220
3269
  response = get_tagged_response(tag, "IDLE", idle_response_timeout)
3221
3270
  end
3222
3271
  end
3272
+ rescue InvalidResponseError
3273
+ disconnect
3274
+ raise
3223
3275
  end
3224
3276
 
3225
3277
  return response
@@ -3511,67 +3563,28 @@ module Net
3511
3563
  end
3512
3564
 
3513
3565
  def receive_responses
3514
- connection_closed = false
3515
- until connection_closed
3516
- synchronize do
3517
- @exception = nil
3518
- end
3519
- begin
3520
- resp = get_response
3521
- rescue Exception => e
3522
- synchronize do
3523
- state_logout!
3524
- @sock.close
3525
- @exception = e
3526
- end
3527
- break
3528
- end
3529
- unless resp
3530
- synchronize do
3531
- @exception = EOFError.new("end of file reached")
3532
- end
3533
- break
3534
- end
3535
- begin
3536
- synchronize do
3537
- case resp
3538
- when TaggedResponse
3539
- @tagged_responses[resp.tag] = resp
3540
- @tagged_response_arrival.broadcast
3541
- case resp.tag
3542
- when @logout_command_tag
3543
- state_logout!
3544
- return
3545
- when @continued_command_tag
3546
- @continuation_request_exception =
3547
- RESPONSE_ERRORS[resp.name].new(resp)
3548
- @continuation_request_arrival.signal
3549
- end
3550
- when UntaggedResponse
3551
- record_untagged_response(resp)
3552
- if resp.name == "BYE" && @logout_command_tag.nil?
3553
- state_logout!
3554
- @sock.close
3555
- @exception = ByeResponseError.new(resp)
3556
- connection_closed = true
3557
- end
3558
- when ContinuationRequest
3559
- @continuation_request_arrival.signal
3560
- end
3561
- state_unselected! if resp in {data: {code: {name: "CLOSED"}}}
3562
- @response_handlers.each do |handler|
3563
- handler.call(resp)
3564
- end
3565
- end
3566
- rescue Exception => e
3567
- @exception = e
3568
- synchronize do
3569
- @tagged_response_arrival.broadcast
3570
- @continuation_request_arrival.broadcast
3571
- end
3572
- end
3566
+ exception = nil
3567
+ loop do
3568
+ resp = get_response or raise EOFError, "end of file reached"
3569
+ handle_response(resp) or return
3573
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
3574
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!
3575
3588
  @receiver_thread_terminating = true
3576
3589
  @tagged_response_arrival.broadcast
3577
3590
  @continuation_request_arrival.broadcast
@@ -3579,36 +3592,6 @@ module Net
3579
3592
  @idle_done_cond.signal
3580
3593
  end
3581
3594
  end
3582
- ensure
3583
- state_logout!
3584
- end
3585
-
3586
- def get_tagged_response(tag, cmd, timeout = nil)
3587
- if timeout
3588
- deadline = Time.now + timeout
3589
- end
3590
- until @tagged_responses.key?(tag)
3591
- raise @exception if @exception
3592
- if timeout
3593
- timeout = deadline - Time.now
3594
- if timeout <= 0
3595
- return nil
3596
- end
3597
- end
3598
- @tagged_response_arrival.wait(timeout)
3599
- end
3600
- resp = @tagged_responses.delete(tag)
3601
- case resp.name
3602
- when /\A(?:OK)\z/ni
3603
- return resp
3604
- when /\A(?:NO)\z/ni
3605
- raise NoResponseError, resp
3606
- when /\A(?:BAD)\z/ni
3607
- raise BadResponseError, resp
3608
- else
3609
- disconnect
3610
- raise InvalidResponseError, "invalid tagged resp: %p" % [resp.raw_data.chomp]
3611
- end
3612
3595
  end
3613
3596
 
3614
3597
  def get_response
@@ -3621,6 +3604,47 @@ module Net
3621
3604
  #############################
3622
3605
  # built-in response handlers
3623
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
+
3624
3648
  # store name => [..., data]
3625
3649
  def record_untagged_response(resp)
3626
3650
  @responses[resp.name] << resp.data
@@ -3657,18 +3681,17 @@ module Net
3657
3681
  end
3658
3682
 
3659
3683
  def send_command(cmd, *args, &block)
3684
+ args.each do validate_data _1 end
3660
3685
  synchronize do
3661
- args.each do |i|
3662
- validate_data(i)
3663
- end
3664
3686
  tag = generate_tag
3687
+ command = Command[tag:, name: cmd]
3665
3688
  put_string(tag + " " + cmd)
3666
3689
  args.each do |i|
3667
3690
  put_string(" ")
3668
3691
  send_data(i, tag)
3669
3692
  end
3670
3693
  @logout_command_tag = tag if cmd == "LOGOUT"
3671
- guard_against_tagged_response_skipping_handler!(tag, cmd)
3694
+ finish_sending_command(command)
3672
3695
  add_response_handler(&block) if block
3673
3696
  begin
3674
3697
  put_string(CRLF)
@@ -3676,20 +3699,78 @@ module Net
3676
3699
  ensure
3677
3700
  remove_response_handler(block) if block
3678
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:)
3713
+ end
3714
+ end
3715
+
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]
3679
3741
  end
3680
- rescue InvalidResponseError
3681
- disconnect
3682
- raise
3683
3742
  end
3684
3743
 
3685
- def guard_against_tagged_response_skipping_handler!(tag, cmd)
3686
- return unless (resp = @tagged_responses[tag])&.name&.upcase == "OK"
3687
- raise InvalidResponseError, format(
3688
- "Received tagged 'OK' to incomplete %s command (tag=%s). " \
3689
- "This could indicate a malicious server, a man-in-the-middle, or " \
3690
- "client-side command injection. Disconnecting.",
3691
- cmd, tag
3692
- )
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
3693
3774
  end
3694
3775
 
3695
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.1
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.13
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: []