net-imap 0.4.22 → 0.6.3

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.
Files changed (48) hide show
  1. checksums.yaml +4 -4
  2. data/Gemfile +12 -2
  3. data/README.md +10 -4
  4. data/docs/styles.css +75 -14
  5. data/lib/net/imap/authenticators.rb +2 -2
  6. data/lib/net/imap/command_data.rb +40 -95
  7. data/lib/net/imap/config/attr_accessors.rb +8 -9
  8. data/lib/net/imap/config/attr_inheritance.rb +64 -1
  9. data/lib/net/imap/config/attr_type_coercion.rb +22 -10
  10. data/lib/net/imap/config/attr_version_defaults.rb +90 -0
  11. data/lib/net/imap/config.rb +241 -125
  12. data/lib/net/imap/connection_state.rb +48 -0
  13. data/lib/net/imap/data_encoding.rb +80 -31
  14. data/lib/net/imap/deprecated_client_options.rb +6 -3
  15. data/lib/net/imap/errors.rb +158 -0
  16. data/lib/net/imap/esearch_result.rb +225 -0
  17. data/lib/net/imap/fetch_data.rb +126 -47
  18. data/lib/net/imap/flags.rb +1 -1
  19. data/lib/net/imap/response_data.rb +123 -187
  20. data/lib/net/imap/response_parser/parser_utils.rb +19 -23
  21. data/lib/net/imap/response_parser.rb +182 -38
  22. data/lib/net/imap/response_reader.rb +10 -12
  23. data/lib/net/imap/sasl/anonymous_authenticator.rb +3 -3
  24. data/lib/net/imap/sasl/authentication_exchange.rb +52 -20
  25. data/lib/net/imap/sasl/authenticators.rb +8 -4
  26. data/lib/net/imap/sasl/client_adapter.rb +77 -26
  27. data/lib/net/imap/sasl/cram_md5_authenticator.rb +4 -4
  28. data/lib/net/imap/sasl/digest_md5_authenticator.rb +218 -56
  29. data/lib/net/imap/sasl/external_authenticator.rb +2 -2
  30. data/lib/net/imap/sasl/gs2_header.rb +7 -7
  31. data/lib/net/imap/sasl/login_authenticator.rb +4 -3
  32. data/lib/net/imap/sasl/oauthbearer_authenticator.rb +6 -6
  33. data/lib/net/imap/sasl/plain_authenticator.rb +7 -7
  34. data/lib/net/imap/sasl/protocol_adapters.rb +60 -4
  35. data/lib/net/imap/sasl/scram_authenticator.rb +8 -8
  36. data/lib/net/imap/sasl.rb +7 -4
  37. data/lib/net/imap/sasl_adapter.rb +0 -1
  38. data/lib/net/imap/search_result.rb +10 -5
  39. data/lib/net/imap/sequence_set.rb +1104 -421
  40. data/lib/net/imap/stringprep/nameprep.rb +1 -1
  41. data/lib/net/imap/stringprep/trace.rb +4 -4
  42. data/lib/net/imap/uidplus_data.rb +4 -147
  43. data/lib/net/imap/vanished_data.rb +65 -0
  44. data/lib/net/imap.rb +1002 -313
  45. data/net-imap.gemspec +1 -1
  46. data/rakelib/rfcs.rake +2 -0
  47. data/rakelib/string_prep_tables_generator.rb +6 -2
  48. metadata +7 -3
@@ -185,6 +185,11 @@ module Net
185
185
  @str[@pos, str.length] == str
186
186
  end
187
187
 
188
+ def peek_re?(re)
189
+ assert_no_lookahead if Net::IMAP.debug
190
+ re.match?(@str, @pos)
191
+ end
192
+
188
193
  def peek_re(re)
189
194
  assert_no_lookahead if config.debug?
190
195
  re.match(@str, @pos)
@@ -210,29 +215,20 @@ module Net
210
215
  @token = nil
211
216
  end
212
217
 
