Skip to content

Commit 35528f7

Browse files
authored
🔀 Merge pull request #649 from ruby/add-support-for-non-synchronizing-literals
✨ Support `LITERAL+` and `LITERAL-` non-synchronizing literals (RFC7888)
2 parents d819520 + 7f61f71 commit 35528f7

8 files changed

Lines changed: 192 additions & 52 deletions

File tree

‎lib/net/imap.rb‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -450,16 +450,16 @@ module Net
450450
#
451451
# Although IMAP4rev2[https://www.rfc-editor.org/rfc/rfc9051] is not supported
452452
# yet, Net::IMAP supports several extensions that have been folded into it:
453-
# +ENABLE+, +IDLE+, +MOVE+, +NAMESPACE+, +SASL-IR+, +UIDPLUS+, +UNSELECT+,
454-
# <tt>STATUS=SIZE</tt>, and the fetch side of +BINARY+.
453+
# +ENABLE+, +IDLE+, +LITERAL-+, +MOVE+, +NAMESPACE+, +SASL-IR+, +UIDPLUS+,
454+
# +UNSELECT+, <tt>STATUS=SIZE</tt>, and the fetch side of +BINARY+.
455455
# Commands for these extensions are listed with the {Core IMAP
456456
# commands}[rdoc-ref:Net::IMAP@Core+IMAP+commands], above.
457457
#
458458
# >>>
459459
# <em>The following are folded into +IMAP4rev2+ but are currently
460460
# unsupported or incompletely supported by</em> Net::IMAP<em>: RFC4466
461461
# extensions, +SEARCHRES+, +LIST-EXTENDED+, +LIST-STATUS+,
462-
# +LITERAL-+, and +SPECIAL-USE+.</em>
462+
# and +SPECIAL-USE+.</em>
463463
#
464464
# ==== RFC2087: +QUOTA+
465465
# +NOTE:+ Only the +STORAGE+ quota resource type is currently supported.
@@ -569,6 +569,15 @@ module Net
569569
# - Updates #store and #uid_store with the +unchangedsince+ modifier and adds
570570
# the +MODIFIED+ ResponseCode to the tagged response.
571571
#
572+
# ==== RFC7888: <tt>LITERAL+</tt>
573+
# - Literal strings smaller than Config#max_non_synchronizing_literal bytes
574+
# are sent without waiting for the server's continuation request.
575+
#
576+
# ==== RFC7888: +LITERAL-+
577+
# - Literal strings smaller than 4096 bytes or
578+
# Config#max_non_synchronizing_literal (whichever is smaller)
579+
# are sent without waiting for the server's continuation request.
580+
#
572581
# ==== RFC8438: <tt>STATUS=SIZE</tt>
573582
# - Updates #status with the +SIZE+ status attribute.
574583
#

‎lib/net/imap/command_data.rb‎

Lines changed: 33 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
require_relative "errors"
66

7+
# :enddoc:
8+
79
module Net
810
class IMAP < Protocol
911

