Skip to content

Commit 43f531e

Browse files
committed
🐛 Fix partial-range encoding of exclusive ranges
This _could_ be a one line bugfix: `first, last = range.minmax`. But the rest was written to simplify support for the `PARTIAL` extension.
1 parent 157b528 commit 43f531e

File tree

4 files changed

+104
-14
lines changed

4 files changed

+104
-14
lines changed

lib/net/imap.rb

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3338,24 +3338,14 @@ def convert_return_opts(unconverted)
33383338
]
33393339
return_opts.map {|opt|
33403340
case opt
3341-
when Symbol then opt.to_s
3342-
when Range then partial_range_last_or_seqset(opt)
3343-
else opt
3341+
when Symbol then opt.to_s
3342+
when PartialRange::Negative then PartialRange[opt]
3343+
when Range then SequenceSet[opt]
3344+
else opt
33443345
end
33453346
}
33463347
end
33473348

3348-
def partial_range_last_or_seqset(range)
3349-
case [range.begin, range.end]
3350-
in [Integer => first, Integer => last] if first.negative? && last.negative?
3351-
# partial-range-last [RFC9394]
3352-
first <= last or raise DataFormatError, "empty range: %p" % [range]
3353-
"#{first}:#{last}"
3354-
else
3355-
SequenceSet[range]
3356-
end
3357-
end
3358-
33593349
def search_internal(cmd, ...)
33603350
args, esearch = search_args(...)
33613351
synchronize do

lib/net/imap/command_data.rb

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,38 @@ def send_data(imap, tag)
153153
end
154154
end
155155

156+
class PartialRange < CommandData # :nodoc:
157+
uint32_max = 2**32 - 1
158+
POS_RANGE = 1..uint32_max
159+
NEG_RANGE = -uint32_max..-1
160+
Positive = ->{ (_1 in Range) and POS_RANGE.cover?(_1) }
161+
Negative = ->{ (_1 in Range) and NEG_RANGE.cover?(_1) }
162+
163+
def initialize(data:)
164+
min, max = case data
165+
in Range
166+
data.minmax.map { Integer _1 }
167+
in ResponseParser::Patterns::PARTIAL_RANGE
168+
data.split(":").map { Integer _1 }.minmax
169+
else
170+
raise ArgumentError, "invalid partial range input: %p" % [data]
171+
end
172+
data = min..max
173+
unless data in Positive | Negative
174+
raise ArgumentError, "invalid partial-range: %p" % [data]
175+
end
176+
super
177+
rescue TypeError, RangeError
178+
raise ArgumentError, "expected range min/max to be Integers"
179+
end
180+
181+
def formatted = "%d:%d" % data.minmax
182+
183+
def send_data(imap, tag)
184+
imap.__send__(:put_string, formatted)
185+
end
186+
end
187+
156188
# *DEPRECATED*. Replaced by SequenceSet.
157189
class MessageSet < CommandData # :nodoc:
158190
def send_data(imap, tag)

lib/net/imap/response_parser.rb

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,24 @@ module RFC3629
321321
SEQUENCE_SET = /#{SEQUENCE_SET_ITEM}(?:,#{SEQUENCE_SET_ITEM})*/n
322322
SEQUENCE_SET_STR = /\A#{SEQUENCE_SET}\z/n
323323

324+
# partial-range-first = nz-number ":" nz-number
325+
# ;; Request to search from oldest (lowest UIDs) to
326+
# ;; more recent messages.
327+
# ;; A range 500:400 is the same as 400:500.
328+
# ;; This is similar to <seq-range> from [RFC3501]
329+
# ;; but cannot contain "*".
330+
PARTIAL_RANGE_FIRST = /\A(#{NZ_NUMBER}):(#{NZ_NUMBER})\z/n
331+
332+
# partial-range-last = MINUS nz-number ":" MINUS nz-number
333+
# ;; Request to search from newest (highest UIDs) to
334+
# ;; oldest messages.
335+
# ;; A range -500:-400 is the same as -400:-500.
336+
PARTIAL_RANGE_LAST = /\A(-#{NZ_NUMBER}):(-#{NZ_NUMBER})\z/n
337+
338+
# partial-range = partial-range-first / partial-range-last
339+
PARTIAL_RANGE = Regexp.union(PARTIAL_RANGE_FIRST,
340+
PARTIAL_RANGE_LAST)
341+
324342
# RFC3501:
325343
# literal = "{" number "}" CRLF *CHAR8
326344
# ; Number represents the number of CHAR8s

test/net/imap/test_imap.rb

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,56 @@ def test_send_invalid_number
658658
end
659659
end
660660

661+
test("send PartialRange args") do
662+
with_fake_server do |server, imap|
663+
server.on "TEST", &:done_ok
664+
send_partial_ranges = ->(*args) do
665+
args.map! { Net::IMAP::PartialRange[_1] }
666+
imap.__send__(:send_command, "TEST", *args)
667+
end
668+
# simple strings
669+
send_partial_ranges.call "1:5", "-5:-1"
670+
assert_equal "1:5 -5:-1", server.commands.pop.args
671+
# backwards strings are reversed
672+
send_partial_ranges.call "5:1", "-1:-5"
673+
assert_equal "1:5 -5:-1", server.commands.pop.args
674+
# simple ranges
675+
send_partial_ranges.call 1..5, -5..-1
676+
assert_equal "1:5 -5:-1", server.commands.pop.args
677+
# exclusive ranges drop end
678+
send_partial_ranges.call 1...5, -5...-1
679+
assert_equal "1:4 -5:-2", server.commands.pop.args
680+
681+
# backwards ranges are invalid
682+
assert_raise(ArgumentError) do send_partial_ranges.call( 5.. 1) end
683+
assert_raise(ArgumentError) do send_partial_ranges.call(-1..-5) end
684+
685+
# bounds checks
686+
uint32_max = 2**32 - 1
687+
not_uint32 = 2**32
688+
send_partial_ranges.call 500..uint32_max
689+
assert_equal "500:#{uint32_max}", server.commands.pop.args
690+
send_partial_ranges.call 500...not_uint32
691+
assert_equal "500:#{uint32_max}", server.commands.pop.args
692+
send_partial_ranges.call "#{uint32_max}:500"
693+
assert_equal "500:#{uint32_max}", server.commands.pop.args
694+
695+
send_partial_ranges.call(-uint32_max..-500)
696+
assert_equal "-#{uint32_max}:-500", server.commands.pop.args
697+
send_partial_ranges.call "-500:-#{uint32_max}"
698+
assert_equal "-#{uint32_max}:-500", server.commands.pop.args
699+
700+
assert_raise(ArgumentError) do send_partial_ranges.call("foo") end
701+
assert_raise(ArgumentError) do send_partial_ranges.call("foo:bar") end
702+
assert_raise(ArgumentError) do send_partial_ranges.call("1.2:3.5") end
703+
assert_raise(ArgumentError) do send_partial_ranges.call("1:*") end
704+
assert_raise(ArgumentError) do send_partial_ranges.call("1:#{not_uint32}") end
705+
assert_raise(ArgumentError) do send_partial_ranges.call(1..) end
706+
assert_raise(ArgumentError) do send_partial_ranges.call(1..not_uint32) end
707+
assert_raise(ArgumentError) do send_partial_ranges.call(..1) end
708+
end
709+
end
710+
661711
def test_send_literal
662712
server = create_tcp_server
663713
port = server.addr[1]

0 commit comments

Comments
 (0)