Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🐛 Fix partial-range encoding of exclusive ranges #370

Merged
merged 1 commit into from
Dec 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading