Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
1a70932
🍒 pick e224fd67: 🥅 Validate that Atom and Flag are not empty
nevans May 22, 2026
9e69af5
🍒 pick 5c213ab7 (#675): 🏷️ Allow 64-bit Integer arguments
nevans May 8, 2026
b923dc9
🍒 pick 3fe06a4c (#676): 🥅 Ensure send_number_data input is an Integer
nevans May 5, 2026
c2dafab
🍒 pick 0d508fa7 (#681): 🥅 Validate server's literal byte size format
nevans Apr 15, 2026
e289159
🍒 pick b02182ce (#678): ✅🐛 Fix FakeServer CommandParseError (tests only)
nevans May 11, 2026
8acb305
🍒 pick ef6fde3b (#678): ✅ Handle "stream closed" as EOF (tests only)
nevans May 11, 2026
445e596
🍒 pick a27a0022 (#678): ✅ Allow test server td ignore abrupt EOF
nevans May 11, 2026
ac2c228
🍒 pick 95afda8f (#679): ♻️ Allow RawData.new to directly set parts array
nevans May 1, 2026
8a73739
🍒 pick 257e51d0 (#679): ♻️ Extract RawData.split(string)
nevans May 1, 2026
0bc84ad
🍒 pick aab64f92 (#686): 🧵 Fix deadlock in `#disconnect`
nevans Jun 4, 2026
e97dcf3
🍒 pick e3c50fad (#698): ♻️ Refactor RawText, add improve test coverage
nevans May 2, 2026
2ccfaae
🍒 pick 8d9397ab (#698): 🥅 Validate QuotedString contains only valid b…
nevans May 2, 2026
3ee25e3
🍒 pick 1f97168b (#699): 🥅 Validate `#enable` arguments are all atoms
nevans May 22, 2026
90b334c
🍒 pick d6ddd294 (#700): 🐛 Prevent trailing `{0}` in RawData validation
nevans May 11, 2026
33348a4
🍒 pick 62a0da6d (#701): 🥅 Validate non-synchronizing literals support
nevans May 11, 2026
9d262bd
🍒 pick ae9f83b5 (#701): ♻️ Extract str.bytesize lvar in send_literal
nevans May 11, 2026
0343c58
🍒 pick 0ea9eba3 (#701): ✅ Fix flaky tests for MacOS, TruffleRuby
nevans Jun 9, 2026
807b007
✅ Avoid endless method defs for Ruby 2.7 compatibility
hsbt Jun 15, 2026
f9020ba
🧵 Synchronize FakeServer::Connection#close to avoid double logout
hsbt Jun 15, 2026
8a238ad
🔖 Bump version to 0.4.25
hsbt Jun 15, 2026
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
12 changes: 7 additions & 5 deletions lib/net/imap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -779,7 +779,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.4.24"
VERSION = "0.4.25"

# Aliases for supported capabilities, to be used with the #enable command.
ENABLE_ALIASES = {
Expand Down Expand Up @@ -1052,6 +1052,7 @@ def client_thread # :nodoc:
# Related: #logout, #logout!
def disconnect
return if disconnected?
in_receiver_thread = Thread.current == @receiver_thread
begin
begin
# try to call SSL::SSLSocket#io.
Expand All @@ -1063,9 +1064,9 @@ def disconnect
rescue Errno::ENOTCONN
# ignore `Errno::ENOTCONN: Socket is not connected' on some platforms.
rescue Exception => e
@receiver_thread.raise(e)
@receiver_thread.raise(e) unless in_receiver_thread
end
@receiver_thread.join
@receiver_thread.join unless mon_owned? || in_receiver_thread
synchronize do
@sock.close
end
Expand Down Expand Up @@ -2560,10 +2561,11 @@ def enable(*capabilities)
capabilities = capabilities
.flatten
.map {|e| ENABLE_ALIASES[e] || e }
.flat_map { _1.is_a?(String) && !_1.empty? ? _1.split(/ /, -1) : [_1] }
.uniq
.join(' ')
.map { Atom[_1] }
synchronize do
send_command("ENABLE #{capabilities}")
send_command("ENABLE", *capabilities)
result = clear_responses("ENABLED").last || []
@utf8_strings ||= result.include? "UTF8=ACCEPT"
@utf8_strings ||= result.include? "IMAP4REV2"
Expand Down
131 changes: 85 additions & 46 deletions lib/net/imap/command_data.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@ def validate_data(data)
when nil
when String
when Integer
NumValidator.ensure_number(data)
# Covers modseq-valzer, which is the largest valid IMAP integer
if data.negative?
raise DataFormatError, "Integer argument must be unsigned: #{data}"
elsif 0xffff_ffff_ffff_ffff < data
raise DataFormatError, "Integer argument must fit in 64 bits: #{data}"
end
when Array
if data[0] == 'CHANGEDSINCE'
NumValidator.ensure_mod_sequence_value(data[1])
else
data.each do |i|
validate_data(i)
end
data.each do |i|
validate_data(i)
end
when Time, Date, DateTime
when Symbol
Expand Down Expand Up @@ -82,15 +83,23 @@ def send_binary_literal(*a, **kw) send_literal(*a, **kw, binary: true) end

# `non_sync` is an optional tri-state flag:
# * `true` -> Force non-synchronizing `LITERAL+`/`LITERAL-` behavior.
# TODO: raise or warn when capabilities don't allow non_sync.
# NOTE: raises DataFormatError when server doesn't support
# non-synchronizing literal, or literal is too large for LITERAL-.
# * `false` -> Force normal synchronizing literal behavior.
# * `nil` -> (default) Currently behaves like `false` (will be dynamic).
# TODO: Dynamic, based on capabilities and bytesize.
def send_literal(str, tag = nil, binary: false, non_sync: nil)
bytesize = str.bytesize
synchronize do
if non_sync && !non_sync_literal_allowed?(bytesize)
# TODO: check in Printer, so we don't need to close the connection.
@sock.close
raise DataFormatError, "Connection closed: " \
"Cannot send non-synchronizing literal without known server support"
end
prefix = "~" if binary
plus = "+" if non_sync
put_string("#{prefix}{#{str.bytesize}#{plus}}\r\n")
put_string("#{prefix}{#{bytesize}#{plus}}\r\n")
if non_sync
put_string(str)
return
Expand All @@ -109,8 +118,18 @@ def send_literal(str, tag = nil, binary: false, non_sync: nil)
end
end

def non_sync_literal_allowed?(bytesize)
return unless capabilities_cached?
return "+" if capable?("LITERAL+")
return "-" if capable_literal_minus? && bytesize <= 4096
false
end

def capable_literal_minus?; capable?("LITERAL-") || capable?("IMAP4rev2") end

# NOTE: +num+ should already be an Integer
def send_number_data(num)
put_string(num.to_s)
put_string(Integer(num).to_s)
end

def send_list_data(list, tag = nil)
Expand Down Expand Up @@ -165,49 +184,70 @@ def validate
end
end

# Represents IMAP +text+ data, which may contain any 7-bit ASCII character,
# except for +NULL+, +CR+, or +LF+. +text+ is extended to allow any
# multibyte +UTF-8+ character when either +UTF8=ACCEPT+ or +IMAP4rev2+ have
# been enabled, or when the server supports only +IMAP4rev2+ and not earlier
# IMAP revisions, or when the server advertises +UTF8=ONLY+.
# Represents IMAP +text+ or +quoted+ data, which share the same
# validations of decoded #data, and differ only in how they are formatted.
#
# +data+ may contain any 7-bit ASCII character except +NULL+, +CR+, or +LF+.
# Any multibyte +UTF-8+ character is also allowed when the connection
# supports UTF8: either +UTF8=ACCEPT+ or +IMAP4rev2+ have been enabled, or
# the server supports only +IMAP4rev2+ and not earlier IMAP revisions, or
# the server advertises +UTF8=ONLY+.
#
# NOTE: The current implementation does not validate whether the connection
# currently supports UTF-8. Future versions may change.
# NOTE: This does not verify whether the connection supports UTF-8, but that
# may change in future versions.
#
# The string's bytes must be valid ASCII or valid UTF-8. The string's
# reported encoding is ignored, but the string is _not_ transcoded.
class RawText < CommandData # :nodoc:
class ValidNonLiteralData < CommandData
def initialize(data:)
data = String(data.to_str)
data = if [Encoding::ASCII, Encoding::UTF_8].include?(data.encoding)
-data
elsif data.ascii_only?
-(data.dup.force_encoding("ASCII"))
else
-(data.dup.force_encoding("UTF-8"))
unless [Encoding::ASCII, Encoding::UTF_8].include?(data.encoding)
data = data.dup.force_encoding(data.ascii_only? ? "ASCII" : "UTF-8")
end
data = -data
super
validate
end

def validate
if data.include?("\0")
raise DataFormatError, "NULL byte must be binary literal encoded"
if ![Encoding::ASCII, Encoding::UTF_8].include?(data.encoding)
raise DataFormatError, "must use ASCII or UTF-8 encoding"
elsif !data.valid_encoding?
raise DataFormatError, "invalid UTF-8 must be literal encoded"
elsif data.include?("\0")
raise DataFormatError, "NULL byte must be binary literal encoded"
elsif /[\r\n]/.match?(data)
raise DataFormatError, "CR and LF bytes must be literal encoded"
end
end

def ascii_only?; data.ascii_only? end

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

# Represents IMAP +text+ data, which covers everything in the IMAP grammar,
# except for +literal+, +literal8+, and the concluding +CRLF+.
#
# NOTE: The current implementation does not verify that the connection
# supports UTF-8. Future versions may validate this.
class RawText < ValidNonLiteralData # :nodoc:
# raw: no formatting necessary
alias formatted data
end

class RawData < CommandData # :nodoc:
def initialize(data:)
data = split_parts(data)
case data
when String
data = self.class.split(data)
when Array
unless data.all? { |part| RawText === part || Literal === part }
raise TypeError, "expected String or Array[#{RawText} | #{Literal}]"
end
else
raise TypeError, "expected String or Array[#{RawText} | #{Literal}]"
end
super
validate
end
Expand All @@ -217,14 +257,16 @@ def send_data(imap, tag) data.each do _1.send_data(imap, tag) end end
def validate
return unless RawText === data.last
text = data.last.data
if text.rindex(/~?\{[1-9]\d*\+?\}\z/n)
if text.rindex(/\{\d+\+?\}\z/n)
raise DataFormatError, "RawData cannot end with literal continuation"
end
end

private

def split_parts(data)
# Splits an input +string+ into an array of RawText and Literal/Literal8.
#
# NOTE: unlike RawData#validate, this does not prevent the final RawText
# from ending with a literal prefix.
def self.split(data)
data = data.b # dups and ensures BINARY encoding
parts = []
while data.match(/(~)?\{(0|[1-9]\d*)(\+)?\}\r\n/n)
Expand All @@ -241,14 +283,15 @@ def split_parts(data)
parts
end

def extract_literal(data, binary:, bytesize:, non_sync:)
def self.extract_literal(data, binary:, bytesize:, non_sync:)
if data.bytesize < bytesize
raise DataFormatError, "Too few bytes in string for literal, " \
"expected: %s, remaining: %s" % [bytesize, data.bytesize]
end
literal = data.byteslice(0, bytesize)
(binary ? Literal8 : Literal).new(data: literal, non_sync: non_sync)
end
private_class_method :extract_literal
end

class Atom < CommandData # :nodoc:
Expand All @@ -262,6 +305,8 @@ def validate
or raise DataFormatError, "#{self.class} must be ASCII only"
data.match?(ResponseParser::Patterns::ATOM_SPECIALS) \
and raise DataFormatError, "#{self.class} must not contain atom-specials"
data.empty? \
and raise DataFormatError, "#{self.class} must not be empty"
end

def send_data(imap, tag)
Expand All @@ -275,19 +320,13 @@ def send_data(imap, tag)
end
end

class QuotedString # :nodoc:
def send_data(imap, tag)
imap.__send__(:send_quoted_string, @data)
end

def validate
end

private

def initialize(data)
@data = data
end
# Represents a IMAP +quoted+ string, which can encode any valid ASCII or
# UTF-8 string, unless it contains any +CR+, +LF+, or +NULL+ bytes.
#
# NOTE: The current implementation does not verify that the connection
# supports UTF-8. Future versions may validate this.
class QuotedString < ValidNonLiteralData # :nodoc:
def formatted; %("#{data.gsub(/["\\]/, "\\\\\\&")}") end
end

class Literal # :nodoc:
Expand Down
32 changes: 20 additions & 12 deletions lib/net/imap/response_parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2055,10 +2055,7 @@ def next_token
if $1
return Token.new(T_SPACE, $+)
elsif $2
len = $+.to_i
val = @str[@pos, len]
@pos += len
return Token.new(T_LITERAL8, val)
literal_token($+, T_LITERAL8)
elsif $3 && $7
# greedily match ATOM, prefixed with NUMBER, NIL, or PLUS.
return Token.new(T_ATOM, $3)
Expand Down Expand Up @@ -2086,10 +2083,7 @@ def next_token
elsif $15
return Token.new(T_RBRA, $+)
elsif $16
len = $+.to_i
val = @str[@pos, len]
@pos += len
return Token.new(T_LITERAL, val)
literal_token($+)
elsif $17
return Token.new(T_PERCENT, $+)
elsif $18
Expand All @@ -2115,10 +2109,7 @@ def next_token
elsif $4
return Token.new(T_QUOTED, Patterns.unescape_quoted($+))
elsif $5
len = $+.to_i
val = @str[@pos, len]
@pos += len
return Token.new(T_LITERAL, val)
literal_token($+)
elsif $6
return Token.new(T_LPAR, $+)
elsif $7
Expand All @@ -2133,6 +2124,23 @@ def next_token
else
parse_error("invalid @lex_state - %s", @lex_state.inspect)
end
rescue DataFormatError => error
parse_error error.message
end

def literal_token(len, type = T_LITERAL)
len = coerce_number64 len.to_i
val = @str[@pos, len]
@pos += len
Token.new(type, val)
end

# copied/adapted from NumValidator in v0.6
def coerce_number64(num)
int = num.to_i
return int if 0 <= int && int <= 0x7fff_ffff_ffff_ffff
raise DataFormatError,
"number64 must be unsigned 63-bit integer: #{num}"
end

end
Expand Down
15 changes: 14 additions & 1 deletion lib/net/imap/response_reader.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ module Net
class IMAP
# See https://www.rfc-editor.org/rfc/rfc9051#section-2.2.2
class ResponseReader # :nodoc:
include NumValidator

attr_reader :client

def initialize(client, sock)
Expand Down Expand Up @@ -35,7 +37,10 @@ def done?; line_done? && !literal_size end
def line_done?; buff.end_with?(CRLF) end

def get_literal_size(buff)
buff.end_with?("}\r\n") && buff.rindex(/\{(\d+)\}\r\n\z/n) && $1.to_i
buff.end_with?("}\r\n") && buff.rindex(/\{(\d+)\}\r\n\z/n) &&
coerce_number64($1)
rescue DataFormatError
raise DataFormatError, format("invalid response literal size (%s)", $1)
end

def read_line
Expand Down Expand Up @@ -76,6 +81,14 @@ def max_response_remaining!
)
end

# copied/adapted from NumValidator in v0.6
def coerce_number64(num)
int = num.to_i
return int if 0 <= int && int <= 0x7fff_ffff_ffff_ffff
raise DataFormatError,
"number64 must be unsigned 63-bit integer: #{num}"
end

end
end
end
Loading
Loading