Skip to content

Commit

Permalink
🐛 Fix partial-range encoding of exclusive ranges
Browse files Browse the repository at this point in the history
This _could_ be a one line bugfix: `first, last = range.minmax`.  But
the rest was written to simplify support for the `PARTIAL` extension.
  • Loading branch information
nevans committed Dec 22, 2024
1 parent 3d3f658 commit 1125c6c
Show file tree
Hide file tree
Showing 4 changed files with 104 additions and 14 deletions.
18 changes: 4 additions & 14 deletions lib/net/imap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3338,24 +3338,14 @@ def convert_return_opts(unconverted)
]
return_opts.map {|opt|
case opt
when Symbol then opt.to_s
when Range then partial_range_last_or_seqset(opt)
else opt
when Symbol then opt.to_s
when PartialRange::Negative then PartialRange[opt]
when Range then SequenceSet[opt]
else opt
end
}
end

def partial_range_last_or_seqset(range)
case [range.begin, range.end]
in [Integer => first, Integer => last] if first.negative? && last.negative?
# partial-range-last [RFC9394]
first <= last or raise DataFormatError, "empty range: %p" % [range]
"#{first}:#{last}"
else
SequenceSet[range]
end
end

def search_internal(cmd, ...)
args, esearch = search_args(...)
synchronize do
Expand Down
32 changes: 32 additions & 0 deletions lib/net/imap/command_data.rb
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,38 @@ def send_data(imap, tag)
end
end

class PartialRange < CommandData # :nodoc:
uint32_max = 2**32 - 1
POS_RANGE = 1..uint32_max
NEG_RANGE = -uint32_max..-1
Positive = ->{ (_1 in Range) and POS_RANGE.cover?(_1) }
Negative = ->{ (_1 in Range) and NEG_RANGE.cover?(_1) }

def initialize(data:)
min, max = case data
in Range
data.minmax.map { Integer _1 }
in ResponseParser::Patterns::PARTIAL_RANGE
data.split(":").map { Integer _1 }.minmax
else
raise ArgumentError, "invalid partial range input: %p" % [data]
end
data = min..max
unless data in Positive | Negative
raise ArgumentError, "invalid partial-range: %p" % [data]
end
super
rescue TypeError, RangeError
raise ArgumentError, "expected range min/max to be Integers"
end

def formatted = "%d:%d" % data.minmax

def send_data(imap, tag)
imap.__send__(:put_string, formatted)
end
end

# *DEPRECATED*. Replaced by SequenceSet.
class MessageSet < CommandData # :nodoc:
def send_data(imap, tag)
Expand Down
18 changes: 18 additions & 0 deletions lib/net/imap/response_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,24 @@ module RFC3629
SEQUENCE_SET = /#{SEQUENCE_SET_ITEM}(?:,#{SEQUENCE_SET_ITEM})*/n
SEQUENCE_SET_STR = /\A#{SEQUENCE_SET}\z/n

# partial-range-first = nz-number ":" nz-number
# ;; Request to search from oldest (lowest UIDs) to
# ;; more recent messages.
# ;; A range 500:400 is the same as 400:500.
# ;; This is similar to <seq-range> from [RFC3501]
# ;; but cannot contain "*".
PARTIAL_RANGE_FIRST = /\A(#{NZ_NUMBER}):(#{NZ_NUMBER})\z/n

# partial-range-last = MINUS nz-number ":" MINUS nz-number
# ;; Request to search from newest (highest UIDs) to
# ;; oldest messages.
# ;; A range -500:-400 is the same as -400:-500.
PARTIAL_RANGE_LAST = /\A(-#{NZ_NUMBER}):(-#{NZ_NUMBER})\z/n

# partial-range = partial-range-first / partial-range-last
PARTIAL_RANGE = Regexp.union(PARTIAL_RANGE_FIRST,
PARTIAL_RANGE_LAST)

# RFC3501:
# literal = "{" number "}" CRLF *CHAR8
# ; Number represents the number of CHAR8s
Expand Down
50 changes: 50 additions & 0 deletions test/net/imap/test_imap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -658,6 +658,56 @@ def test_send_invalid_number
end
end

test("send PartialRange args") do
with_fake_server do |server, imap|
server.on "TEST", &:done_ok
send_partial_ranges = ->(*args) do
args.map! { Net::IMAP::PartialRange[_1] }
imap.__send__(:send_command, "TEST", *args)
end
# simple strings
send_partial_ranges.call "1:5", "-5:-1"
assert_equal "1:5 -5:-1", server.commands.pop.args
# backwards strings are reversed
send_partial_ranges.call "5:1", "-1:-5"
assert_equal "1:5 -5:-1", server.commands.pop.args
# simple ranges
send_partial_ranges.call 1..5, -5..-1
assert_equal "1:5 -5:-1", server.commands.pop.args
# exclusive ranges drop end
send_partial_ranges.call 1...5, -5...-1
assert_equal "1:4 -5:-2", server.commands.pop.args

# backwards ranges are invalid
assert_raise(ArgumentError) do send_partial_ranges.call( 5.. 1) end
assert_raise(ArgumentError) do send_partial_ranges.call(-1..-5) end

# bounds checks
uint32_max = 2**32 - 1
not_uint32 = 2**32
send_partial_ranges.call 500..uint32_max
assert_equal "500:#{uint32_max}", server.commands.pop.args
send_partial_ranges.call 500...not_uint32
assert_equal "500:#{uint32_max}", server.commands.pop.args
send_partial_ranges.call "#{uint32_max}:500"
assert_equal "500:#{uint32_max}", server.commands.pop.args

send_partial_ranges.call(-uint32_max..-500)
assert_equal "-#{uint32_max}:-500", server.commands.pop.args
send_partial_ranges.call "-500:-#{uint32_max}"
assert_equal "-#{uint32_max}:-500", server.commands.pop.args

assert_raise(ArgumentError) do send_partial_ranges.call("foo") end
assert_raise(ArgumentError) do send_partial_ranges.call("foo:bar") end
assert_raise(ArgumentError) do send_partial_ranges.call("1.2:3.5") end
assert_raise(ArgumentError) do send_partial_ranges.call("1:*") end
assert_raise(ArgumentError) do send_partial_ranges.call("1:#{not_uint32}") end
assert_raise(ArgumentError) do send_partial_ranges.call(1..) end
assert_raise(ArgumentError) do send_partial_ranges.call(1..not_uint32) end
assert_raise(ArgumentError) do send_partial_ranges.call(..1) end
end
end

def test_send_literal
server = create_tcp_server
port = server.addr[1]
Expand Down

0 comments on commit 1125c6c

Please sign in to comment.