213
- def parse_error(fmt, *args)
214
- msg = format(fmt, *args)
215
- if config.debug?
216
- local_path = File.dirname(__dir__)
217
- tok = @token ? "%s: %p" % [@token.symbol, @token.value] : "nil"
218
- warn "%s %s: %s" % [self.class, __method__, msg]
219
- warn " tokenized : %s" % [@str[...@pos].dump]
220
- warn " remaining : %s" % [@str[@pos..].dump]
221
- warn " @lex_state: %s" % [@lex_state]
222
- warn " @pos : %d" % [@pos]
223
- warn " @token : %s" % [tok]
224
- caller_locations(1..20).each_with_index do |cloc, idx|
225
- next unless cloc.path&.start_with?(local_path)
226
- warn " caller[%2d]: %-30s (%s:%d)" % [
227
- idx,
228
- cloc.base_label,
229
- File.basename(cloc.path, ".rb"),
230
- cloc.lineno
231
- ]
232
- end
233
- end
234
- raise ResponseParseError, msg
235
- end
218
+ def parse_error(fmt, *args) = raise exception format(fmt, *args)
219
+
220
+ def exception(message) = ResponseParseError.new(
221
+ message, parser_state:, parser_class: self.class
222
+ )
223
+
224
+ # This can be used to backtrack after a parse error, and re-attempt to
225
+ # parse using a fallback.
226
+ #
227
+ # NOTE: Reckless backtracking could lead to O(n²) situations, so this
228
+ # should very rarely be used. Ideally, fallbacks should not backtrack.
229
+ def restore_state(state) = (@lex_state, @pos, @token = state)
230
+ def current_state = [@lex_state, @pos, @token]
231
+ def parser_state = [@str, *current_state]
236
232
 
237
233
  end
238
234
  end
@@ -38,6 +38,11 @@ module Net
38
38
  @lex_state = EXPR_BEG
39
39
  @token = nil
40
40
  return response
41
+ rescue ResponseParseError => error
42
+ if config.debug?
43
+ warn error.detailed_message(parser_state: true, parser_backtrace: true)
44
+ end
45
+ raise
41
46
  end
42
47
 
43
48
  private
@@ -325,6 +330,24 @@ module Net
325
330
  SEQUENCE_SET = /#{SEQUENCE_SET_ITEM}(?:,#{SEQUENCE_SET_ITEM})*/n
326
331
  SEQUENCE_SET_STR = /\A#{SEQUENCE_SET}\z/n
327
332
 
333
+ # partial-range-first = nz-number ":" nz-number
334
+ # ;; Request to search from oldest (lowest UIDs) to
335
+ # ;; more recent messages.
336
+ # ;; A range 500:400 is the same as 400:500.
337
+ # ;; This is similar to <seq-range> from [RFC3501]
338
+ # ;; but cannot contain "*".
339
+ PARTIAL_RANGE_FIRST = /\A(#{NZ_NUMBER}):(#{NZ_NUMBER})\z/n
340
+
341
+ # partial-range-last = MINUS nz-number ":" MINUS nz-number
342
+ # ;; Request to search from newest (highest UIDs) to
343
+ # ;; oldest messages.
344
+ # ;; A range -500:-400 is the same as -400:-500.
345
+ PARTIAL_RANGE_LAST = /\A(-#{NZ_NUMBER}):(-#{NZ_NUMBER})\z/n
346
+
347
+ # partial-range = partial-range-first / partial-range-last
348
+ PARTIAL_RANGE = Regexp.union(PARTIAL_RANGE_FIRST,
349
+ PARTIAL_RANGE_LAST)
350
+
328
351
  # RFC3501:
329
352
  # literal = "{" number "}" CRLF *CHAR8
330
353
  # ; Number represents the number of CHAR8s
@@ -720,7 +743,7 @@ module Net
720
743
  when "EXISTS" then mailbox_data__exists # RFC3501, RFC9051
721
744
  when "ESEARCH" then esearch_response # RFC4731, RFC9051, etc
722
745
  when "VANISHED" then expunged_resp # RFC7162
723
- when "UIDFETCH" then uidfetch_resp # (draft) UIDONLY
746
+ when "UIDFETCH" then uidfetch_resp # RFC9586
724
747
  when "SEARCH" then mailbox_data__search # RFC3501 (obsolete)
725
748
  when "CAPABILITY" then capability_data__untagged # RFC3501, RFC9051
726
749
  when "FLAGS" then mailbox_data__flags # RFC3501, RFC9051
@@ -773,9 +796,6 @@ module Net
773
796
  def response_data__ignored; response_data__unhandled(IgnoredResponse) end
774
797
  alias response_data__noop response_data__ignored
775
798
 
776
- alias esearch_response response_data__unhandled
777
- alias expunged_resp response_data__unhandled
778
- alias uidfetch_resp response_data__unhandled
779
799
  alias listrights_data response_data__unhandled
780
800
  alias myrights_data response_data__unhandled
781
801
  alias metadata_resp response_data__unhandled
@@ -836,6 +856,14 @@ module Net
836
856
  UntaggedResponse.new(name, data, @str)
837
857
  end
838
858
 
859
+ # uidfetch-resp = uniqueid SP "UIDFETCH" SP msg-att
860
+ def uidfetch_resp
861
+ uid = uniqueid; SP!
862
+ name = label "UIDFETCH"; SP!
863
+ data = UIDFetchData.new(uid, msg_att(uid))
864
+ UntaggedResponse.new(name, data, @str)
865
+ end
866
+
839
867
  def response_data__simple_numeric
840
868
  data = nz_number; SP!
841
869
  name = tagged_ext_label
@@ -846,6 +874,20 @@ module Net
846
874
  alias mailbox_data__exists response_data__simple_numeric
847
875
  alias mailbox_data__recent response_data__simple_numeric
848
876
 
877
+ # The name for this is confusing, because it *replaces* EXPUNGE
878
+ # >>>
879
+ # expunged-resp = "VANISHED" [SP "(EARLIER)"] SP known-uids
880
+ def expunged_resp
881
+ name = label "VANISHED"; SP!
882
+ earlier = if lpar? then label("EARLIER"); rpar; SP!; true else false end
883
+ uids = known_uids
884
+ data = VanishedData[uids, earlier]
885
+ UntaggedResponse.new name, data, @str
886
+ end
887
+
888
+ # TODO: replace with uid_set
889
+ alias known_uids sequence_set
890
+
849
891
  # RFC3501 & RFC9051:
850
892
  # msg-att = "(" (msg-att-dynamic / msg-att-static)
851
893
  # *(SP (msg-att-dynamic / msg-att-static)) ")"
@@ -1321,31 +1363,19 @@ module Net
1321
1363
  # header-fld-name = astring
1322
1364
  #
1323
1365
  # NOTE: Previously, Net::IMAP recreated the raw original source string.
1324
- # Now, it grabs the raw encoded value using @str and @pos. A future
1325
- # version may simply return the decoded astring value. Although that is
1326
- # technically incompatible, it should almost never make a difference: all
1327
- # standard header field names are valid atoms:
1366
+ # Now, it returns the decoded astring value. Although this is technically
1367
+ # incompatible, it should almost never make a difference: all standard
1368
+ # header field names are valid atoms:
1328
1369
  #
1329
1370
  # https://www.iana.org/assignments/message-headers/message-headers.xhtml
1330
1371
  #
1331
- # Although RFC3501 allows any astring, RFC5322-valid header names are one
1332
- # or more of the printable US-ASCII characters, except SP and colon. So
1333
- # empty string isn't valid, and literals aren't needed and should not be
1334
- # used. This is explicitly unchanged by [I18N-HDRS] (RFC6532).
1335
- #
1336
- # RFC5233:
1372
+ # See also RFC5233:
1337
1373
  # optional-field = field-name ":" unstructured CRLF
1338
1374
  # field-name = 1*ftext
1339
1375
  # ftext = %d33-57 / ; Printable US-ASCII
1340
1376
  # %d59-126 ; characters not including
1341
1377
  # ; ":".
1342
- def header_fld_name
1343
- assert_no_lookahead
1344
- start = @pos
1345
- astring
1346
- end_pos = @token ? @pos - 1 : @pos
1347
- @str[start...end_pos]
1348
- end
1378
+ alias header_fld_name astring
1349
1379
 
1350
1380
  # mailbox-data = "FLAGS" SP flag-list / "LIST" SP mailbox-list /
1351
1381
  # "LSUB" SP mailbox-list / "SEARCH" *(SP nz-number) /
@@ -1484,6 +1514,111 @@ module Net
1484
1514
  end
1485
1515
  alias sort_data mailbox_data__search
1486
1516
 
1517
+ # esearch-response = "ESEARCH" [search-correlator] [SP "UID"]
1518
+ # *(SP search-return-data)
1519
+ # ;; Note that SEARCH and ESEARCH responses
1520
+ # ;; SHOULD be mutually exclusive,
1521
+ # ;; i.e., only one of the response types
1522
+ # ;; should be
1523
+ # ;; returned as a result of a command.
1524
+ # esearch-response = "ESEARCH" [search-correlator] [SP "UID"]
1525
+ # *(SP search-return-data)
1526
+ # ; ESEARCH response replaces SEARCH response
1527
+ # ; from IMAP4rev1.
1528
+ # search-correlator = SP "(" "TAG" SP tag-string ")"
1529
+ def esearch_response
1530
+ name = label("ESEARCH")
1531
+ tag = search_correlator if peek_str?(" (")
1532
+ uid = peek_re?(/\G UID\b/i) && (SP!; label("UID"); true)
1533
+ data = []
1534
+ data << search_return_data while SP?
1535
+ esearch = ESearchResult.new(tag, uid, data)
1536
+ UntaggedResponse.new(name, esearch, @str)
1537
+ end
1538
+
1539
+ # From RFC4731 (ESEARCH):
1540
+ # search-return-data = "MIN" SP nz-number /
1541
+ # "MAX" SP nz-number /
1542
+ # "ALL" SP sequence-set /
1543
+ # "COUNT" SP number /
1544
+ # search-ret-data-ext
1545
+ # ; All return data items conform to
1546
+ # ; search-ret-data-ext syntax.
1547
+ # search-ret-data-ext = search-modifier-name SP search-return-value
1548
+ # search-modifier-name = tagged-ext-label
1549
+ # search-return-value = tagged-ext-val
1550
+ #
1551
+ # From RFC4731 (ESEARCH):
1552
+ # search-return-data =/ "MODSEQ" SP mod-sequence-value
1553
+ #
1554
+ # From RFC9394 (PARTIAL):
1555
+ # search-return-data =/ ret-data-partial
1556
+ #
1557
+ def search_return_data
1558
+ label = search_modifier_name; SP!
1559
+ value =
1560
+ case label
1561
+ when "MIN" then nz_number
1562
+ when "MAX" then nz_number
1563
+ when "ALL" then sequence_set
1564
+ when "COUNT" then number
1565
+ when "MODSEQ" then mod_sequence_value # RFC7162: CONDSTORE
1566
+ when "PARTIAL" then ret_data_partial__value # RFC9394: PARTIAL
1567
+ else search_return_value
1568
+ end
1569
+ [label, value]
1570
+ end
1571
+
1572
+ # From RFC5267 (CONTEXT=SEARCH, CONTEXT=SORT) and RFC9394 (PARTIAL):
1573
+ # ret-data-partial = "PARTIAL"
1574
+ # SP "(" partial-range SP partial-results ")"
1575
+ def ret_data_partial__value
1576
+ lpar
1577
+ range = partial_range; SP!
1578
+ results = partial_results
1579
+ rpar
1580
+ ESearchResult::PartialResult.new(range, results)
1581
+ end
1582
+
1583
+ # partial-range = partial-range-first / partial-range-last
1584
+ # tagged-ext-simple =/ partial-range-last
1585
+ def partial_range
1586
+ case (str = atom)
1587
+ when Patterns::PARTIAL_RANGE_FIRST, Patterns::PARTIAL_RANGE_LAST
1588
+ min, max = [Integer($1), Integer($2)].minmax
1589
+ min..max
1590
+ else
1591
+ parse_error("unexpected atom %p, expected partial-range", str)
1592
+ end
1593
+ end
1594
+
1595
+ # partial-results = sequence-set / "NIL"
1596
+ # ;; <sequence-set> from [RFC3501].
1597
+ # ;; NIL indicates that no results correspond to
1598
+ # ;; the requested range.
1599
+ def partial_results; NIL? ? nil : sequence_set end
1600
+
1601
+ # search-modifier-name = tagged-ext-label
1602
+ alias search_modifier_name tagged_ext_label
1603
+
1604
+ # search-return-value = tagged-ext-val
1605
+ # ; Data for the returned search option.
1606
+ # ; A single "nz-number"/"number"/"number64" value
1607
+ # ; can be returned as an atom (i.e., without
1608
+ # ; quoting). A sequence-set can be returned
1609
+ # ; as an atom as well.
1610
+ def search_return_value; ExtensionData.new(tagged_ext_val) end
1611
+
1612
+ # search-correlator = SP "(" "TAG" SP tag-string ")"
1613
+ def search_correlator
1614
+ SP!; lpar; label("TAG"); SP!; tag = tag_string; rpar
1615
+ tag
1616
+ end
1617
+
1618
+ # tag-string = astring
1619
+ # ; <tag> represented as <astring>
1620
+ alias tag_string astring
1621
+
1487
1622
  # RFC5256: THREAD
1488
1623
  # thread-data = "THREAD" [SP 1*thread-list]
1489
1624
  def thread_data
@@ -1753,12 +1888,17 @@ module Net
1753
1888
  # We leniently re-interpret this as
1754
1889
  # resp-text = ["[" resp-text-code "]" [SP [text]] / [text]
1755
1890
  def resp_text
1756
- if lbra?
1757
- code = resp_text_code; rbra
1758
- ResponseText.new(code, SP? && text? || "")
1759
- else
1760
- ResponseText.new(nil, text? || "")
1891
+ begin
1892
+ state = current_state
1893
+ if lbra?
1894
+ code = resp_text_code; rbra
1895
+ return ResponseText.new(code, SP? && text? || "")
1896
+ end
1897
+ rescue ResponseParseError => error
1898
+ raise if /\buid-set\b/i.match? error.message
1899
+ restore_state state
1761
1900
  end
1901
+ ResponseText.new(nil, text? || "")
1762
1902
  end
1763
1903
 
1764
1904
  # RFC3501 (See https://www.rfc-editor.org/errata/rfc3501):
@@ -1816,6 +1956,9 @@ module Net
1816
1956
  #
1817
1957
  # RFC8474: OBJECTID
1818
1958
  # resp-text-code =/ "MAILBOXID" SP "(" objectid ")"
1959
+ #
1960
+ # RFC9586: UIDONLY
1961
+ # resp-text-code =/ "UIDREQUIRED"
1819
1962
  def resp_text_code
1820
1963
  name = resp_text_code__name
1821
1964
  data =
@@ -1838,6 +1981,7 @@ module Net
1838
1981
  when "HIGHESTMODSEQ" then SP!; mod_sequence_value # CONDSTORE
1839
1982
  when "MODIFIED" then SP!; sequence_set # CONDSTORE
1840
1983
  when "MAILBOXID" then SP!; parens__objectid # RFC8474: OBJECTID
1984
+ when "UIDREQUIRED" then # RFC9586: UIDONLY
1841
1985
  else
1842
1986
  SP? and text_chars_except_rbra
1843
1987
  end
@@ -1883,24 +2027,24 @@ module Net
1883
2027
  CopyUID(validity, src_uids, dst_uids)
1884
2028
  end
1885
2029
 
1886
- def AppendUID(...) DeprecatedUIDPlus(...) || AppendUIDData.new(...) end
1887
- def CopyUID(...) DeprecatedUIDPlus(...) || CopyUIDData.new(...) end
2030
+ PARSER_PATH = File.expand_path(__FILE__).delete_suffix(".rb")
1888
2031
 
1889
2032
  # TODO: remove this code in the v0.6.0 release
1890
2033
  def DeprecatedUIDPlus(validity, src_uids = nil, dst_uids)
1891
2034
  return unless config.parser_use_deprecated_uidplus_data
1892
- compact_uid_sets = [src_uids, dst_uids].compact
1893
- count = compact_uid_sets.map { _1.count_with_duplicates }.max
1894
- max = config.parser_max_deprecated_uidplus_data_size
1895
- if count <= max
1896
- src_uids &&= src_uids.each_ordered_number.to_a
1897
- dst_uids = dst_uids.each_ordered_number.to_a
1898
- UIDPlusData.new(validity, src_uids, dst_uids)
1899
- elsif config.parser_use_deprecated_uidplus_data != :up_to_max_size
1900
- parse_error("uid-set is too large: %d > %d", count, max)
1901
- end
2035
+ uplevel = caller_locations
2036
+ .find_index { !_1.path.start_with?(PARSER_PATH) }
2037
+ &.succ
2038
+ warn("#{Config}#parser_use_deprecated_uidplus_data is ignored " \
2039
+ "since v0.6.0. Disable this warning by setting " \
2040
+ "config.parser_use_deprecated_uidplus_data = false.",
2041
+ category: :deprecated, uplevel:)
2042
+ nil
1902
2043
  end
1903
2044
 
2045
+ def AppendUID(...) DeprecatedUIDPlus(...) || AppendUIDData.new(...) end
2046
+ def CopyUID(...) DeprecatedUIDPlus(...) || CopyUIDData.new(...) end
2047
+
1904
2048
  ADDRESS_REGEXP = /\G
1905
2049
  \( (?: NIL | #{Patterns::QUOTED_rev2} ) # 1: NAME
1906
2050
  \s (?: NIL | #{Patterns::QUOTED_rev2} ) # 2: ROUTE
@@ -28,11 +28,11 @@ module Net
28
28
 
29
29
  attr_reader :buff, :literal_size
30
30
 
31
- def bytes_read; buff.bytesize end
32
- def empty?; buff.empty? end
33
- def done?; line_done? && !get_literal_size end
34
- def line_done?; buff.end_with?(CRLF) end
35
- def get_literal_size; /\{(\d+)\}\r\n\z/n =~ buff && $1.to_i end
31
+ def bytes_read = buff.bytesize
32
+ def empty? = buff.empty?
33
+ def done? = line_done? && !get_literal_size
34
+ def line_done? = buff.end_with?(CRLF)
35
+ def get_literal_size = /\{(\d+)\}\r\n\z/n =~ buff && $1.to_i
36
36
 
37
37
  def read_line
38
38
  buff << (@sock.gets(CRLF, read_limit) or throw :eof)
@@ -52,10 +52,10 @@ module Net
52
52
  [limit, max_response_remaining!].compact.min
53
53
  end
54
54
 
55
- def max_response_size; client.max_response_size end
56
- def max_response_remaining; max_response_size &.- bytes_read end
57
- def response_too_large?; max_response_size &.< min_response_size end
58
- def min_response_size; bytes_read + min_response_remaining end
55
+ def max_response_size = client.max_response_size
56
+ def max_response_remaining = max_response_size &.- bytes_read
57
+ def response_too_large? = max_response_size &.< min_response_size
58
+ def min_response_size = bytes_read + min_response_remaining
59
59
 
60
60
  def min_response_remaining
61
61
  empty? ? 3 : done? ? 0 : (literal_size || 0) + 2
@@ -64,9 +64,7 @@ module Net
64
64
  def max_response_remaining!
65
65
  return max_response_remaining unless response_too_large?
66
66
  raise ResponseTooLargeError.new(
67
- max_response_size: max_response_size,
68
- bytes_read: bytes_read,
69
- literal_size: literal_size,
67
+ max_response_size:, bytes_read:, literal_size:,
70
68
  )
71
69
  end
72
70
 
@@ -5,7 +5,7 @@ module Net
5
5
  module SASL
6
6
 
7
7
  # Authenticator for the "+ANONYMOUS+" SASL mechanism, as specified by
8
- # RFC-4505[https://tools.ietf.org/html/rfc4505]. See
8
+ # RFC-4505[https://www.rfc-editor.org/rfc/rfc4505]. See
9
9
  # Net::IMAP#authenticate.
10
10
  class AnonymousAuthenticator
11
11
 
@@ -13,7 +13,7 @@ module Net
13
13
  # characters in length.
14
14
  #
15
15
  # If it contains an "@" sign, the message must be a valid email address
16
- # (+addr-spec+ from RFC-2822[https://tools.ietf.org/html/rfc2822]).
16
+ # (+addr-spec+ from RFC-2822[https://www.rfc-editor.org/rfc/rfc2822]).
17
17
  # Email syntax is _not_ validated by AnonymousAuthenticator.
18
18
  #
19
19
  # Otherwise, it can be any UTF8 string which is permitted by the
@@ -25,7 +25,7 @@ module Net
25
25
  # new(anonymous_message: "", **) -> authenticator
26
26
  #
27
27
  # Creates an Authenticator for the "+ANONYMOUS+" SASL mechanism, as
28
- # specified in RFC-4505[https://tools.ietf.org/html/rfc4505]. To use
28
+ # specified in RFC-4505[https://www.rfc-editor.org/rfc/rfc4505]. To use
29
29
  # this, see Net::IMAP#authenticate or your client's authentication
30
30
  # method.
31
31
  #
@@ -4,44 +4,76 @@ module Net
4
4
  class IMAP
5
5
  module SASL
6
6
 
7
- # This API is *experimental*, and may change.
7
+ # AuthenticationExchange is used internally by Net::IMAP#authenticate.
8
+ # But the API is still *experimental*, and may change.
8
9
  #
9
10
  # TODO: catch exceptions in #process and send #cancel_response.
10
11
  # TODO: raise an error if the command succeeds after being canceled.
11
12
  # TODO: use with more clients, to verify the API can accommodate them.
13
+ # TODO: pass ClientAdapter#service to SASL.authenticator
12
14
  #
13
- # Create an AuthenticationExchange from a client adapter and a mechanism
14
- # authenticator:
15
- # def authenticate(mechanism, ...)
16
- # authenticator = SASL.authenticator(mechanism, ...)
17
- # SASL::AuthenticationExchange.new(
18
- # sasl_adapter, mechanism, authenticator
19
- # ).authenticate
20
- # end
21
- #
22
- # private
15
+ # An AuthenticationExchange represents a single attempt to authenticate
16
+ # a SASL client to a SASL server. It is created from a client adapter, a
17
+ # mechanism name, and a mechanism authenticator. When #authenticate is
18
+ # called, it will send the appropriate authenticate command to the server,
19
+ # returning the client response on success and raising an exception on
20
+ # failure.
23
21
  #
24
- # def sasl_adapter = MyClientAdapter.new(self, &method(:send_command))
22
+ # In most cases, the client will not need to use
23
+ # SASL::AuthenticationExchange directly at all. Instead, use
24
+ # SASL::ClientAdapter#authenticate. If customizations are needed, the
25
+ # custom client adapter is probably the best place for that code.
25
26
  #
26
- # Or delegate creation of the authenticator to ::build:
27
27
  # def authenticate(...)
28
- # SASL::AuthenticationExchange.build(sasl_adapter, ...)
29
- # .authenticate
28
+ # MyClient::SASLAdapter.new(self).authenticate(...)
30
29
  # end
31
30
  #
32
- # As a convenience, ::authenticate combines ::build and #authenticate:
31
+ # SASL::ClientAdapter#authenticate delegates to ::authenticate, like so:
32
+ #
33
33
  # def authenticate(...)
34
+ # sasl_adapter = MyClient::SASLAdapter.new(self)
34
35
  # SASL::AuthenticationExchange.authenticate(sasl_adapter, ...)
35
36
  # end
36
37
  #
37
- # Likewise, ClientAdapter#authenticate delegates to #authenticate:
38
- # def authenticate(...) = sasl_adapter.authenticate(...)
38
+ # ::authenticate simply delegates to ::build and #authenticate, like so:
39
+ #
40
+ # def authenticate(...)
41
+ # sasl_adapter = MyClient::SASLAdapter.new(self)
42
+ # SASL::AuthenticationExchange
43
+ # .build(sasl_adapter, ...)
44
+ # .authenticate
45
+ # end
46
+ #
47
+ # And ::build delegates to SASL.authenticator and ::new, like so:
48
+ #
49
+ # def authenticate(mechanism, ...)
50
+ # sasl_adapter = MyClient::SASLAdapter.new(self)
51
+ # authenticator = SASL.authenticator(mechanism, ...)
52
+ # SASL::AuthenticationExchange
53
+ # .new(sasl_adapter, mechanism, authenticator)
54
+ # .authenticate
55
+ # end
39
56
  #
40
57
  class AuthenticationExchange
41
58
  # Convenience method for <tt>build(...).authenticate</tt>
59
+ #
60
+ # See also: SASL::ClientAdapter#authenticate
42
61
  def self.authenticate(...) build(...).authenticate end
43
62
 
44
- # Use +registry+ to override the global Authenticators registry.
63
+ # Convenience method to combine the creation of a new authenticator and
64
+ # a new Authentication exchange.
65
+ #
66
+ # +client+ must be an instance of SASL::ClientAdapter.
67
+ #
68
+ # +mechanism+ must be a SASL mechanism name, as a string or symbol.
69
+ #
70
+ # +sasl_ir+ allows or disallows sending an "initial response", depending
71
+ # also on whether the server capabilities, mechanism authenticator, and
72
+ # client adapter all support it. Defaults to +true+.
73
+ #
74
+ # +mechanism+, +args+, +kwargs+, and +block+ are all forwarded to
75
+ # SASL.authenticator. Use the +registry+ kwarg to override the global
76
+ # SASL::Authenticators registry.
45
77
  def self.build(client, mechanism, *args, sasl_ir: true, **kwargs, &block)
46
78
  authenticator = SASL.authenticator(mechanism, *args, **kwargs, &block)
47
79
  new(client, mechanism, authenticator, sasl_ir: sasl_ir)
@@ -51,7 +83,7 @@ module Net
51
83
 
52
84
  def initialize(client, mechanism, authenticator, sasl_ir: true)
53
85
  @client = client
54
- @mechanism = -mechanism.to_s.upcase.tr(?_, ?-)
86
+ @mechanism = Authenticators.normalize_name(mechanism)
55
87
  @authenticator = authenticator
56
88
  @sasl_ir = sasl_ir
57
89
  @processed = false
@@ -21,6 +21,10 @@ module Net::IMAP::SASL
21
21
  # ScramSHA1Authenticator for examples.
22
22
  class Authenticators
23
23
 
24
+ # Normalize the mechanism name as an uppercase string, with underscores
25
+ # converted to dashes.
26
+ def self.normalize_name(mechanism) -(mechanism.to_s.upcase.tr(?_, ?-)) end
27
+
24
28
  # Create a new Authenticators registry.
25
29
  #
26
30
  # This class is usually not instantiated directly. Use SASL.authenticators
@@ -65,7 +69,6 @@ module Net::IMAP::SASL
65
69
  # lazily loaded from <tt>Net::IMAP::SASL::#{name}Authenticator</tt> (case is
66
70
  # preserved and non-alphanumeric characters are removed..
67
71
  def add_authenticator(name, authenticator = nil)
68
- key = -name.to_s.upcase.tr(?_, ?-)
69
72
  authenticator ||= begin
70
73
  class_name = "#{name.gsub(/[^a-zA-Z0-9]/, "")}Authenticator".to_sym
71
74
  auth_class = nil
@@ -74,17 +77,18 @@ module Net::IMAP::SASL
74
77
  auth_class.new(*creds, **props, &block)
75
78
  }
76
79
  end
80
+ key = Authenticators.normalize_name(name)
77
81
  @authenticators[key] = authenticator
78
82
  end
79
83
 
80
84
  # Removes the authenticator registered for +name+
81
85
  def remove_authenticator(name)
82
- key = -name.to_s.upcase.tr(?_, ?-)
86
+ key = Authenticators.normalize_name(name)
83
87
  @authenticators.delete(key)
84
88
  end
85
89
 
86
90
  def mechanism?(name)
87
- key = -name.to_s.upcase.tr(?_, ?-)
91
+ key = Authenticators.normalize_name(name)
88
92
  @authenticators.key?(key)
89
93
  end
90
94
 
@@ -105,7 +109,7 @@ module Net::IMAP::SASL
105
109
  # only. Protocol client users should see refer to their client's
106
110
  # documentation, e.g. Net::IMAP#authenticate.
107
111
  def authenticator(mechanism, ...)
108
- key = -mechanism.to_s.upcase.tr(?_, ?-)
112
+ key = Authenticators.normalize_name(mechanism)
109
113
  auth = @authenticators.fetch(key) do
110
114
  raise ArgumentError, 'unknown auth type - "%s"' % key
111
115
  end