@@ -77,12 +79,23 @@ def send_quoted_string(str)
7779
put_string('"' + str.gsub(/["\\]/, "\\\\\\&") + '"')
7880
end
7981

80-
def send_binary_literal(str, tag) = send_literal(str, tag, binary: true)
82+
def send_binary_literal(*, **) = send_literal(*, **, binary: true)
8183

82-
def send_literal(str, tag = nil, binary: false)
84+
# `non_sync` is an optional tri-state flag:
85+
# * `true` -> Force non-synchronizing `LITERAL+`/`LITERAL-` behavior.
86+
# TODO: raise or warn when capabilities don't allow non_sync.
87+
# * `false` -> Force normal synchronizing literal behavior.
88+
# * `nil` -> (default) Currently behaves like `false` (will be dynamic).
89+
def send_literal(str, tag = nil, binary: false, non_sync: nil)
8390
synchronize do
91+
non_sync = non_sync_literal?(str.bytesize) if non_sync.nil?
8492
prefix = "~" if binary
85-
put_string("#{prefix}{#{str.bytesize}}\r\n")
93+
plus = "+" if non_sync
94+
put_string("#{prefix}{#{str.bytesize}#{plus}}\r\n")
95+
if non_sync
96+
put_string(str)
97+
return
98+
end
8699
@continued_command_tag = tag
87100
@continuation_request_exception = nil
88101
begin
@@ -97,6 +110,13 @@ def send_literal(str, tag = nil, binary: false)
97110
end
98111
end
99112

113+
def non_sync_literal?(bytesize)
114+
capabilities_cached? &&
115+
bytesize <= config.max_non_synchronizing_literal &&
116+
(capable?("LITERAL+") ||
117+
bytesize <= 4096 && (capable?("IMAP4rev2") || capable?("LITERAL-")))
118+
end
119+
100120
def send_number_data(num)
101121
put_string(num.to_s)
102122
end
@@ -149,8 +169,14 @@ def send_data(imap, tag)
149169
end
150170
end
151171

152-
class Literal < CommandData # :nodoc:
153-
def initialize(data:)
172+
class Literal < Data.define(:data, :non_sync) # :nodoc:
173+
def self.validate(...)
174+
data = new(...)
175+
data.validate
176+
data
177+
end
178+
179+
def initialize(data:, non_sync: nil)
154180
data = -String(data.to_str).b or
155181
raise DataFormatError, "#{self.class} expects string input"
156182
super
@@ -167,15 +193,15 @@ def validate
167193
end
168194

169195
def send_data(imap, tag)
170-
imap.__send__(:send_literal, data, tag)
196+
imap.__send__(:send_literal, data, tag, non_sync:)
171197
end
172198
end
173199

174200
class Literal8 < Literal # :nodoc:
175201
def validate = nil # all bytes are okay
176202

177203
def send_data(imap, tag)
178-
imap.__send__(:send_binary_literal, data, tag)
204+
imap.__send__(:send_binary_literal, data, tag, non_sync:)
179205
end
180206
end
181207

‎lib/net/imap/config.rb‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,40 @@ def self.[](config)
281281
0.5r => true,
282282
}
283283

284+
# The maximum bytesize for sending non-synchronizing literals, when the
285+
# server supports them. To disable non-synchronizing literals, set the
286+
# value to +-1+.
287+
#
288+
# Non-synchronizing literals are only sent when the server's
289+
# capabilities[rdoc-ref:IMAP#capabilities] have been
290+
# cached[rdoc-ref:IMAP#capabilities_cached?] and include either
291+
# <tt>LITERAL+</tt> [RFC7888[https://www.rfc-editor.org/rfc/rfc7888]],
292+
# <tt>LITERAL-</tt> [RFC7888[https://www.rfc-editor.org/rfc/rfc7888]], or
293+
# +IMAP4rev2+ [RFC9051[https://www.rfc-editor.org/rfc/rfc9051]].
294+
#
295+
# For <tt>LITERAL+</tt>, this value is the only limit on whether a literal
296+
# value is sent as non-synchronizing literals. For <tt>LITERAL-</tt> and
297+
# <tt>IMAP4rev2</tt>, non-synchronizing literals must also be smaller than
298+
# +4096+ bytes.
299+
#
300+
# Non-synchronizing literals avoid the latency of waiting for the server
301+
# to allow continuation. However, if a client sends a non-synchronizing
302+
# literal that is too large for the server, the server may need to close
303+
# the connection. Because <tt>LITERAL+</tt> does not directly indicate
304+
# the server's limits, it's best to avoid sending very large
305+
# non-synchronized literals.
306+
#
307+
# ==== Versioned Defaults
308+
#
309+
# max_non_synchronizing_literal <em>was added in +v0.6.4+.</em>
310+
#
311+
# * original: +-1+ (_never_ send non-synchronizing literals)
312+
# * +0.6+: 16 KiB
313+
attr_accessor :max_non_synchronizing_literal, type: Integer?, defaults: {
314+
0.0r => -1,
315+
0.6r => 16 << 16, # 16 KiB
316+
}
317+
284318
# The maximum allowed server response size. When +nil+, there is no limit
285319
# on response size.
286320
#

‎test/net/imap/fake_server.rb‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,10 @@ def state; connection.state end
103103
# See CommandRouter#on
104104
def on(...) connection&.on(...) end
105105

106+
# See CommandRouter#literal_acceptor
107+
def literal_acceptor = connection&.literal_acceptor
108+
def literal_acceptor=(v); connection.literal_acceptor = v end
109+
106110
# See Connection#unsolicited
107111
def unsolicited(...) @mutex.synchronize { connection&.unsolicited(...) } end
108112

‎test/net/imap/fake_server/command_reader.rb‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,12 @@ class Net::IMAP::FakeServer
77

88
class CommandReader
99
attr_reader :last_command
10+
attr_accessor :literal_acceptor
1011

1112
def initialize(socket)
1213
@socket = socket
1314
@last_command = nil
15+
@literal_acceptor = proc {|buff, size| true }
1416
end
1517

1618
def get_command
@@ -19,8 +21,17 @@ def get_command
1921
s = socket.gets("\r\n") or break
2022
buf << s
2123
break unless /\{(\d+)(\+)?\}\r\n\z/n =~ buf
22-
$2 or socket.print "+ Continue\r\n"
23-
buf << socket.read(Integer($1))
24+
bytes = Integer($1)
25+
if $2
26+
buf << socket.read(bytes)
27+
elsif literal_acceptor[buf, bytes]
28+
socket.print "+ Continue\r\n"
29+
buf << socket.read(bytes)
30+
else
31+
partial = partial_parse(buf)
32+
socket.print "#{partial.tag} NO #{bytes} byte literal rejected\r\n"
33+
buf = "".b
34+
end
2435
end
2536
throw :eof if buf.empty?
2637
@last_command = parse(buf)
@@ -43,6 +54,7 @@ def parse(buf)
4354
Command.new $1, $2, $3, buf # TODO...
4455
end
4556
end
57+
alias partial_parse parse
4658

4759
# TODO: this is not the correct regexp, and literals aren't handled either
4860
def scan_astrings(str)

‎test/net/imap/fake_server/connection.rb‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ def commands; state.commands end
2222
def on(...) router.on(...) end
2323
def unsolicited(...) writer.untagged(...) end
2424

25+
def literal_acceptor = reader.literal_acceptor
26+
def literal_acceptor=(v) reader.literal_acceptor = v end
27+
2528
def run
2629
writer.greeting
2730
catch(:eof) do

‎test/net/imap/test_command_data.rb‎

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ class CommandDataTest < Net::IMAP::TestCase
99
Literal = Net::IMAP::Literal
1010
Literal8 = Net::IMAP::Literal8
1111

12-
Output = Data.define(:name, :args)
12+
Output = Data.define(:name, :args, :kwargs)
1313
TAG = Module.new.freeze
1414

1515
class FakeCommandWriter
@@ -18,11 +18,15 @@ def self.def_printer(name)
1818
Net::IMAP.private_instance_methods.include?(name)
1919
raise NoMethodError, "#{name} is not a method on Net::IMAP"
2020
end
21-
define_method(name) do |*args|
22-
output << Output[name:, args:]
21+
define_method(name) do |*args, **kwargs|
22+
kwargs = kwargs.compact
23+
kwargs = nil if kwargs.empty?
24+
output << Output[name:, args:, kwargs:]
2325
end
24-
Output.define_singleton_method(name) do |*args|
25-
new(name:, args:)
26+
Output.define_singleton_method(name) do |*args, **kwargs|
27+
kwargs = kwargs.compact
28+
kwargs = nil if kwargs.empty?
29+
new(name:, args:, kwargs:)
2630
end
2731
end
2832

@@ -50,11 +54,20 @@ def send_data(*data, tag: TAG)
5054
def_printer :send_binary_literal
5155
end
5256

57+
attr_reader :imap
58+
59+
setup do
60+
@imap = FakeCommandWriter.new
61+
end
62+
5363
test "Literal" do
54-
imap = FakeCommandWriter.new
5564
imap.send_data Literal["foo\r\nbar"]
65+
imap.send_data Literal["foo\r\nbar", false]
66+
imap.send_data Literal["foo\r\nbar", true]
5667
assert_equal [
5768
Output.send_literal("foo\r\nbar", TAG),
69+
Output.send_literal("foo\r\nbar", TAG, non_sync: false),
70+
Output.send_literal("foo\r\nbar", TAG, non_sync: true),
5871
], imap.output
5972

6073
imap.clear
@@ -65,11 +78,14 @@ def send_data(*data, tag: TAG)
6578
end
6679

6780
test "Literal8" do
68-
imap = FakeCommandWriter.new
6981
imap.send_data Literal8["foo\r\nbar"], Literal8["foo\0bar"]
82+
imap.send_data Literal8["foo\0bar", false]
83+
imap.send_data Literal8["foo\0bar", true]
7084
assert_equal [
7185
Output.send_binary_literal("foo\r\nbar", TAG),
7286
Output.send_binary_literal("foo\0bar", TAG),
87+
Output.send_binary_literal("foo\0bar", TAG, non_sync: false),
88+
Output.send_binary_literal("foo\0bar", TAG, non_sync: true),
7389
], imap.output
7490
end
7591

0 commit comments

Comments
 (0)