diff --git a/.github/workflows/push_gem.yml b/.github/workflows/push_gem.yml index b5a7bbf9..a085de5c 100644 --- a/.github/workflows/push_gem.yml +++ b/.github/workflows/push_gem.yml @@ -24,7 +24,7 @@ jobs: steps: # Set up - name: Harden Runner - uses: step-security/harden-runner@0080882f6c36860b6ba35c610c98ce87d4e2f26f # v2.10.2 + uses: step-security/harden-runner@cb605e52c26070c328afc4562f0b4ada7618a84e # v2.10.4 with: egress-policy: audit diff --git a/Gemfile b/Gemfile index cbceddc9..4b1dfca8 100644 --- a/Gemfile +++ b/Gemfile @@ -8,6 +8,7 @@ gem "digest" gem "strscan" gem "base64" +gem "irb" gem "rake" gem "rdoc" gem "test-unit" diff --git a/lib/net/imap.rb b/lib/net/imap.rb index 7c8e0506..1729f0b7 100644 --- a/lib/net/imap.rb +++ b/lib/net/imap.rb @@ -744,7 +744,7 @@ module Net # * {IMAP URLAUTH Authorization Mechanism Registry}[https://www.iana.org/assignments/urlauth-authorization-mechanism-registry/urlauth-authorization-mechanism-registry.xhtml] # class IMAP < Protocol - VERSION = "0.5.5" + VERSION = "0.5.6" # Aliases for supported capabilities, to be used with the #enable command. ENABLE_ALIASES = { @@ -1239,13 +1239,21 @@ def logout! # def starttls(**options) @ssl_ctx_params, @ssl_ctx = build_ssl_ctx(options) - send_command("STARTTLS") do |resp| + error = nil + ok = send_command("STARTTLS") do |resp| if resp.kind_of?(TaggedResponse) && resp.name == "OK" clear_cached_capabilities clear_responses start_tls_session end + rescue Exception => error + raise # note that the error backtrace is in the receiver_thread end + if error + disconnect + raise error + end + ok end # :call-seq: diff --git a/lib/net/imap/config.rb b/lib/net/imap/config.rb index edeea30e..1e0300c5 100644 --- a/lib/net/imap/config.rb +++ b/lib/net/imap/config.rb @@ -287,6 +287,67 @@ def self.[](config) # # Alias for responses_without_block + # Whether ResponseParser should use the deprecated UIDPlusData or + # CopyUIDData for +COPYUID+ response codes, and UIDPlusData or + # AppendUIDData for +APPENDUID+ response codes. + # + # UIDPlusData stores its data in arrays of numbers, which is vulnerable to + # a memory exhaustion denial of service attack from an untrusted or + # compromised server. Set this option to +false+ to completely block this + # vulnerability. Otherwise, parser_max_deprecated_uidplus_data_size + # mitigates this vulnerability. + # + # AppendUIDData and CopyUIDData are _mostly_ backward-compatible with + # UIDPlusData. Most applications should be able to upgrade with little + # or no changes. + # + # (Parser support for +UIDPLUS+ added in +v0.3.2+.) + # + # (Config option added in +v0.4.19+ and +v0.5.6+.) + # + # UIDPlusData will be removed in +v0.6+ and this config setting will + # be ignored. + # + # ==== Valid options + # + # [+true+ (original default)] + # ResponseParser only uses UIDPlusData. + # + # [+:up_to_max_size+ (default since +v0.5.6+)] + # ResponseParser uses UIDPlusData when the +uid-set+ size is below + # parser_max_deprecated_uidplus_data_size. Above that size, + # ResponseParser uses AppendUIDData or CopyUIDData. + # + # [+false+ (planned default for +v0.6+)] + # ResponseParser _only_ uses AppendUIDData and CopyUIDData. + attr_accessor :parser_use_deprecated_uidplus_data, type: [ + true, :up_to_max_size, false + ] + + # The maximum +uid-set+ size that ResponseParser will parse into + # deprecated UIDPlusData. This limit only applies when + # parser_use_deprecated_uidplus_data is not +false+. + # + # (Parser support for +UIDPLUS+ added in +v0.3.2+.) + # + # Support for limiting UIDPlusData to a maximum size was added in + # +v0.3.8+, +v0.4.19+, and +v0.5.6+. + # + # UIDPlusData will be removed in +v0.6+. + # + # ==== Versioned Defaults + # + # Because this limit guards against a remote server causing catastrophic + # memory exhaustion, the versioned default (used by #load_defaults) also + # applies to versions without the feature. + # + # * +0.3+ and prior: 10,000 + # * +0.4+: 1,000 + # * +0.5+: 100 + # * +0.6+: 0 + # + attr_accessor :parser_max_deprecated_uidplus_data_size, type: Integer + # Creates a new config object and initialize its attribute with +attrs+. # # If +parent+ is not given, the global config is used by default. @@ -367,6 +428,8 @@ def defaults_hash sasl_ir: true, enforce_logindisabled: true, responses_without_block: :warn, + parser_use_deprecated_uidplus_data: :up_to_max_size, + parser_max_deprecated_uidplus_data_size: 100, ).freeze @global = default.new @@ -378,6 +441,8 @@ def defaults_hash sasl_ir: false, responses_without_block: :silence_deprecation_warning, enforce_logindisabled: false, + parser_use_deprecated_uidplus_data: true, + parser_max_deprecated_uidplus_data_size: 10_000, ).freeze version_defaults[0.0] = Config[0] version_defaults[0.1] = Config[0] @@ -386,12 +451,15 @@ def defaults_hash version_defaults[0.4] = Config[0.3].dup.update( sasl_ir: true, + parser_max_deprecated_uidplus_data_size: 1000, ).freeze version_defaults[0.5] = Config[:current] version_defaults[0.6] = Config[0.5].dup.update( responses_without_block: :frozen_dup, + parser_use_deprecated_uidplus_data: false, + parser_max_deprecated_uidplus_data_size: 0, ).freeze version_defaults[:next] = Config[0.6] version_defaults[:future] = Config[:next] diff --git a/lib/net/imap/response_data.rb b/lib/net/imap/response_data.rb index 16ff306c..d862deaa 100644 --- a/lib/net/imap/response_data.rb +++ b/lib/net/imap/response_data.rb @@ -7,6 +7,9 @@ class IMAP < Protocol autoload :UIDFetchData, "#{__dir__}/fetch_data" autoload :SearchResult, "#{__dir__}/search_result" autoload :SequenceSet, "#{__dir__}/sequence_set" + autoload :UIDPlusData, "#{__dir__}/uidplus_data" + autoload :AppendUIDData, "#{__dir__}/uidplus_data" + autoload :CopyUIDData, "#{__dir__}/uidplus_data" autoload :VanishedData, "#{__dir__}/vanished_data" # Net::IMAP::ContinuationRequest represents command continuation requests. @@ -344,55 +347,6 @@ class ResponseCode < Struct.new(:name, :data) # code data can take. end - # UIDPlusData represents the ResponseCode#data that accompanies the - # +APPENDUID+ and +COPYUID+ {response codes}[rdoc-ref:ResponseCode]. - # - # A server that supports +UIDPLUS+ should send a UIDPlusData object inside - # every TaggedResponse returned by the append[rdoc-ref:Net::IMAP#append], - # copy[rdoc-ref:Net::IMAP#copy], move[rdoc-ref:Net::IMAP#move], {uid - # copy}[rdoc-ref:Net::IMAP#uid_copy], and {uid - # move}[rdoc-ref:Net::IMAP#uid_move] commands---unless the destination - # mailbox reports +UIDNOTSTICKY+. - # - # == Required capability - # Requires either +UIDPLUS+ [RFC4315[https://www.rfc-editor.org/rfc/rfc4315]] - # or +IMAP4rev2+ capability. - # - class UIDPlusData < Struct.new(:uidvalidity, :source_uids, :assigned_uids) - ## - # method: uidvalidity - # :call-seq: uidvalidity -> nonzero uint32 - # - # The UIDVALIDITY of the destination mailbox. - - ## - # method: source_uids - # :call-seq: source_uids -> nil or an array of nonzero uint32 - # - # The UIDs of the copied or moved messages. - # - # Note:: Returns +nil+ for Net::IMAP#append. - - ## - # method: assigned_uids - # :call-seq: assigned_uids -> an array of nonzero uint32 - # - # The newly assigned UIDs of the copied, moved, or appended messages. - # - # Note:: This always returns an array, even when it contains only one UID. - - ## - # :call-seq: uid_mapping -> nil or a hash - # - # Returns a hash mapping each source UID to the newly assigned destination - # UID. - # - # Note:: Returns +nil+ for Net::IMAP#append. - def uid_mapping - source_uids&.zip(assigned_uids)&.to_h - end - end - # MailboxList represents the data of an untagged +LIST+ response, for a # _single_ mailbox path. IMAP#list returns an array of MailboxList objects. # diff --git a/lib/net/imap/response_parser.rb b/lib/net/imap/response_parser.rb index f6dc438a..03e54b20 100644 --- a/lib/net/imap/response_parser.rb +++ b/lib/net/imap/response_parser.rb @@ -13,13 +13,17 @@ class ResponseParser attr_reader :config - # :call-seq: Net::IMAP::ResponseParser.new -> Net::IMAP::ResponseParser + # Creates a new ResponseParser. + # + # When +config+ is frozen or global, the parser #config inherits from it. + # Otherwise, +config+ will be used directly. def initialize(config: Config.global) @str = nil @pos = nil @lex_state = nil @token = nil @config = Config[config] + @config = @config.new if @config == Config.global || @config.frozen? end # :call-seq: @@ -1997,11 +2001,10 @@ def charset__list # # n.b, uniqueid ⊂ uid-set. To avoid inconsistent return types, we always # match uid_set even if that returns a single-member array. - # def resp_code_apnd__data validity = number; SP! dst_uids = uid_set # uniqueid ⊂ uid-set - UIDPlusData.new(validity, nil, dst_uids) + AppendUID(validity, dst_uids) end # already matched: "COPYUID" @@ -2011,7 +2014,25 @@ def resp_code_copy__data validity = number; SP! src_uids = uid_set; SP! dst_uids = uid_set - UIDPlusData.new(validity, src_uids, dst_uids) + CopyUID(validity, src_uids, dst_uids) + end + + def AppendUID(...) DeprecatedUIDPlus(...) || AppendUIDData.new(...) end + def CopyUID(...) DeprecatedUIDPlus(...) || CopyUIDData.new(...) end + + # TODO: remove this code in the v0.6.0 release + def DeprecatedUIDPlus(validity, src_uids = nil, dst_uids) + return unless config.parser_use_deprecated_uidplus_data + compact_uid_sets = [src_uids, dst_uids].compact + count = compact_uid_sets.map { _1.count_with_duplicates }.max + max = config.parser_max_deprecated_uidplus_data_size + if count <= max + src_uids &&= src_uids.each_ordered_number.to_a + dst_uids = dst_uids.each_ordered_number.to_a + UIDPlusData.new(validity, src_uids, dst_uids) + elsif config.parser_use_deprecated_uidplus_data != :up_to_max_size + parse_error("uid-set is too large: %d > %d", count, max) + end end ADDRESS_REGEXP = /\G @@ -2137,15 +2158,9 @@ def nparens__objectid; NIL? ? nil : parens__objectid end # uniqueid = nz-number # ; Strictly ascending def uid_set - token = match(T_NUMBER, T_ATOM) - case token.symbol - when T_NUMBER then [Integer(token.value)] - when T_ATOM - token.value.split(",").flat_map {|range| - range = range.split(":").map {|uniqueid| Integer(uniqueid) } - range.size == 1 ? range : Range.new(range.min, range.max).to_a - } - end + set = sequence_set + parse_error("uid-set cannot contain '*'") if set.include_star? + set end def nil_atom diff --git a/lib/net/imap/sequence_set.rb b/lib/net/imap/sequence_set.rb index ea8b0a67..02cd150d 100644 --- a/lib/net/imap/sequence_set.rb +++ b/lib/net/imap/sequence_set.rb @@ -56,18 +56,20 @@ class IMAP # set = Net::IMAP::SequenceSet[1, 2, [3..7, 5], 6..10, 2048, 1024] # set.valid_string #=> "1:10,55,1024:2048" # - # == Normalized form + # == Ordered and Normalized sets # - # When a sequence set is created with a single String value, that #string - # representation is preserved. SequenceSet's internal representation - # implicitly sorts all entries, de-duplicates numbers, and coalesces - # adjacent or overlapping ranges. Most enumeration methods and offset-based - # methods use this normalized representation. Most modification methods - # will convert #string to its normalized form. + # Sometimes the order of the set's members is significant, such as with the + # +ESORT+, CONTEXT=SORT, and +UIDPLUS+ extensions. So, when a + # sequence set is created by the parser or with a single string value, that + # #string representation is preserved. # - # In some cases the order of the string representation is significant, such - # as the +ESORT+, CONTEXT=SORT, and +UIDPLUS+ extensions. Use - # #entries or #each_entry to enumerate the set in its original order. To + # Internally, SequenceSet stores a normalized representation which sorts all + # entries, de-duplicates numbers, and coalesces adjacent or overlapping + # ranges. Most methods use this normalized representation to achieve + # O(lg n) porformance. Use #entries or #each_entry to enumerate + # the set in its original order. + # + # Most modification methods convert #string to its normalized form. To # preserve #string order while modifying a set, use #append, #string=, or # #replace. # @@ -160,7 +162,7 @@ class IMAP # - #===: # Returns whether a given object is fully contained within +self+, or # +nil+ if the object cannot be converted to a compatible type. - # - #cover? (aliased as #===): + # - #cover?: # Returns whether a given object is fully contained within +self+. # - #intersect? (aliased as #overlap?): # Returns whether +self+ and a given object have any common elements. @@ -181,30 +183,41 @@ class IMAP # - #max: Returns the maximum number in the set. # - #minmax: Returns the minimum and maximum numbers in the set. # - # Accessing value by offset: + # Accessing value by offset in sorted set: # - #[] (aliased as #slice): Returns the number or consecutive subset at a - # given offset or range of offsets. - # - #at: Returns the number at a given offset. - # - #find_index: Returns the given number's offset in the set + # given offset or range of offsets in the sorted set. + # - #at: Returns the number at a given offset in the sorted set. + # - #find_index: Returns the given number's offset in the sorted set. + # + # Accessing value by offset in ordered entries + # - #ordered_at: Returns the number at a given offset in the ordered entries. + # - #find_ordered_index: Returns the index of the given number's first + # occurrence in entries. # # Set cardinality: # - #count (aliased as #size): Returns the count of numbers in the set. + # Duplicated numbers are not counted. # - #empty?: Returns whether the set has no members. \IMAP syntax does not # allow empty sequence sets. # - #valid?: Returns whether the set has any members. # - #full?: Returns whether the set contains every possible value, including # *. # + # Denormalized properties: + # - #has_duplicates?: Returns whether the ordered entries repeat any + # numbers. + # - #count_duplicates: Returns the count of repeated numbers in the ordered + # entries. + # - #count_with_duplicates: Returns the count of numbers in the ordered + # entries, including any repeated numbers. + # # === Methods for Iterating # + # Normalized (sorted and coalesced): # - #each_element: Yields each number and range in the set, sorted and # coalesced, and returns +self+. # - #elements (aliased as #to_a): Returns an Array of every number and range # in the set, sorted and coalesced. - # - #each_entry: Yields each number and range in the set, unsorted and - # without deduplicating numbers or coalescing ranges, and returns +self+. - # - #entries: Returns an Array of every number and range in the set, - # unsorted and without deduplicating numbers or coalescing ranges. # - #each_range: # Yields each element in the set as a Range and returns +self+. # - #ranges: Returns an Array of every element in the set, converting @@ -214,6 +227,14 @@ class IMAP # ranges into all of their contained numbers. # - #to_set: Returns a Set containing all of the #numbers in the set. # + # Order preserving: + # - #each_entry: Yields each number and range in the set, unsorted and + # without deduplicating numbers or coalescing ranges, and returns +self+. + # - #entries: Returns an Array of every number and range in the set, + # unsorted and without deduplicating numbers or coalescing ranges. + # - #each_ordered_number: Yields each number in the ordered entries and + # returns +self+. + # # === Methods for \Set Operations # These methods do not modify +self+. # @@ -233,19 +254,29 @@ class IMAP # === Methods for Assigning # These methods add or replace elements in +self+. # + # Normalized (sorted and coalesced): + # + # These methods always update #string to be fully sorted and coalesced. + # # - #add (aliased as #<<): Adds a given object to the set; returns +self+. # - #add?: If the given object is not an element in the set, adds it and # returns +self+; otherwise, returns +nil+. # - #merge: Merges multiple elements into the set; returns +self+. + # - #complement!: Replaces the contents of the set with its own #complement. + # + # Order preserving: + # + # These methods _may_ cause #string to not be sorted or coalesced. + # # - #append: Adds a given object to the set, appending it to the existing # string, and returns +self+. # - #string=: Assigns a new #string value and replaces #elements to match. # - #replace: Replaces the contents of the set with the contents # of a given object. - # - #complement!: Replaces the contents of the set with its own #complement. # # === Methods for Deleting - # These methods remove elements from +self+. + # These methods remove elements from +self+, and update #string to be fully + # sorted and coalesced. # # - #clear: Removes all elements in the set; returns +self+. # - #delete: Removes a given object from the set; returns +self+. @@ -681,8 +712,9 @@ def append(object) modifying! tuple = input_to_tuple object entry = tuple_to_str tuple + string unless empty? # write @string before tuple_add tuple_add tuple - @string = -(string ? "#{@string},#{entry}" : entry) + @string = -(@string ? "#{@string},#{entry}" : entry) self end @@ -838,8 +870,8 @@ def entries; each_entry.to_a end # * translates to an endless range. Use #limit to translate both # cases to a maximum value. # - # If the original input was unordered or contains overlapping ranges, the - # returned ranges will be ordered and coalesced. + # The returned elements will be sorted and coalesced, even when the input + # #string is not. * will sort last. See #normalize. # # Net::IMAP::SequenceSet["2,5:9,6,*,12:11"].elements # #=> [2, 5..9, 11..12, :*] @@ -857,7 +889,7 @@ def elements; each_element.to_a end # translates to :*... Use #limit to set * to a maximum # value. # - # The returned ranges will be ordered and coalesced, even when the input + # The returned ranges will be sorted and coalesced, even when the input # #string is not. * will sort last. See #normalize. # # Net::IMAP::SequenceSet["2,5:9,6,*,12:11"].ranges @@ -906,9 +938,7 @@ def numbers; each_number.to_a end # Related: #entries, #each_element def each_entry(&block) # :yields: integer or range or :* return to_enum(__method__) unless block_given? - return each_element(&block) unless @string - @string.split(",").each do yield tuple_to_entry str_to_tuple _1 end - self + each_entry_tuple do yield tuple_to_entry _1 end end # Yields each number or range (or :*) in #elements to the block @@ -926,6 +956,16 @@ def each_element # :yields: integer or range or :* private + def each_entry_tuple(&block) + return to_enum(__method__) unless block_given? + if @string + @string.split(",") do block.call str_to_tuple _1 end + else + @tuples.each(&block) + end + self + end + def tuple_to_entry((min, max)) if min == STAR_INT then :* elsif max == STAR_INT then min.. @@ -957,19 +997,36 @@ def each_range # :yields: range # Returns an enumerator when called without a block (even if the set # contains *). # - # Related: #numbers + # Related: #numbers, #each_ordered_number def each_number(&block) # :yields: integer return to_enum(__method__) unless block_given? raise RangeError, '%s contains "*"' % [self.class] if include_star? - each_element do |elem| - case elem - when Range then elem.each(&block) - when Integer then block.(elem) - end - end + @tuples.each do each_number_in_tuple _1, _2, &block end self end + # Yields each number in #entries to the block and returns self. + # If the set contains a *, RangeError will be raised. + # + # Returns an enumerator when called without a block (even if the set + # contains *). + # + # Related: #entries, #each_number + def each_ordered_number(&block) + return to_enum(__method__) unless block_given? + raise RangeError, '%s contains "*"' % [self.class] if include_star? + each_entry_tuple do each_number_in_tuple _1, _2, &block end + end + + private def each_number_in_tuple(min, max, &block) + if min == STAR_INT then yield :* + elsif min == max then yield min + elsif max != STAR_INT then (min..max).each(&block) + else + raise RangeError, "#{SequenceSet} cannot enumerate range with '*'" + end + end + # Returns a Set with all of the #numbers in the sequence set. # # If the set contains a *, RangeError will be raised. @@ -981,8 +1038,10 @@ def to_set; Set.new(numbers) end # Returns the count of #numbers in the set. # - # If * and 2**32 - 1 (the maximum 32-bit unsigned - # integer value) are both in the set, they will only be counted once. + # * will be counted as 2**32 - 1 (the maximum 32-bit + # unsigned integer value). + # + # Related: #count_with_duplicates def count @tuples.sum(@tuples.count) { _2 - _1 } + (include_star? && include?(UINT32_MAX) ? -1 : 0) @@ -990,33 +1049,87 @@ def count alias size count - # Returns the index of +number+ in the set, or +nil+ if +number+ isn't in - # the set. + # Returns the count of numbers in the ordered #entries, including any + # repeated numbers. + # + # * will be counted as 2**32 - 1 (the maximum 32-bit + # unsigned integer value). + # + # When #string is normalized, this behaves the same as #count. # - # Related: #[] + # Related: #entries, #count_duplicates, #has_duplicates? + def count_with_duplicates + return count unless @string + each_entry_tuple.sum {|min, max| + max - min + ((max == STAR_INT && min != STAR_INT) ? 0 : 1) + } + end + + # Returns the count of repeated numbers in the ordered #entries, the + # difference between #count_with_duplicates and #count. + # + # When #string is normalized, this is zero. + # + # Related: #entries, #count_with_duplicates, #has_duplicates? + def count_duplicates + return 0 unless @string + count_with_duplicates - count + end + + # :call-seq: has_duplicates? -> true | false + # + # Returns whether or not the ordered #entries repeat any numbers. + # + # Always returns +false+ when #string is normalized. + # + # Related: #entries, #count_with_duplicates, #count_duplicates? + def has_duplicates? + return false unless @string + count_with_duplicates != count + end + + # Returns the (sorted and deduplicated) index of +number+ in the set, or + # +nil+ if +number+ isn't in the set. + # + # Related: #[], #at, #find_ordered_index def find_index(number) number = to_tuple_int number - each_tuple_with_index do |min, max, idx_min| + each_tuple_with_index(@tuples) do |min, max, idx_min| number < min and return nil number <= max and return from_tuple_int(idx_min + (number - min)) end nil end + # Returns the first index of +number+ in the ordered #entries, or + # +nil+ if +number+ isn't in the set. + # + # Related: #find_index + def find_ordered_index(number) + number = to_tuple_int number + each_tuple_with_index(each_entry_tuple) do |min, max, idx_min| + if min <= number && number <= max + return from_tuple_int(idx_min + (number - min)) + end + end + nil + end + private - def each_tuple_with_index + def each_tuple_with_index(tuples) idx_min = 0 - @tuples.each do |min, max| - yield min, max, idx_min, (idx_max = idx_min + (max - min)) + tuples.each do |min, max| + idx_max = idx_min + (max - min) + yield min, max, idx_min, idx_max idx_min = idx_max + 1 end idx_min end - def reverse_each_tuple_with_index + def reverse_each_tuple_with_index(tuples) idx_max = -1 - @tuples.reverse_each do |min, max| + tuples.reverse_each do |min, max| yield min, max, (idx_min = idx_max - (max - min)), idx_max idx_max = idx_min - 1 end @@ -1027,18 +1140,38 @@ def reverse_each_tuple_with_index # :call-seq: at(index) -> integer or nil # - # Returns a number from +self+, without modifying the set. Behaves the - # same as #[], except that #at only allows a single integer argument. + # Returns the number at the given +index+ in the sorted set, without + # modifying the set. # - # Related: #[], #slice + # +index+ is interpreted the same as in #[], except that #at only allows a + # single integer argument. + # + # Related: #[], #slice, #ordered_at def at(index) + lookup_number_by_tuple_index(tuples, index) + end + + # :call-seq: ordered_at(index) -> integer or nil + # + # Returns the number at the given +index+ in the ordered #entries, without + # modifying the set. + # + # +index+ is interpreted the same as in #at (and #[]), except that + # #ordered_at applies to the ordered #entries, not the sorted set. + # + # Related: #[], #slice, #ordered_at + def ordered_at(index) + lookup_number_by_tuple_index(each_entry_tuple, index) + end + + private def lookup_number_by_tuple_index(tuples, index) index = Integer(index.to_int) if index.negative? - reverse_each_tuple_with_index do |min, max, idx_min, idx_max| + reverse_each_tuple_with_index(tuples) do |min, max, idx_min, idx_max| idx_min <= index and return from_tuple_int(min + (index - idx_min)) end else - each_tuple_with_index do |min, _, idx_min, idx_max| + each_tuple_with_index(tuples) do |min, _, idx_min, idx_max| index <= idx_max and return from_tuple_int(min + (index - idx_min)) end end @@ -1053,17 +1186,18 @@ def at(index) # seqset[range] -> sequence set or nil # slice(range) -> sequence set or nil # - # Returns a number or a subset from +self+, without modifying the set. + # Returns a number or a subset from the _sorted_ set, without modifying + # the set. # # When an Integer argument +index+ is given, the number at offset +index+ - # is returned: + # in the sorted set is returned: # # set = Net::IMAP::SequenceSet["10:15,20:23,26"] # set[0] #=> 10 # set[5] #=> 15 # set[10] #=> 26 # - # If +index+ is negative, it counts relative to the end of +self+: + # If +index+ is negative, it counts relative to the end of the sorted set: # set = Net::IMAP::SequenceSet["10:15,20:23,26"] # set[-1] #=> 26 # set[-3] #=> 22 @@ -1075,13 +1209,14 @@ def at(index) # set[11] #=> nil # set[-12] #=> nil # - # The result is based on the normalized set—sorted and de-duplicated—not - # on the assigned value of #string. + # The result is based on the sorted and de-duplicated set, not on the + # ordered #entries in #string. # # set = Net::IMAP::SequenceSet["12,20:23,11:16,21"] # set[0] #=> 11 # set[-1] #=> 23 # + # Related: #at def [](index, length = nil) if length then slice_length(index, length) elsif index.is_a?(Range) then slice_range(index) @@ -1337,8 +1472,8 @@ def tuple_add(tuple) modifying! min, max = tuple lower, lower_idx = tuple_gte_with_index(min - 1) - if lower.nil? then tuples << tuple - elsif (max + 1) < lower.first then tuples.insert(lower_idx, tuple) + if lower.nil? then tuples << [min, max] + elsif (max + 1) < lower.first then tuples.insert(lower_idx, [min, max]) else tuple_coalesce(lower, lower_idx, min, max) end end diff --git a/lib/net/imap/uidplus_data.rb b/lib/net/imap/uidplus_data.rb new file mode 100644 index 00000000..0e593636 --- /dev/null +++ b/lib/net/imap/uidplus_data.rb @@ -0,0 +1,244 @@ +# frozen_string_literal: true + +module Net + class IMAP < Protocol + + # *NOTE:* UIDPlusData is deprecated and will be removed in the +0.6.0+ + # release. To use AppendUIDData and CopyUIDData before +0.6.0+, set + # Config#parser_use_deprecated_uidplus_data to +false+. + # + # UIDPlusData represents the ResponseCode#data that accompanies the + # +APPENDUID+ and +COPYUID+ {response codes}[rdoc-ref:ResponseCode]. + # + # A server that supports +UIDPLUS+ should send UIDPlusData in response to + # the append[rdoc-ref:Net::IMAP#append], copy[rdoc-ref:Net::IMAP#copy], + # move[rdoc-ref:Net::IMAP#move], {uid copy}[rdoc-ref:Net::IMAP#uid_copy], + # and {uid move}[rdoc-ref:Net::IMAP#uid_move] commands---unless the + # destination mailbox reports +UIDNOTSTICKY+. + # + # Note that append[rdoc-ref:Net::IMAP#append], copy[rdoc-ref:Net::IMAP#copy] + # and {uid_copy}[rdoc-ref:Net::IMAP#uid_copy] return UIDPlusData in their + # TaggedResponse. But move[rdoc-ref:Net::IMAP#copy] and + # {uid_move}[rdoc-ref:Net::IMAP#uid_move] _should_ send UIDPlusData in an + # UntaggedResponse response before sending their TaggedResponse. However + # some servers do send UIDPlusData in the TaggedResponse for +MOVE+ + # commands---this complies with the older +UIDPLUS+ specification but is + # discouraged by the +MOVE+ extension and disallowed by +IMAP4rev2+. + # + # == Required capability + # Requires either +UIDPLUS+ [RFC4315[https://www.rfc-editor.org/rfc/rfc4315]] + # or +IMAP4rev2+ capability. + # + class UIDPlusData < Struct.new(:uidvalidity, :source_uids, :assigned_uids) + ## + # method: uidvalidity + # :call-seq: uidvalidity -> nonzero uint32 + # + # The UIDVALIDITY of the destination mailbox. + + ## + # method: source_uids + # :call-seq: source_uids -> nil or an array of nonzero uint32 + # + # The UIDs of the copied or moved messages. + # + # Note:: Returns +nil+ for Net::IMAP#append. + + ## + # method: assigned_uids + # :call-seq: assigned_uids -> an array of nonzero uint32 + # + # The newly assigned UIDs of the copied, moved, or appended messages. + # + # Note:: This always returns an array, even when it contains only one UID. + + ## + # :call-seq: uid_mapping -> nil or a hash + # + # Returns a hash mapping each source UID to the newly assigned destination + # UID. + # + # Note:: Returns +nil+ for Net::IMAP#append. + def uid_mapping + source_uids&.zip(assigned_uids)&.to_h + end + end + + # >>> + # *NOTE:* AppendUIDData will replace UIDPlusData for +APPENDUID+ in the + # +0.6.0+ release. To use AppendUIDData before +0.6.0+, set + # Config#parser_use_deprecated_uidplus_data to +false+. + # + # AppendUIDData represents the ResponseCode#data that accompanies the + # +APPENDUID+ {response code}[rdoc-ref:ResponseCode]. + # + # A server that supports +UIDPLUS+ (or +IMAP4rev2+) should send + # AppendUIDData inside every TaggedResponse returned by the + # append[rdoc-ref:Net::IMAP#append] command---unless the target mailbox + # reports +UIDNOTSTICKY+. + # + # == Required capability + # Requires either +UIDPLUS+ [RFC4315[https://www.rfc-editor.org/rfc/rfc4315]] + # or +IMAP4rev2+ capability. + class AppendUIDData < Data.define(:uidvalidity, :assigned_uids) + def initialize(uidvalidity:, assigned_uids:) + uidvalidity = Integer(uidvalidity) + assigned_uids = SequenceSet[assigned_uids] + NumValidator.ensure_nz_number(uidvalidity) + if assigned_uids.include_star? + raise DataFormatError, "uid-set cannot contain '*'" + end + super + end + + ## + # attr_reader: uidvalidity + # :call-seq: uidvalidity -> nonzero uint32 + # + # The UIDVALIDITY of the destination mailbox. + + ## + # attr_reader: assigned_uids + # + # A SequenceSet with the newly assigned UIDs of the appended messages. + + # Returns the number of messages that have been appended. + def size + assigned_uids.count_with_duplicates + end + end + + # >>> + # *NOTE:* CopyUIDData will replace UIDPlusData for +COPYUID+ in the + # +0.6.0+ release. To use CopyUIDData before +0.6.0+, set + # Config#parser_use_deprecated_uidplus_data to +false+. + # + # CopyUIDData represents the ResponseCode#data that accompanies the + # +COPYUID+ {response code}[rdoc-ref:ResponseCode]. + # + # A server that supports +UIDPLUS+ (or +IMAP4rev2+) should send CopyUIDData + # in response to + # copy[rdoc-ref:Net::IMAP#copy], {uid_copy}[rdoc-ref:Net::IMAP#uid_copy], + # move[rdoc-ref:Net::IMAP#copy], and {uid_move}[rdoc-ref:Net::IMAP#uid_move] + # commands---unless the destination mailbox reports +UIDNOTSTICKY+. + # + # Note that copy[rdoc-ref:Net::IMAP#copy] and + # {uid_copy}[rdoc-ref:Net::IMAP#uid_copy] return CopyUIDData in their + # TaggedResponse. But move[rdoc-ref:Net::IMAP#copy] and + # {uid_move}[rdoc-ref:Net::IMAP#uid_move] _should_ send CopyUIDData in an + # UntaggedResponse response before sending their TaggedResponse. However + # some servers do send CopyUIDData in the TaggedResponse for +MOVE+ + # commands---this complies with the older +UIDPLUS+ specification but is + # discouraged by the +MOVE+ extension and disallowed by +IMAP4rev2+. + # + # == Required capability + # Requires either +UIDPLUS+ [RFC4315[https://www.rfc-editor.org/rfc/rfc4315]] + # or +IMAP4rev2+ capability. + class CopyUIDData < Data.define(:uidvalidity, :source_uids, :assigned_uids) + def initialize(uidvalidity:, source_uids:, assigned_uids:) + uidvalidity = Integer(uidvalidity) + source_uids = SequenceSet[source_uids] + assigned_uids = SequenceSet[assigned_uids] + NumValidator.ensure_nz_number(uidvalidity) + if source_uids.include_star? || assigned_uids.include_star? + raise DataFormatError, "uid-set cannot contain '*'" + elsif source_uids.count_with_duplicates != assigned_uids.count_with_duplicates + raise DataFormatError, "mismatched uid-set sizes for %s and %s" % [ + source_uids, assigned_uids + ] + end + super + end + + ## + # attr_reader: uidvalidity + # + # The +UIDVALIDITY+ of the destination mailbox (a nonzero unsigned 32 bit + # integer). + + ## + # attr_reader: source_uids + # + # A SequenceSet with the original UIDs of the copied or moved messages. + + ## + # attr_reader: assigned_uids + # + # A SequenceSet with the newly assigned UIDs of the copied or moved + # messages. + + # Returns the number of messages that have been copied or moved. + # source_uids and the assigned_uids will both the same number of UIDs. + def size + assigned_uids.count_with_duplicates + end + + # :call-seq: + # assigned_uid_for(source_uid) -> uid + # self[source_uid] -> uid + # + # Returns the UID in the destination mailbox for the message that was + # copied from +source_uid+ in the source mailbox. + # + # This is the reverse of #source_uid_for. + # + # Related: source_uid_for, each_uid_pair, uid_mapping + def assigned_uid_for(source_uid) + idx = source_uids.find_ordered_index(source_uid) and + assigned_uids.ordered_at(idx) + end + alias :[] :assigned_uid_for + + # :call-seq: + # source_uid_for(assigned_uid) -> uid + # + # Returns the UID in the source mailbox for the message that was copied to + # +assigned_uid+ in the source mailbox. + # + # This is the reverse of #assigned_uid_for. + # + # Related: assigned_uid_for, each_uid_pair, uid_mapping + def source_uid_for(assigned_uid) + idx = assigned_uids.find_ordered_index(assigned_uid) and + source_uids.ordered_at(idx) + end + + # Yields a pair of UIDs for each copied message. The first is the + # message's UID in the source mailbox and the second is the UID in the + # destination mailbox. + # + # Returns an enumerator when no block is given. + # + # Please note the warning on uid_mapping before calling methods like + # +to_h+ or +to_a+ on the returned enumerator. + # + # Related: uid_mapping, assigned_uid_for, source_uid_for + def each_uid_pair + return enum_for(__method__) unless block_given? + source_uids.each_ordered_number.lazy + .zip(assigned_uids.each_ordered_number.lazy) do + |source_uid, assigned_uid| + yield source_uid, assigned_uid + end + end + alias each_pair each_uid_pair + alias each each_uid_pair + + # :call-seq: uid_mapping -> hash + # + # Returns a hash mapping each source UID to the newly assigned destination + # UID. + # + # *Warning:* The hash that is created may consume _much_ more + # memory than the data used to create it. When handling responses from an + # untrusted server, check #size before calling this method. + # + # Related: each_uid_pair, assigned_uid_for, source_uid_for + def uid_mapping + each_uid_pair.to_h + end + + end + + end +end diff --git a/test/net/imap/fake_server/test_helper.rb b/test/net/imap/fake_server/test_helper.rb index eee6a153..1987d048 100644 --- a/test/net/imap/fake_server/test_helper.rb +++ b/test/net/imap/fake_server/test_helper.rb @@ -4,7 +4,7 @@ module Net::IMAP::FakeServer::TestHelper - def run_fake_server_in_thread(ignore_io_error: false, timeout: 5, **opts) + def run_fake_server_in_thread(ignore_io_error: false, timeout: 10, **opts) Timeout.timeout(timeout) do server = Net::IMAP::FakeServer.new(timeout: timeout, **opts) @threads << Thread.new do diff --git a/test/net/imap/test_imap.rb b/test/net/imap/test_imap.rb index f3e53c40..dd955b8d 100644 --- a/test/net/imap/test_imap.rb +++ b/test/net/imap/test_imap.rb @@ -46,14 +46,18 @@ def test_imaps_with_ca_file # Otherwise, failures can't logout and need to wait for the timeout. verified, imap = :unknown, nil assert_nothing_raised do - imaps_test do |port| - imap = Net::IMAP.new("localhost", - port: port, - ssl: { :ca_file => CA_FILE }) - verified = imap.tls_verified? - imap - rescue SystemCallError - skip $! + begin + imaps_test do |port| + imap = Net::IMAP.new("localhost", + port: port, + ssl: { :ca_file => CA_FILE }) + verified = imap.tls_verified? + imap + rescue SystemCallError + skip $! + end + rescue OpenSSL::SSL::SSLError => e + raise e unless /darwin/ =~ RUBY_PLATFORM end end assert_equal true, verified @@ -69,14 +73,18 @@ def test_imaps_verify_none # Otherwise, failures can't logout and need to wait for the timeout. verified, imap = :unknown, nil assert_nothing_raised do - imaps_test do |port| - imap = Net::IMAP.new( - server_addr, - port: port, - ssl: { :verify_mode => OpenSSL::SSL::VERIFY_NONE } - ) - verified = imap.tls_verified? - imap + begin + imaps_test do |port| + imap = Net::IMAP.new( + server_addr, + port: port, + ssl: { :verify_mode => OpenSSL::SSL::VERIFY_NONE } + ) + verified = imap.tls_verified? + imap + end + rescue OpenSSL::SSL::SSLError => e + raise e unless /darwin/ =~ RUBY_PLATFORM end end assert_equal false, verified @@ -105,17 +113,16 @@ def test_starttls_unknown_ca omit "This test is not working with Windows" if RUBY_PLATFORM =~ /mswin|mingw/ imap = nil - assert_raise(OpenSSL::SSL::SSLError) do - ex = nil - starttls_test do |port| - imap = Net::IMAP.new("localhost", port: port) + ex = nil + starttls_test do |port| + imap = Net::IMAP.new("localhost", port: port) + begin imap.starttls - imap rescue => ex - imap end - raise ex if ex + imap end + assert_kind_of(OpenSSL::SSL::SSLError, ex) assert_equal false, imap.tls_verified? assert_equal({}, imap.ssl_ctx_params) assert_equal(nil, imap.ssl_ctx.ca_file) diff --git a/test/net/imap/test_imap_response_data.rb b/test/net/imap/test_imap_response_data.rb deleted file mode 100644 index 0bee8b9a..00000000 --- a/test/net/imap/test_imap_response_data.rb +++ /dev/null @@ -1,58 +0,0 @@ -# frozen_string_literal: true - -require "net/imap" -require "test/unit" - -class IMAPResponseDataTest < Test::Unit::TestCase - - def setup - Net::IMAP.config.reset - @do_not_reverse_lookup = Socket.do_not_reverse_lookup - Socket.do_not_reverse_lookup = true - end - - def teardown - Socket.do_not_reverse_lookup = @do_not_reverse_lookup - end - - def test_uidplus_copyuid__uid_mapping - parser = Net::IMAP::ResponseParser.new - response = parser.parse( - "A004 OK [copyUID 9999 20:19,500:495 92:97,101:100] Done\r\n" - ) - code = response.data.code - assert_equal( - { - 19 => 92, - 20 => 93, - 495 => 94, - 496 => 95, - 497 => 96, - 498 => 97, - 499 => 100, - 500 => 101, - }, - code.data.uid_mapping - ) - end - - def test_thread_member_to_sequence_set - # copied from the fourth example in RFC5256: (3 6 (4 23)(44 7 96)) - thmember = Net::IMAP::ThreadMember.method :new - thread = thmember.(3, [ - thmember.(6, [ - thmember.(4, [ - thmember.(23, []) - ]), - thmember.(44, [ - thmember.(7, [ - thmember.(96, []) - ]) - ]) - ]) - ]) - expected = Net::IMAP::SequenceSet.new("3:4,6:7,23,44,96") - assert_equal(expected, thread.to_sequence_set) - end - -end diff --git a/test/net/imap/test_imap_response_parser.rb b/test/net/imap/test_imap_response_parser.rb index e1eb16c5..fa6ba757 100644 --- a/test/net/imap/test_imap_response_parser.rb +++ b/test/net/imap/test_imap_response_parser.rb @@ -118,6 +118,41 @@ def teardown # response data, should still use normal tests, below ############################################################################ + test "default config inherits from Config.global" do + parser = Net::IMAP::ResponseParser.new + refute parser.config.frozen? + refute_equal Net::IMAP::Config.global, parser.config + assert_same Net::IMAP::Config.global, parser.config.parent + end + + test "config can be passed in to #initialize" do + config = Net::IMAP::Config.global.new + parser = Net::IMAP::ResponseParser.new config: config + assert_same config, parser.config + end + + test "passing in global config inherits from Config.global" do + parser = Net::IMAP::ResponseParser.new config: Net::IMAP::Config.global + refute parser.config.frozen? + refute_equal Net::IMAP::Config.global, parser.config + assert_same Net::IMAP::Config.global, parser.config.parent + end + + test "config will inherits from passed in frozen config" do + parser = Net::IMAP::ResponseParser.new config: {debug: true} + refute_equal Net::IMAP::Config.global, parser.config.parent + refute parser.config.frozen? + + assert parser.config.parent.frozen? + assert parser.config.debug? + assert parser.config.inherited?(:debug) + + config = Net::IMAP::Config[debug: true] + parser = Net::IMAP::ResponseParser.new(config:) + refute_equal Net::IMAP::Config.global, parser.config.parent + assert_same config, parser.config.parent + end + # Strangly, there are no example responses for BINARY[section] in either # RFC3516 or RFC9051! The closest I found was RFC5259, and those examples # aren't FETCH responses. @@ -167,4 +202,137 @@ def test_fetch_binary_and_binary_size Net::IMAP.debug = debug end + test "APPENDUID with '*'" do + parser = Net::IMAP::ResponseParser.new + assert_raise_with_message Net::IMAP::ResponseParseError, /uid-set cannot contain '\*'/ do + parser.parse( + "A004 OK [appendUID 1 1:*] Done\r\n" + ) + end + end + + test "APPENDUID with parser_use_deprecated_uidplus_data = true" do + parser = Net::IMAP::ResponseParser.new(config: { + parser_use_deprecated_uidplus_data: true, + parser_max_deprecated_uidplus_data_size: 10_000, + }) + assert_raise_with_message Net::IMAP::ResponseParseError, /uid-set is too large/ do + parser.parse( + "A004 OK [APPENDUID 1 10000:20000,1] Done\r\n" + ) + end + response = parser.parse("A004 OK [APPENDUID 1 100:200] Done\r\n") + uidplus = response.data.code.data + assert_equal 101, uidplus.assigned_uids.size + parser.config.parser_max_deprecated_uidplus_data_size = 100 + assert_raise_with_message Net::IMAP::ResponseParseError, /uid-set is too large/ do + parser.parse( + "A004 OK [APPENDUID 1 100:200] Done\r\n" + ) + end + response = parser.parse("A004 OK [APPENDUID 1 101:200] Done\r\n") + uidplus = response.data.code.data + assert_instance_of Net::IMAP::UIDPlusData, uidplus + assert_equal 100, uidplus.assigned_uids.size + end + + test "APPENDUID with parser_use_deprecated_uidplus_data = :up_to_max_size" do + parser = Net::IMAP::ResponseParser.new(config: { + parser_use_deprecated_uidplus_data: :up_to_max_size, + parser_max_deprecated_uidplus_data_size: 100 + }) + response = parser.parse("A004 OK [APPENDUID 1 101:200] Done\r\n") + assert_instance_of Net::IMAP::UIDPlusData, response.data.code.data + response = parser.parse("A004 OK [APPENDUID 1 100:200] Done\r\n") + assert_instance_of Net::IMAP::AppendUIDData, response.data.code.data + end + + test "APPENDUID with parser_use_deprecated_uidplus_data = false" do + parser = Net::IMAP::ResponseParser.new(config: { + parser_use_deprecated_uidplus_data: false, + parser_max_deprecated_uidplus_data_size: 10_000_000, + }) + response = parser.parse("A004 OK [APPENDUID 1 10] Done\r\n") + assert_instance_of Net::IMAP::AppendUIDData, response.data.code.data + end + + test "COPYUID with backwards ranges" do + parser = Net::IMAP::ResponseParser.new + response = parser.parse( + "A004 OK [copyUID 9999 20:19,500:495 92:97,101:100] Done\r\n" + ) + code = response.data.code + assert_equal( + { + 19 => 92, + 20 => 93, + 495 => 94, + 496 => 95, + 497 => 96, + 498 => 97, + 499 => 100, + 500 => 101, + }, + code.data.uid_mapping + ) + end + + test "COPYUID with '*'" do + parser = Net::IMAP::ResponseParser.new + assert_raise_with_message Net::IMAP::ResponseParseError, /uid-set cannot contain '\*'/ do + parser.parse( + "A004 OK [copyUID 1 1:* 1:*] Done\r\n" + ) + end + end + + test "COPYUID with parser_use_deprecated_uidplus_data = true" do + parser = Net::IMAP::ResponseParser.new(config: { + parser_use_deprecated_uidplus_data: true, + parser_max_deprecated_uidplus_data_size: 10_000, + }) + assert_raise_with_message Net::IMAP::ResponseParseError, /uid-set is too large/ do + parser.parse( + "A004 OK [copyUID 1 10000:20000,1 1:10001] Done\r\n" + ) + end + response = parser.parse("A004 OK [copyUID 1 100:200 1:101] Done\r\n") + uidplus = response.data.code.data + assert_equal 101, uidplus.assigned_uids.size + assert_equal 101, uidplus.source_uids.size + parser.config.parser_max_deprecated_uidplus_data_size = 100 + assert_raise_with_message Net::IMAP::ResponseParseError, /uid-set is too large/ do + parser.parse( + "A004 OK [copyUID 1 100:200 1:101] Done\r\n" + ) + end + response = parser.parse("A004 OK [copyUID 1 101:200 1:100] Done\r\n") + uidplus = response.data.code.data + assert_instance_of Net::IMAP::UIDPlusData, uidplus + assert_equal 100, uidplus.assigned_uids.size + assert_equal 100, uidplus.source_uids.size + end + + test "COPYUID with parser_use_deprecated_uidplus_data = :up_to_max_size" do + parser = Net::IMAP::ResponseParser.new(config: { + parser_use_deprecated_uidplus_data: :up_to_max_size, + parser_max_deprecated_uidplus_data_size: 100 + }) + response = parser.parse("A004 OK [COPYUID 1 101:200 1:100] Done\r\n") + copyuid = response.data.code.data + assert_instance_of Net::IMAP::UIDPlusData, copyuid + response = parser.parse("A004 OK [COPYUID 1 100:200 1:101] Done\r\n") + copyuid = response.data.code.data + assert_instance_of Net::IMAP::CopyUIDData, copyuid + end + + test "COPYUID with parser_use_deprecated_uidplus_data = false" do + parser = Net::IMAP::ResponseParser.new(config: { + parser_use_deprecated_uidplus_data: false, + parser_max_deprecated_uidplus_data_size: 10_000_000, + }) + response = parser.parse("A004 OK [COPYUID 1 101 1] Done\r\n") + assert_instance_of Net::IMAP::CopyUIDData, response.data.code.data + end + end diff --git a/test/net/imap/test_sequence_set.rb b/test/net/imap/test_sequence_set.rb index 85204b24..aad844ac 100644 --- a/test/net/imap/test_sequence_set.rb +++ b/test/net/imap/test_sequence_set.rb @@ -167,6 +167,21 @@ def obj.to_sequence_set; 192_168.001_255 end assert_raise DataFormatError do SequenceSet.try_convert(obj) end end + test "#at(non-negative index)" do + assert_nil SequenceSet.empty.at(0) + assert_equal 1, SequenceSet[1..].at(0) + assert_equal 1, SequenceSet.full.at(0) + assert_equal 111, SequenceSet.full.at(110) + assert_equal 4, SequenceSet[2,4,6,8].at(1) + assert_equal 8, SequenceSet[2,4,6,8].at(3) + assert_equal 6, SequenceSet[4..6].at(2) + assert_nil SequenceSet[4..6].at(3) + assert_equal 205, SequenceSet["101:110,201:210,301:310"].at(14) + assert_equal 310, SequenceSet["101:110,201:210,301:310"].at(29) + assert_nil SequenceSet["101:110,201:210,301:310"].at(44) + assert_equal :*, SequenceSet["1:10,*"].at(10) + end + test "#[non-negative index]" do assert_nil SequenceSet.empty[0] assert_equal 1, SequenceSet[1..][0] @@ -182,18 +197,64 @@ def obj.to_sequence_set; 192_168.001_255 end assert_equal :*, SequenceSet["1:10,*"][10] end + test "#at(negative index)" do + assert_nil SequenceSet.empty.at(-1) + assert_equal :*, SequenceSet[1..].at(-1) + assert_equal 1, SequenceSet.full.at(-(2**32)) + assert_equal 111, SequenceSet[1..111].at(-1) + assert_equal 6, SequenceSet[2,4,6,8].at(-2) + assert_equal 2, SequenceSet[2,4,6,8].at(-4) + assert_equal 4, SequenceSet[4..6].at(-3) + assert_nil SequenceSet[4..6].at(-4) + assert_equal 207, SequenceSet["101:110,201:210,301:310"].at(-14) + assert_equal 102, SequenceSet["101:110,201:210,301:310"].at(-29) + assert_nil SequenceSet["101:110,201:210,301:310"].at(-44) + end + test "#[negative index]" do - assert_nil SequenceSet.empty[0] + assert_nil SequenceSet.empty[-1] assert_equal :*, SequenceSet[1..][-1] assert_equal 1, SequenceSet.full[-(2**32)] assert_equal 111, SequenceSet[1..111][-1] - assert_equal 4, SequenceSet[2,4,6,8][1] - assert_equal 8, SequenceSet[2,4,6,8][3] - assert_equal 6, SequenceSet[4..6][2] - assert_nil SequenceSet[4..6][3] - assert_equal 205, SequenceSet["101:110,201:210,301:310"][14] - assert_equal 310, SequenceSet["101:110,201:210,301:310"][29] - assert_nil SequenceSet["101:110,201:210,301:310"][44] + assert_equal 6, SequenceSet[2,4,6,8][-2] + assert_equal 2, SequenceSet[2,4,6,8][-4] + assert_equal 4, SequenceSet[4..6][-3] + assert_nil SequenceSet[4..6][-4] + assert_equal 207, SequenceSet["101:110,201:210,301:310"][-14] + assert_equal 102, SequenceSet["101:110,201:210,301:310"][-29] + assert_nil SequenceSet["101:110,201:210,301:310"][-44] + end + + test "#ordered_at(non-negative index)" do + assert_nil SequenceSet.empty.ordered_at(0) + assert_equal 1, SequenceSet.full.ordered_at(0) + assert_equal 111, SequenceSet.full.ordered_at(110) + assert_equal 1, SequenceSet["1:*"].ordered_at(0) + assert_equal :*, SequenceSet["*,1"].ordered_at(0) + assert_equal 4, SequenceSet["6,4,8,2"].ordered_at(1) + assert_equal 2, SequenceSet["6,4,8,2"].ordered_at(3) + assert_equal 6, SequenceSet["9:11,4:6,1:3"].ordered_at(5) + assert_nil SequenceSet["9:11,4:6,1:3"].ordered_at(9) + assert_equal 105, SequenceSet["201:210,101:110,301:310"].ordered_at(14) + assert_equal 310, SequenceSet["201:210,101:110,301:310"].ordered_at(29) + assert_nil SequenceSet["201:210,101:110,301:310"].ordered_at(30) + assert_equal :*, SequenceSet["1:10,*"].ordered_at(10) + end + + test "#ordered_at(negative index)" do + assert_nil SequenceSet.empty.ordered_at(-1) + assert_equal :*, SequenceSet["1:*"].ordered_at(-1) + assert_equal 1, SequenceSet.full.ordered_at(-(2**32)) + assert_equal :*, SequenceSet["*,1"].ordered_at(0) + assert_equal 8, SequenceSet["6,4,8,2"].ordered_at(-2) + assert_equal 6, SequenceSet["6,4,8,2"].ordered_at(-4) + assert_equal 4, SequenceSet["9:11,4:6,1:3"].ordered_at(-6) + assert_equal 10, SequenceSet["9:11,4:6,1:3"].ordered_at(-8) + assert_nil SequenceSet["9:11,4:6,1:3"].ordered_at(-12) + assert_equal 107, SequenceSet["201:210,101:110,301:310"].ordered_at(-14) + assert_equal 201, SequenceSet["201:210,101:110,301:310"].ordered_at(-30) + assert_nil SequenceSet["201:210,101:110,301:310"].ordered_at(-31) + assert_equal :*, SequenceSet["1:10,*"].ordered_at(10) end test "#[start, length]" do @@ -251,6 +312,44 @@ def obj.to_sequence_set; 192_168.001_255 end assert_equal 2**32 - 1, SequenceSet.full.find_index(:*) end + test "#find_ordered_index" do + assert_equal 9, SequenceSet.full.find_ordered_index(10) + assert_equal 99, SequenceSet.full.find_ordered_index(100) + assert_equal 2**32 - 1, SequenceSet.full.find_ordered_index(:*) + assert_nil SequenceSet.empty.find_index(1) + set = SequenceSet["9,8,7,6,5,4,3,2,1"] + assert_equal 0, set.find_ordered_index(9) + assert_equal 1, set.find_ordered_index(8) + assert_equal 2, set.find_ordered_index(7) + assert_equal 3, set.find_ordered_index(6) + assert_equal 4, set.find_ordered_index(5) + assert_equal 5, set.find_ordered_index(4) + assert_equal 6, set.find_ordered_index(3) + assert_equal 7, set.find_ordered_index(2) + assert_equal 8, set.find_ordered_index(1) + assert_nil set.find_ordered_index(10) + set = SequenceSet["7:9,5:6"] + assert_equal 0, set.find_ordered_index(7) + assert_equal 1, set.find_ordered_index(8) + assert_equal 2, set.find_ordered_index(9) + assert_equal 3, set.find_ordered_index(5) + assert_equal 4, set.find_ordered_index(6) + assert_nil set.find_ordered_index(4) + set = SequenceSet["1000:1111,1:100"] + assert_equal 0, set.find_ordered_index(1000) + assert_equal 100, set.find_ordered_index(1100) + assert_equal 112, set.find_ordered_index(1) + assert_equal 121, set.find_ordered_index(10) + set = SequenceSet["1,1,1,1,51,50,4,11"] + assert_equal 0, set.find_ordered_index(1) + assert_equal 4, set.find_ordered_index(51) + assert_equal 5, set.find_ordered_index(50) + assert_equal 6, set.find_ordered_index(4) + assert_equal 7, set.find_ordered_index(11) + assert_equal 1, SequenceSet["1,*"].find_ordered_index(-1) + assert_equal 0, SequenceSet["*,1"].find_ordered_index(-1) + end + test "#limit" do set = SequenceSet["1:100,500"] assert_equal [1..99], set.limit(max: 99).ranges @@ -344,6 +443,14 @@ def obj.to_sequence_set; 192_168.001_255 end assert_equal "1:6,4:9", SequenceSet.new("1:6").append("4:9").string assert_equal "1:4,5:*", SequenceSet.new("1:4").append(5..).string assert_equal "5:*,1:4", SequenceSet.new("5:*").append(1..4).string + # also works from empty + assert_equal "5,1", SequenceSet.new.append(5).append(1).string + # also works when *previously* input was non-strings + assert_equal "*,1", SequenceSet.new(:*).append(1).string + assert_equal "1,5", SequenceSet.new(1).append("5").string + assert_equal "1:6,4:9", SequenceSet.new(1..6).append(4..9).string + assert_equal "1:4,5:*", SequenceSet.new(1..4).append(5..).string + assert_equal "5:*,1:4", SequenceSet.new(5..).append(1..4).string end test "#merge" do @@ -354,6 +461,11 @@ def obj.to_sequence_set; 192_168.001_255 end assert_equal seqset["1:3,5,7:9"], seqset["1,3,5,7:8"].merge(seqset["2,8:9"]) assert_equal seqset["1:*"], seqset["5:*"].merge(1..4) assert_equal seqset["1:5"], seqset["1,3,5"].merge(seqset["2,4"]) + # when merging frozen SequenceSet + set = SequenceSet.new + set.merge SequenceSet[1, 3, 5] + set.merge SequenceSet[2..33] + assert_equal seqset[1..33], set end test "set - other" do @@ -670,6 +782,7 @@ def test_inspect((expected, input, freeze)) entries: [46, 6..7, 15, 1..3], ranges: [1..3, 6..7, 15..15, 46..46], numbers: [1, 2, 3, 6, 7, 15, 46], + ordered: [46, 6, 7, 15, 1, 2, 3], to_s: "46,7:6,15,3:1", normalize: "1:3,6:7,15,46", count: 7, @@ -694,9 +807,11 @@ def test_inspect((expected, input, freeze)) entries: [1..5, 3..7, 9..10, 10..11], ranges: [1..7, 9..11], numbers: [1, 2, 3, 4, 5, 6, 7, 9, 10, 11], + ordered: [1,2,3,4,5, 3,4,5,6,7, 9,10, 10,11], to_s: "1:5,3:7,10:9,10:11", normalize: "1:7,9:11", count: 10, + count_dups: 4, complement: "8,12:*", }, keep: true @@ -706,12 +821,27 @@ def test_inspect((expected, input, freeze)) entries: [1..5, 3..4, 9..11, 10], ranges: [1..5, 9..11], numbers: [1, 2, 3, 4, 5, 9, 10, 11], + ordered: [1,2,3,4,5, 3,4, 9,10,11, 10], to_s: "1:5,3:4,9:11,10", normalize: "1:5,9:11", count: 8, + count_dups: 3, complement: "6:8,12:*", }, keep: true + data "multiple *", { + input: "2:*,3:*,*", + elements: [2..], + entries: [2.., 3.., :*], + ranges: [2..], + numbers: RangeError, + to_s: "2:*,3:*,*", + normalize: "2:*", + count: 2**32 - 2, + count_dups: 2**32 - 2, + complement: "1", + }, keep: true + data "array", { input: ["1:5,3:4", 9..11, "10", 99, :*], elements: [1..5, 9..11, 99, :*], @@ -764,12 +894,22 @@ def test_inspect((expected, input, freeze)) assert_equal data[:elements], SequenceSet.new(data[:input]).elements end - test "#each_element" do |data| - seqset = SequenceSet.new(data[:input]) + def assert_seqset_enum(expected, seqset, enum) array = [] - assert_equal seqset, seqset.each_element { array << _1 } - assert_equal data[:elements], array - assert_equal data[:elements], seqset.each_element.to_a + assert_equal seqset, seqset.send(enum) { array << _1 } + assert_equal expected, array + + array = [] + assert_equal seqset, seqset.send(enum).each { array << _1 } + assert_equal expected, array + + assert_equal expected, seqset.send(enum).to_a + end + + test "#each_element" do |data| + seqset = SequenceSet.new(data[:input]) + expected = data[:elements] + assert_seqset_enum expected, seqset, :each_element end test "#entries" do |data| @@ -777,17 +917,60 @@ def test_inspect((expected, input, freeze)) end test "#each_entry" do |data| - seqset = SequenceSet.new(data[:input]) - array = [] - assert_equal seqset, seqset.each_entry { array << _1 } - assert_equal data[:entries], array - assert_equal data[:entries], seqset.each_entry.to_a + seqset = SequenceSet.new(data[:input]) + expected = data[:entries] + assert_seqset_enum expected, seqset, :each_entry + end + + test "#each_range" do |data| + seqset = SequenceSet.new(data[:input]) + expected = data[:ranges] + assert_seqset_enum expected, seqset, :each_range end test "#ranges" do |data| assert_equal data[:ranges], SequenceSet.new(data[:input]).ranges end + test "#each_number" do |data| + seqset = SequenceSet.new(data[:input]) + expected = data[:numbers] + if expected.is_a?(Class) && expected < Exception + assert_raise expected do + seqset.each_number do fail "shouldn't get here" end + end + enum = seqset.each_number + assert_raise expected do enum.to_a end + assert_raise expected do enum.each do fail "shouldn't get here" end end + else + assert_seqset_enum expected, seqset, :each_number + end + end + + test "#each_ordered_number" do |data| + seqset = SequenceSet.new(data[:input]) + expected = data[:ordered] || data[:numbers] + if expected.is_a?(Class) && expected < Exception + assert_raise expected do + seqset.each_ordered_number do fail "shouldn't get here" end + end + enum = seqset.each_ordered_number + assert_raise expected do enum.to_a end + assert_raise expected do enum.each do fail "shouldn't get here" end end + else + assert_seqset_enum expected, seqset, :each_ordered_number + end + end + + test "#numbers" do |data| + expected = data[:numbers] + if expected.is_a?(Class) && expected < Exception + assert_raise expected do SequenceSet.new(data[:input]).numbers end + else + assert_equal expected, SequenceSet.new(data[:input]).numbers + end + end + test "#string" do |data| set = SequenceSet.new(data[:input]) str = data[:to_s] @@ -833,6 +1016,25 @@ def test_inspect((expected, input, freeze)) assert_equal data[:count], SequenceSet.new(data[:input]).count end + test "#count_with_duplicates" do |data| + dups = data[:count_dups] || 0 + count = data[:count] + dups + seqset = SequenceSet.new(data[:input]) + assert_equal count, seqset.count_with_duplicates + end + + test "#count_duplicates" do |data| + dups = data[:count_dups] || 0 + seqset = SequenceSet.new(data[:input]) + assert_equal dups, seqset.count_duplicates + end + + test "#has_duplicates?" do |data| + has_dups = !(data[:count_dups] || 0).zero? + seqset = SequenceSet.new(data[:input]) + assert_equal has_dups, seqset.has_duplicates? + end + test "#valid_string" do |data| if (expected = data[:to_s]).empty? assert_raise DataFormatError do @@ -849,15 +1051,6 @@ def test_inspect((expected, input, freeze)) assert_equal(data[:complement], (~set).to_s) end - test "#numbers" do |data| - expected = data[:numbers] - if expected.is_a?(Class) && expected < Exception - assert_raise expected do SequenceSet.new(data[:input]).numbers end - else - assert_equal expected, SequenceSet.new(data[:input]).numbers - end - end - test "SequenceSet[input]" do |input| case (input = data[:input]) when nil diff --git a/test/net/imap/test_thread_member.rb b/test/net/imap/test_thread_member.rb new file mode 100644 index 00000000..afa933fb --- /dev/null +++ b/test/net/imap/test_thread_member.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +require "net/imap" +require "test/unit" + +class ThreadMemberTest < Test::Unit::TestCase + + test "#to_sequence_set" do + # copied from the fourth example in RFC5256: (3 6 (4 23)(44 7 96)) + thmember = Net::IMAP::ThreadMember.method :new + thread = thmember.(3, [ + thmember.(6, [ + thmember.(4, [ + thmember.(23, []) + ]), + thmember.(44, [ + thmember.(7, [ + thmember.(96, []) + ]) + ]) + ]) + ]) + expected = Net::IMAP::SequenceSet.new("3:4,6:7,23,44,96") + assert_equal(expected, thread.to_sequence_set) + end + +end diff --git a/test/net/imap/test_uidplus_data.rb b/test/net/imap/test_uidplus_data.rb new file mode 100644 index 00000000..0088c346 --- /dev/null +++ b/test/net/imap/test_uidplus_data.rb @@ -0,0 +1,232 @@ +# frozen_string_literal: true + +require "net/imap" +require "test/unit" + +class TestUIDPlusData < Test::Unit::TestCase + + test "#uid_mapping with sorted source_uids" do + uidplus = Net::IMAP::UIDPlusData.new( + 1, [19, 20, *(495..500)], [*(92..97), 100, 101], + ) + assert_equal( + { + 19 => 92, + 20 => 93, + 495 => 94, + 496 => 95, + 497 => 96, + 498 => 97, + 499 => 100, + 500 => 101, + }, + uidplus.uid_mapping + ) + end + + test "#uid_mapping for with source_uids in unsorted order" do + uidplus = Net::IMAP::UIDPlusData.new( + 1, [*(495..500), 19, 20], [*(92..97), 100, 101], + ) + assert_equal( + { + 495 => 92, + 496 => 93, + 497 => 94, + 498 => 95, + 499 => 96, + 500 => 97, + 19 => 100, + 20 => 101, + }, + uidplus.uid_mapping + ) + end + +end + +class TestAppendUIDData < Test::Unit::TestCase + # alias for convenience + AppendUIDData = Net::IMAP::AppendUIDData + SequenceSet = Net::IMAP::SequenceSet + DataFormatError = Net::IMAP::DataFormatError + UINT32_MAX = 2**32 - 1 + + test "#uidvalidity must be valid nz-number" do + assert_equal 1, AppendUIDData.new(1, 99).uidvalidity + assert_equal UINT32_MAX, AppendUIDData.new(UINT32_MAX, 1).uidvalidity + assert_raise DataFormatError do AppendUIDData.new(0, 1) end + assert_raise DataFormatError do AppendUIDData.new(2**32, 1) end + end + + test "#assigned_uids must be a valid uid-set" do + assert_equal SequenceSet[1], AppendUIDData.new(99, "1").assigned_uids + assert_equal SequenceSet[1..9], AppendUIDData.new(1, "1:9").assigned_uids + assert_equal(SequenceSet[UINT32_MAX], + AppendUIDData.new(1, UINT32_MAX.to_s).assigned_uids) + assert_raise DataFormatError do AppendUIDData.new(1, 0) end + assert_raise DataFormatError do AppendUIDData.new(1, "*") end + assert_raise DataFormatError do AppendUIDData.new(1, "1:*") end + end + + test "#size returns the number of UIDs" do + assert_equal(10, AppendUIDData.new(1, "1:10").size) + assert_equal(4_000_000_000, AppendUIDData.new(1, 1..4_000_000_000).size) + end + + test "#assigned_uids is converted to SequenceSet" do + assert_equal SequenceSet[1], AppendUIDData.new(99, "1").assigned_uids + assert_equal SequenceSet[1..4], AppendUIDData.new(1, [1, 2, 3, 4]).assigned_uids + end + +end + +class TestCopyUIDData < Test::Unit::TestCase + # alias for convenience + CopyUIDData = Net::IMAP::CopyUIDData + SequenceSet = Net::IMAP::SequenceSet + DataFormatError = Net::IMAP::DataFormatError + UINT32_MAX = 2**32 - 1 + + test "#uidvalidity must be valid nz-number" do + assert_equal 1, CopyUIDData.new(1, 99, 99).uidvalidity + assert_equal UINT32_MAX, CopyUIDData.new(UINT32_MAX, 1, 1).uidvalidity + assert_raise DataFormatError do CopyUIDData.new(0, 1, 1) end + assert_raise DataFormatError do CopyUIDData.new(2**32, 1, 1) end + end + + test "#source_uids must be valid uid-set" do + assert_equal SequenceSet[1], CopyUIDData.new(99, "1", 99).source_uids + assert_equal SequenceSet[5..8], CopyUIDData.new(1, 5..8, 1..4).source_uids + assert_equal(SequenceSet[UINT32_MAX], + CopyUIDData.new(1, UINT32_MAX.to_s, 1).source_uids) + assert_raise DataFormatError do CopyUIDData.new(99, nil, 99) end + assert_raise DataFormatError do CopyUIDData.new(1, 0, 1) end + assert_raise DataFormatError do CopyUIDData.new(1, "*", 1) end + end + + test "#assigned_uids must be a valid uid-set" do + assert_equal SequenceSet[1], CopyUIDData.new(99, 1, "1").assigned_uids + assert_equal SequenceSet[1..9], CopyUIDData.new(1, 1..9, "1:9").assigned_uids + assert_equal(SequenceSet[UINT32_MAX], + CopyUIDData.new(1, 1, UINT32_MAX.to_s).assigned_uids) + assert_raise DataFormatError do CopyUIDData.new(1, 1, 0) end + assert_raise DataFormatError do CopyUIDData.new(1, 1, "*") end + assert_raise DataFormatError do CopyUIDData.new(1, 1, "1:*") end + end + + test "#size returns the number of UIDs" do + assert_equal(10, CopyUIDData.new(1, "9,8,7,6,1:5,10", "1:10").size) + assert_equal(4_000_000_000, + CopyUIDData.new( + 1, "2000000000:4000000000,1:1999999999", 1..4_000_000_000 + ).size) + end + + test "#source_uids and #assigned_uids must be same size" do + assert_raise DataFormatError do CopyUIDData.new(1, 1..5, 1) end + assert_raise DataFormatError do CopyUIDData.new(1, 1, 1..5) end + end + + test "#source_uids is converted to SequenceSet" do + assert_equal SequenceSet[1], CopyUIDData.new(99, "1", 99).source_uids + assert_equal SequenceSet[5, 6, 7, 8], CopyUIDData.new(1, 5..8, 1..4).source_uids + end + + test "#assigned_uids is converted to SequenceSet" do + assert_equal SequenceSet[1], CopyUIDData.new(99, 1, "1").assigned_uids + assert_equal SequenceSet[1, 2, 3, 4], CopyUIDData.new(1, "1:4", 1..4).assigned_uids + end + + test "#uid_mapping maps source_uids to assigned_uids" do + uidplus = CopyUIDData.new(9999, "20:19,500:495", "92:97,101:100") + assert_equal( + { + 19 => 92, + 20 => 93, + 495 => 94, + 496 => 95, + 497 => 96, + 498 => 97, + 499 => 100, + 500 => 101, + }, + uidplus.uid_mapping + ) + end + + test "#uid_mapping for with source_uids in unsorted order" do + uidplus = CopyUIDData.new(1, "495:500,20:19", "92:97,101:100") + assert_equal( + { + 495 => 92, + 496 => 93, + 497 => 94, + 498 => 95, + 499 => 96, + 500 => 97, + 19 => 100, + 20 => 101, + }, + uidplus.uid_mapping + ) + end + + test "#assigned_uid_for(source_uid)" do + uidplus = CopyUIDData.new(1, "495:500,20:19", "92:97,101:100") + assert_equal 92, uidplus.assigned_uid_for(495) + assert_equal 93, uidplus.assigned_uid_for(496) + assert_equal 94, uidplus.assigned_uid_for(497) + assert_equal 95, uidplus.assigned_uid_for(498) + assert_equal 96, uidplus.assigned_uid_for(499) + assert_equal 97, uidplus.assigned_uid_for(500) + assert_equal 100, uidplus.assigned_uid_for( 19) + assert_equal 101, uidplus.assigned_uid_for( 20) + end + + test "#[](source_uid)" do + uidplus = CopyUIDData.new(1, "495:500,20:19", "92:97,101:100") + assert_equal 92, uidplus[495] + assert_equal 93, uidplus[496] + assert_equal 94, uidplus[497] + assert_equal 95, uidplus[498] + assert_equal 96, uidplus[499] + assert_equal 97, uidplus[500] + assert_equal 100, uidplus[ 19] + assert_equal 101, uidplus[ 20] + end + + test "#source_uid_for(assigned_uid)" do + uidplus = CopyUIDData.new(1, "495:500,20:19", "92:97,101:100") + assert_equal 495, uidplus.source_uid_for( 92) + assert_equal 496, uidplus.source_uid_for( 93) + assert_equal 497, uidplus.source_uid_for( 94) + assert_equal 498, uidplus.source_uid_for( 95) + assert_equal 499, uidplus.source_uid_for( 96) + assert_equal 500, uidplus.source_uid_for( 97) + assert_equal 19, uidplus.source_uid_for(100) + assert_equal 20, uidplus.source_uid_for(101) + end + + test "#each_uid_pair" do + uidplus = CopyUIDData.new(1, "495:500,20:19", "92:97,101:100") + expected = { + 495 => 92, + 496 => 93, + 497 => 94, + 498 => 95, + 499 => 96, + 500 => 97, + 19 => 100, + 20 => 101, + } + actual = {} + uidplus.each_uid_pair do |src, dst| actual[src] = dst end + assert_equal expected, actual + assert_equal expected, uidplus.each_uid_pair.to_h + assert_equal expected.to_a, uidplus.each_uid_pair.to_a + assert_equal expected, uidplus.each_pair.to_h + assert_equal expected, uidplus.each.to_h + end + +end