VYPR
Medium severityGHSA Advisory· Published May 9, 2026· Updated May 13, 2026

CVE-2026-42257

CVE-2026-42257

Description

Net::IMAP implements Internet Message Access Protocol (IMAP) client functionality in Ruby. Prior to versions 0.4.24, 0.5.14, and 0.6.4, several Net::IMAP commands accept a raw string argument that is sent to the server without validation or escaping. If this string is derived from user-controlled input, it may contain contain CRLF sequences, which an attacker can use to inject arbitrary IMAP commands. This issue has been patched in versions 0.4.24, 0.5.14, and 0.6.4.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
net-imapRubyGems
>= 0.6.0, < 0.6.40.6.4
net-imapRubyGems
>= 0.5.0, < 0.5.140.5.14
net-imapRubyGems
< 0.4.240.4.24

Affected products

1

Patches

5
aec06996eb87

🔀 Merge pull request #663 from ruby/backport/v0.4/raw_data-warnings

https://github.com/ruby/net-imapnicholas a. evansApr 23, 2026via ghsa
6 files changed · +597 40
  • lib/net/imap/command_data.rb+109 8 modified
    @@ -25,6 +25,7 @@ def validate_data(data)
             end
           when Time, Date, DateTime
           when Symbol
    +        Flag.validate(data)
           else
             data.validate
           end
    @@ -45,7 +46,7 @@ def send_data(data, tag = nil)
           when Date
             send_date_data(data)
           when Symbol
    -        send_symbol_data(data)
    +        Flag[data].send_data(self, tag)
           else
             data.send_data(self, tag)
           end
    @@ -129,10 +130,6 @@ def send_list_data(list, tag = nil)
         def send_date_data(date) put_string Net::IMAP.encode_date(date) end
         def send_time_data(time) put_string Net::IMAP.encode_time(time) end
     
    -    def send_symbol_data(symbol)
    -      put_string("\\" + symbol.to_s)
    -    end
    -
         # simplistic emulation of CommandData = Data.define(:data)
         class CommandData # :nodoc:
           class << self
    @@ -154,6 +151,12 @@ def eql?(other) self.class === other && to_h.eql?(other.to_h) end
           # following class definition goes beyond the basic Data.define(:data)
           ##
     
    +      def self.validate(...)
    +        data = new(...)
    +        data.validate
    +        data
    +      end
    +
           def send_data(imap, tag)
             raise NoMethodError, "#{self.class} must implement #{__method__}"
           end
    @@ -162,15 +165,113 @@ 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+.
    +    #
    +    # NOTE: The current implementation does not validate whether the connection
    +    # currently supports UTF-8.  Future versions may change.
    +    #
    +    # 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:
    +      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"))
    +        end
    +        super
    +        validate
    +      end
    +
    +      def validate
    +        if data.include?("\0")
    +          raise DataFormatError, "NULL byte must be binary literal encoded"
    +        elsif !data.valid_encoding?
    +          raise DataFormatError, "invalid UTF-8 must be 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
    +    end
    +
         class RawData < CommandData # :nodoc:
    -      def send_data(imap, tag)
    -        imap.__send__(:put_string, @data)
    +      def initialize(data:)
    +        data = split_parts(data)
    +        super
    +        validate
    +      end
    +
    +      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)
    +          raise DataFormatError, "RawData cannot end with literal continuation"
    +        end
    +      end
    +
    +      private
    +
    +      def split_parts(data)
    +        data = data.b # dups and ensures BINARY encoding
    +        parts = []
    +        while data.match(/(~)?\{(0|[1-9]\d*)(\+)?\}\r\n/n)
    +          text, binary, bytesize, non_sync, data = $`, !!$1, $2, !!$3, $'
    +          bytesize = Integer bytesize, 10
    +          parts << RawText[text] unless text.empty?
    +          parts << extract_literal(data,
    +                                   binary: binary,
    +                                   bytesize: bytesize,
    +                                   non_sync: non_sync)
    +          data[0, bytesize] = ""
    +        end
    +        parts << RawText[data] unless data.empty?
    +        parts
    +      end
    +
    +      def 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
         end
     
         class Atom < CommandData # :nodoc:
    +      def initialize(**)
    +        super
    +        validate
    +      end
    +
    +      def validate
    +        data.to_s.ascii_only? \
    +          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"
    +      end
    +
    +      def send_data(imap, tag)
    +        imap.__send__(:put_string, data.to_s)
    +      end
    +    end
    +
    +    class Flag < Atom # :nodoc:
           def send_data(imap, tag)
    -        imap.__send__(:put_string, @data)
    +        imap.__send__(:put_string, "\\#{data}")
           end
         end
     
    
  • lib/net/imap.rb+88 28 modified
    @@ -460,6 +460,9 @@ module Net
       #   +LITERAL-+, and +SPECIAL-USE+.</em>
       #
       # ==== RFC2087: +QUOTA+
    +  # +NOTE:+ Only the +STORAGE+ quota resource type is currently supported.
    +  # - Obsoleted by <tt>QUOTA=RES-*</tt> [RFC9208[https://www.rfc-editor.org/rfc/rfc9208]],
    +  #   although the commands are backward compatible.
       # - #getquota: returns the resource usage and limits for a quota root
       # - #getquotaroot: returns the list of quota roots for a mailbox, as well as
       #   their resource usage and limits.
    @@ -572,6 +575,16 @@ module Net
       #   See FetchData#emailid and FetchData#emailid.
       # - Updates #status with support for the +MAILBOXID+ status attribute.
       #
    +  # ==== RFC9208: <tt>QUOTA=RES-*</tt>
    +  # +NOTE:+ Only the +STORAGE+ quota resource type is currently supported.
    +  # - Obsoletes the +QUOTA+ [RFC2087[https://www.rfc-editor.org/rfc/rfc2087]]
    +  #   extension and provides strict semantics for different resource types.
    +  # - #getquota: returns the resource usage and limits for a quota root
    +  # - #getquotaroot: returns the list of quota roots for a mailbox, as well as
    +  #   their resource usage and limits.
    +  # - #setquota: sets the resource limits for a given quota root.
    +  # - Updates #status with <tt>"DELETED"</tt> and +DELETED-STORAGE+ attributes.
    +  #
       # == References
       #
       # [{IMAP4rev1}[https://www.rfc-editor.org/rfc/rfc3501.html]]::
    @@ -681,14 +694,13 @@ module Net
       #
       # === \IMAP Extensions
       #
    -  # [QUOTA[https://tools.ietf.org/html/rfc9208]]::
    -  #   Melnikov, A., "IMAP QUOTA Extension", RFC 9208, DOI 10.17487/RFC9208,
    -  #   March 2022, <https://www.rfc-editor.org/info/rfc9208>.
    +  # [QUOTA[https://www.rfc-editor.org/rfc/rfc2087]]::
    +  #   Myers, J., "IMAP4 QUOTA extension", RFC 2087, DOI 10.17487/RFC2087,
    +  #   January 1997, <https://www.rfc-editor.org/info/rfc2087>.
       #
    -  #   <em>Note: obsoletes</em>
    -  #   RFC-2087[https://tools.ietf.org/html/rfc2087]<em> (January 1997)</em>.
    -  #   <em>Net::IMAP does not fully support the RFC9208 updates yet.</em>
    -  # [IDLE[https://tools.ietf.org/html/rfc2177]]::
    +  #   *NOTE*: _obsoleted_ by RFC9208[https://www.rfc-editor.org/rfc/rfc9208]
    +  #   (March 2022).
    +  # [IDLE[https://www.rfc-editor.org/rfc/rfc2177]]::
       #   Leiba, B., "IMAP4 IDLE command", RFC 2177, DOI 10.17487/RFC2177,
       #   June 1997, <https://www.rfc-editor.org/info/rfc2177>.
       # [NAMESPACE[https://tools.ietf.org/html/rfc2342]]::
    @@ -739,9 +751,15 @@ module Net
       #   Gondwana, B., Ed., "IMAP Extension for Object Identifiers",
       #   RFC 8474, DOI 10.17487/RFC8474, September 2018,
       #   <https://www.rfc-editor.org/info/rfc8474>.
    +  # [{QUOTA=RES-*}[https://www.rfc-editor.org/rfc/rfc9208]]::
    +  #   Melnikov, A., "IMAP QUOTA Extension", RFC 9208, DOI 10.17487/RFC9208,
    +  #   March 2022, <https://www.rfc-editor.org/info/rfc9208>.
    +  #
    +  #   Obsoletes RFC2087[https://www.rfc-editor.org/rfc/rfc2087].
       #
       # === IANA registries
       # * {IMAP Capabilities}[http://www.iana.org/assignments/imap4-capabilities]
    +  #   * {IMAP Quota Resource Types}[http://www.iana.org/assignments/imap4-capabilities#imap-capabilities-2]
       # * {IMAP Response Codes}[https://www.iana.org/assignments/imap-response-codes/imap-response-codes.xhtml]
       # * {IMAP Mailbox Name Attributes}[https://www.iana.org/assignments/imap-mailbox-name-attributes/imap-mailbox-name-attributes.xhtml]
       # * {IMAP and JMAP Keywords}[https://www.iana.org/assignments/imap-jmap-keywords/imap-jmap-keywords.xhtml]
    @@ -1742,12 +1760,18 @@ def xlist(refname, mailbox)
         # to both admin and user.  If this mailbox exists, it returns an array
         # containing objects of type MailboxQuotaRoot and MailboxQuota.
         #
    +    # *NOTE:* Currently, Net::IMAP only supports +QUOTA+ responses with a single
    +    # resource type.  This is usually +STORAGE+, but you may need to verify this
    +    # with UntaggedResponse#raw_data.
    +    #
         # Related: #getquota, #setquota, MailboxQuotaRoot, MailboxQuota
         #
         # ===== Capabilities
         #
    -    # The server's capabilities must include +QUOTA+
    -    # [RFC2087[https://tools.ietf.org/html/rfc2087]].
    +    # Requires +QUOTA+ [RFC2087[https://www.rfc-editor.org/rfc/rfc2087]]
    +    # capability, or a capability prefixed with <tt>QUOTA=RES-*</tt>
    +    # {[RFC9208]}[https://www.rfc-editor.org/rfc/rfc9208] for each supported
    +    # resource type.
         def getquotaroot(mailbox)
           synchronize do
             send_command("GETQUOTAROOT", mailbox)
    @@ -1759,41 +1783,59 @@ def getquotaroot(mailbox)
         end
     
         # Sends a {GETQUOTA command [RFC2087 §4.2]}[https://www.rfc-editor.org/rfc/rfc2087#section-4.2]
    -    # along with specified +mailbox+.  If this mailbox exists, then an array
    -    # containing a MailboxQuota object is returned.  This command is generally
    -    # only available to server admin.
    +    # for the +quota_root+.  If this quota root exists, then an array
    +    # containing a MailboxQuota object is returned.
    +    #
    +    # The names of quota roots that are applicable to a particular mailbox can
    +    # be discovered with #getquotaroot.
    +    #
    +    # *NOTE:* Currently, Net::IMAP only supports +QUOTA+ responses with a single
    +    # resource type.  This is usually +STORAGE+, but you may need to verify this
    +    # with UntaggedResponse#raw_data.
         #
         # Related: #getquotaroot, #setquota, MailboxQuota
         #
         # ===== Capabilities
         #
    -    # The server's capabilities must include +QUOTA+
    -    # [RFC2087[https://tools.ietf.org/html/rfc2087]].
    -    def getquota(mailbox)
    +    # Requires +QUOTA+ [RFC2087[https://www.rfc-editor.org/rfc/rfc2087]]
    +    # capability, or a capability prefixed with <tt>QUOTA=RES-*</tt>
    +    # {[RFC9208]}[https://www.rfc-editor.org/rfc/rfc9208] for each supported
    +    # resource type.
    +    def getquota(quota_root)
           synchronize do
    -        send_command("GETQUOTA", mailbox)
    +        send_command("GETQUOTA", quota_root)
             clear_responses("QUOTA")
           end
         end
     
         # Sends a {SETQUOTA command [RFC2087 §4.1]}[https://www.rfc-editor.org/rfc/rfc2087#section-4.1]
    -    # along with the specified +mailbox+ and +quota+.  If +quota+ is nil, then
    -    # +quota+ will be unset for that mailbox.  Typically one needs to be logged
    -    # in as a server admin for this to work.
    +    # along with the specified +quota_root+ and +storage_limit+.  If
    +    # +storage_limit+ is +nil+, resource limits are unset for that quota root.
    +    # If +storage_limit+ is a number, it sets the +STORAGE+ resource limit.
    +    #
    +    #   imap.setquota "#user/alice", 100
    +    #   imap.getquota "#user/alice"
    +    #   # => [#<struct Net::IMAP::MailboxQuota mailbox="#user/alice" usage=54 quota=100>]
    +    #
    +    # Typically one needs to be logged in as a server admin for this to work.
    +    #
    +    # *NOTE:* Currently, Net::IMAP only supports setting +STORAGE+ quota limits.
         #
         # Related: #getquota, #getquotaroot
         #
         # ===== Capabilities
         #
    -    # The server's capabilities must include +QUOTA+
    -    # [RFC2087[https://tools.ietf.org/html/rfc2087]].
    -    def setquota(mailbox, quota)
    -      if quota.nil?
    -        data = '()'
    +    # Requires +QUOTA+ [RFC2087[https://www.rfc-editor.org/rfc/rfc2087]]
    +    # capability, or both +QUOTASET+ and a capability prefixed with
    +    # <tt>QUOTA=RES-*</tt> {[RFC9208]}[https://www.rfc-editor.org/rfc/rfc9208]
    +    # for each supported resource type.
    +    def setquota(quota_root, storage_limit)
    +      if storage_limit.nil?
    +        list = []
           else
    -        data = '(STORAGE ' + quota.to_s + ')'
    +        list = ["STORAGE", Integer(storage_limit)]
           end
    -      send_command("SETQUOTA", mailbox, RawData.new(data))
    +      send_command("SETQUOTA", quota_root, list)
         end
     
         # Sends a {SETACL command [RFC4314 §3.1]}[https://www.rfc-editor.org/rfc/rfc4314#section-3.1]
    @@ -1900,7 +1942,10 @@ def lsub(refname, mailbox)
         # <tt>STATUS=SIZE</tt>
         # {[RFC8483]}[https://www.rfc-editor.org/rfc/rfc8483.html].
         #
    -    # +DELETED+ requires the server's capabilities to include +IMAP4rev2+.
    +    # +DELETED+ must be supported when the server's capabilities includes
    +    # +IMAP4rev2+.
    +    # or <tt>QUOTA=RES-MESSAGES</tt>
    +    # {[RFC9208]}[https://www.rfc-editor.org/rfc/rfc9208.html].
         #
         # +HIGHESTMODSEQ+ requires the server's capabilities to include +CONDSTORE+
         # {[RFC7162]}[https://www.rfc-editor.org/rfc/rfc7162.html].
    @@ -2049,6 +2094,14 @@ def uid_expunge(uid_set)
         #
         # ===== Search criteria
         #
    +    # >>>
    +    #   When +criteria+ is an Array, elements in the array will be validated and
    +    #   formatted.  When +criteria+ is a String, it will be sent <em>with
    +    #   minimal validation and no encoding or formatting</em>.
    +    #
    +    #   <em>*WARNING:* Although CRLF is prohibited, this is vulnerable to other
    +    #   types of attribute injection attack if unvetted user input is used.</em>
    +    #
         # For a full list of search criteria,
         # see [{IMAP4rev1 §6.4.4}[https://www.rfc-editor.org/rfc/rfc3501.html#section-6.4.4]],
         # or  [{IMAP4rev2 §6.4.4}[https://www.rfc-editor.org/rfc/rfc9051.html#section-6.4.4]],
    @@ -2136,6 +2189,13 @@ def uid_search(keys, charset = nil)
         #
         # +attr+ is a list of attributes to fetch; see the documentation
         # for FetchData for a list of valid attributes.
    +    # >>>
    +    #   When +attr+ is a String, it will be sent <em>with minimal validation and
    +    #   no encoding or formatting</em>.  When +attr+ is an Array, each String in
    +    #   +attr+ will be sent this way.
    +    #
    +    #   <em>*WARNING:* Although CRLF is prohibited, this is vulnerable to other
    +    #   types of attribute injection attack if unvetted user input is used.</em>
         #
         # +changedsince+ is an optional integer mod-sequence.  It limits results to
         # messages with a mod-sequence greater than +changedsince+.
    @@ -3071,7 +3131,7 @@ def fetch_internal(cmd, set, attr, mod = nil, changedsince: nil)
         end
     
         def store_internal(cmd, set, attr, flags, unchangedsince: nil)
    -      attr = RawData.new(attr) if attr.instance_of?(String)
    +      attr = Atom.new(attr) if attr.instance_of?(String)
           args = [MessageSet.new(set)]
           args << ["UNCHANGEDSINCE", Integer(unchangedsince)] if unchangedsince
           args << attr << flags
    
  • lib/net/imap/response_data.rb+22 2 modified
    @@ -295,6 +295,14 @@ class ResponseText < Struct.new(:code, :text)
         #   because the server doesn't allow deletion of mailboxes with children.
         #   #data is +nil+.
         #
    +    # ==== <tt>QUOTA=RES-*</tt> response codes
    +    # See {[RFC9208]}[https://www.rfc-editor.org/rfc/rfc9208.html#section-4.3].
    +    # * +OVERQUOTA+ (also in RFC5530[https://www.rfc-editor.org/rfc/rfc5530]),
    +    #   with a tagged +NO+ response to an +APPEND+/+COPY+/+MOVE+ command when
    +    #   the command would put the target mailbox over any quota, and with an
    +    #   untagged +NO+ when a mailbox exceeds a soft quota (which may be caused
    +    #   be external events).  #data is +nil+.
    +    #
         # ==== +CONDSTORE+ extension
         # See {[RFC7162]}[https://www.rfc-editor.org/rfc/rfc7162.html].
         # * +NOMODSEQ+, when selecting a mailbox that does not support
    @@ -369,12 +377,24 @@ class MailboxList < Struct.new(:attr, :delim, :name)
         # Net::IMAP#getquotaroot returns an array containing both MailboxQuotaRoot
         # and MailboxQuota objects.
         #
    +    # ==== Required capability
    +    #
    +    # Requires +QUOTA+ [RFC2087[https://www.rfc-editor.org/rfc/rfc2087]]
    +    # or <tt>QUOTA=RES-STORAGE</tt>
    +    # [RFC9208[https://www.rfc-editor.org/rfc/rfc9208]] capability.
         class MailboxQuota < Struct.new(:mailbox, :usage, :quota)
           ##
           # method: mailbox
           # :call-seq: mailbox -> string
           #
    -      # The mailbox with the associated quota.
    +      # The quota root with the associated quota.
    +      #
    +      # NOTE: this was mistakenly named "mailbox".  But the quota root's name may
    +      # differ from the mailbox.  A single quota root may cover multiple
    +      # mailboxes, and a single mailbox may be governed by multiple quota roots.
    +
    +      # The quota root with the associated quota.
    +      alias quota_root mailbox
     
           ##
           # method: usage
    @@ -386,7 +406,7 @@ class MailboxQuota < Struct.new(:mailbox, :usage, :quota)
           # method: quota
           # :call-seq: quota -> Integer
           #
    -      # Quota limit imposed on the mailbox.
    +      # Storage limit imposed on the mailbox.
           #
         end
     
    
  • test/net/imap/test_command_data.rb+267 0 modified
    @@ -6,8 +6,12 @@
     class CommandDataTest < Test::Unit::TestCase
       DataFormatError = Net::IMAP::DataFormatError
     
    +  Atom = Net::IMAP::Atom
    +  Flag = Net::IMAP::Flag
       Literal = Net::IMAP::Literal
       Literal8 = Net::IMAP::Literal8
    +  RawText = Net::IMAP::RawText
    +  RawData = Net::IMAP::RawData
     
       # simplistic emulation of Output = Data.define(:name, :args)
       class Output
    @@ -84,6 +88,66 @@ def send_data(*data, tag: TAG)
         @imap = FakeCommandWriter.new
       end
     
    +  test "Atom" do
    +    imap.send_data(Atom[:INBOX], Atom["INBOX"], Atom["etc"])
    +    assert_equal [
    +      Output.put_string("INBOX"),
    +      Output.put_string("INBOX"),
    +      Output.put_string("etc"),
    +    ], imap.output
    +
    +    imap.clear
    +    # atom may not contain atom-specials
    +    [
    +      "with_parens()",
    +      "with_list_wildcards*",
    +      "with_list_wildcards%",
    +      "with_resp_special]",
    +      "with\0null",
    +      "with\x7fcontrol_char",
    +      '"with_quoted_specials"',
    +      "with_quoted_specials\\",
    +      "with\rCR",
    +      "with\nLF",
    +    ].each do |symbol|
    +      assert_raise_with_message(Net::IMAP::DataFormatError, /\batom\b/i) do
    +        imap.send_data Atom[symbol]
    +      end
    +    end
    +    assert_empty imap.output
    +  end
    +
    +  test "Flag" do
    +    imap.send_data(Flag[:Seen], Flag[:Flagged],
    +                   Flag["Deleted"], Flag["Answered"])
    +    assert_equal [
    +      Output.put_string("\\Seen"),
    +      Output.put_string("\\Flagged"),
    +      Output.put_string("\\Deleted"),
    +      Output.put_string("\\Answered"),
    +    ], imap.output
    +
    +    imap.clear
    +    # symbol may not contain atom-specials
    +    [
    +      :"with_parens()",
    +      :"with_list_wildcards*",
    +      :"with_list_wildcards%",
    +      :"with_resp_special]",
    +      :"with\0null",
    +      :"with\x7fcontrol_char",
    +      :'"with_quoted_specials"',
    +      :"with_quoted_specials\\",
    +      :"with\rCR",
    +      :"with\nLF",
    +    ].each do |symbol|
    +      assert_raise_with_message(Net::IMAP::DataFormatError, /\bflag\b/i) do
    +        imap.send_data Flag[symbol]
    +      end
    +    end
    +    assert_empty imap.output
    +  end
    +
       test "Literal" do
         imap.send_data Literal["foo\r\nbar"]
         imap.send_data Literal["foo\r\nbar", false]
    @@ -113,4 +177,207 @@ def send_data(*data, tag: TAG)
         ], imap.output
       end
     
    +  class RawTextTest < CommandDataTest
    +    test "basic ASCII string" do
    +      imap.send_data RawText.new('foo "bar" (baz)')
    +      assert_equal [Output.put_string('foo "bar" (baz)')], imap.output
    +    end
    +
    +    test "allows IMAP atom-special symbols" do
    +      imap.send_data RawText.new('foo "bar" (baz)')
    +      imap.send_data RawText.new("(){}[]%*\"\\")
    +      imap.send_data RawText.new("(((((((((((((((( unbalanced ]]]]]]]]]]]]]")
    +      assert_equal [
    +        Output.put_string('foo "bar" (baz)'),
    +        Output.put_string("(){}[]%*\"\\"),
    +        Output.put_string("(((((((((((((((( unbalanced ]]]]]]]]]]]]]"),
    +      ], imap.output
    +    end
    +
    +    test "ASCII compatible string with another encodings" do
    +      imap.send_data RawText.new("foo bar".encode("cp1252"))
    +      assert_equal [
    +        Output.put_string("foo bar"),
    +      ], imap.output
    +    end
    +
    +    test "allows ASCII control chars" do
    +      text = RawText.new("beep\b beep\b escape!\e delete this:\x1f")
    +      imap.send_data text
    +      assert_equal [
    +        Output.put_string("beep\b beep\b escape!\e delete this:\x1f"),
    +      ], imap.output
    +    end
    +
    +    data(
    +      "NULL" => ["with \0 NULL", /NULL\b.+\bbyte/i],
    +      "CR"   => ["with \r CR",   /CR\b.+\bbyte/i],
    +      "LF"   => ["with \n LF",   /LF\b.+\bbyte/i],
    +    )
    +    test "invalid ASCII byte" do |(text, error_message)|
    +      try_multiple_encodings(error_message, text)
    +    end
    +
    +    # See Table 3-7, Well-Formed UTF-8 Byte Sequences, in The Unicode Standard:
    +    # https://www.unicode.org/versions/Unicode17.0.0/core-spec/chapter-3/#G27506
    +    data(
    +      "incomplete 2 byte sequence" => "\xc3".b,
    +      "invalid 2 byte sequence"    => "\xc3\x7f".b,
    +      "incomplete 3 byte sequence" => "\xe0\x80\x80".b,
    +      "invalid 3 byte sequence"    => "\xe0\x80\x80".b,
    +      "incomplete 4 byte sequence" => "\xf1\x80\x80".b,
    +      "invalid 4 byte sequence"    => "\xf0\x80\x80\x80".b,
    +      "first byte too high"        => "\xff\xaa\xaa\xaa".b,
    +      "UTF-16 surrogate pair"      => "\xFE\xFF\xD8\x3D\xDC\xA3\xFE\x0F".b,
    +      "windows-1252"               => "åêïõü".encode("windows-1252"),
    +    )
    +    test "invalid UTF-8" do |text|
    +      try_multiple_encodings(/invalid UTF-8/i, text)
    +    end
    +
    +    def with_multiple_encodings(data)
    +      yield data.b # BINARY
    +      yield data.dup.force_encoding("ASCII")
    +      yield data.dup.force_encoding("UTF-8")
    +      yield data.dup.force_encoding("cp1252")
    +    end
    +
    +    def try_multiple_encodings(error_message, data)
    +      with_multiple_encodings(data) do |encoded|
    +        assert_raise_with_message(DataFormatError, error_message) do
    +          RawText[encoded]
    +        end
    +      end
    +    end
    +  end
    +
    +  class RawDataTest < CommandDataTest
    +    test "simple raw text" do
    +      raw = RawData.new('foo "bar" baz')
    +      assert_equal [RawText['foo "bar" baz']], raw.data
    +      imap.send_data raw
    +      assert_equal [Output.put_string('foo "bar" baz')], imap.output
    +    end
    +
    +    test "a single literal" do
    +      raw = RawData.new("{7}\r\nfoo bar")
    +      assert_equal [Literal["foo bar", false]], raw.data
    +      imap.send_data raw, tag: "t1"
    +      assert_equal [
    +        Output.send_literal("foo bar", "t1", non_sync: false),
    +      ], imap.output
    +    end
    +
    +    test "literals embedded between text" do
    +      raw = RawData.new("foo bar {3}\r\nbaz {4+}\r\nquux etc")
    +      assert_equal [
    +        RawText["foo bar "],
    +        Literal["baz", false],
    +        RawText[" "],
    +        Literal["quux", true], # non-synchronizing
    +        RawText[" etc"],
    +      ], raw.data
    +      imap.send_data raw, tag: "t2"
    +      assert_equal [
    +        Output.put_string("foo bar "),
    +        Output.send_literal("baz", "t2", non_sync: false),
    +        Output.put_string(" "),
    +        Output.send_literal("quux", "t2", non_sync: true),
    +        Output.put_string(" etc"),
    +      ], imap.output
    +    end
    +
    +    test "empty literals" do
    +      raw = RawData.new("{0}\r\n{0+}\r\n~{0}\r\n~{0+}\r\n")
    +      assert_equal [
    +        Literal["", false],
    +        Literal["", true],
    +        Literal8["", false],
    +        Literal8["", true],
    +      ], raw.data
    +      imap.send_data raw, tag: "t2.2"
    +      assert_equal [
    +        Output.send_literal("", "t2.2", non_sync: false),
    +        Output.send_literal("", "t2.2", non_sync: true),
    +        Output.send_binary_literal("", "t2.2", non_sync: false),
    +        Output.send_binary_literal("", "t2.2", non_sync: true),
    +      ], imap.output
    +    end
    +
    +    test "raw text embedded between literals" do
    +      raw = RawData.new("{3}\r\nfoo bar")
    +      assert_equal [
    +        Literal["foo", false],
    +        RawText[" bar"]
    +      ], raw.data
    +      imap.send_data raw, tag: "t3"
    +      assert_equal [
    +        Output.send_literal("foo", "t3", non_sync: false),
    +        Output.put_string(" bar"),
    +      ], imap.output
    +    end
    +
    +    test "raw text followed by literal" do
    +      raw = RawData.new("foo {3}\r\nbar")
    +      assert_equal [
    +        RawText["foo "],
    +        Literal["bar", false],
    +      ], raw.data
    +      imap.send_data raw, tag: "t4"
    +      assert_equal [
    +        Output.put_string("foo "),
    +        Output.send_literal("bar", "t4", non_sync: false),
    +      ], imap.output
    +      imap.clear
    +    end
    +
    +    test "binary literal with regular literal" do
    +      raw = RawData.new("foo ~{7}\r\n\0bar\r\nbaz {4}\r\nquux")
    +      assert_equal [
    +        RawText["foo "],
    +        Literal8["\0bar\r\nb", false],
    +        RawText["az "],
    +        Literal["quux", false],
    +      ], raw.data
    +      imap.send_data raw, tag: "t5"
    +      assert_equal [
    +        Output.put_string("foo "),
    +        Output.send_binary_literal("\0bar\r\nb", "t5", non_sync: false),
    +        Output.put_string("az "),
    +        Output.send_literal("quux", "t5", non_sync: false),
    +      ], imap.output
    +    end
    +
    +    data(
    +      "CR"   => "with \r byte",
    +      "LF"   => "with \n byte",
    +      "NULL" => "with \0 byte",
    +      "CRLF" => "with \r\n bytes",
    +    )
    +    test "invalid bytes in raw text" do |data|
    +      assert_raise_with_message(DataFormatError, /must be.* literal encoded/i) do
    +        RawData.new(data: data)
    +      end
    +    end
    +
    +    test "invalid literal" do |data|
    +      assert_raise_with_message(DataFormatError, /too few bytes/i) do
    +        RawData.new(data: "invalid literal {123}\r\ntoo small")
    +      end
    +
    +      assert_raise_with_message(DataFormatError, /NULL byte.*in.*literal/i) do
    +        RawData.new(data: "invalid literal {10}\r\ncontains \0 null")
    +      end
    +    end
    +
    +    test "invalid literal ending ('{123}')" do
    +      assert_raise(DataFormatError) do RawData.new(data: "literal {123}") end
    +      assert_raise(DataFormatError) do RawData.new(data: "literal+ {123+}") end
    +      assert_raise(DataFormatError) do RawData.new(data: "~literal ~{123}") end
    +      assert_raise(DataFormatError) do RawData.new(data: "~literal+ ~{123+}") end
    +      raw = RawData.new(data: " {123} ")
    +      assert_equal [RawText[" {123} "]], raw.data
    +    end
    +  end
    +
     end
    
  • test/net/imap/test_imap_quota.rb+52 0 added
    @@ -0,0 +1,52 @@
    +# frozen_string_literal: true
    +
    +require "net/imap"
    +require "test/unit"
    +require_relative "fake_server"
    +
    +class IMAPQuotaTest < Test::Unit::TestCase
    +  include Net::IMAP::FakeServer::TestHelper
    +
    +  def setup
    +    Net::IMAP.config.reset
    +    @do_not_reverse_lookup = Socket.do_not_reverse_lookup
    +    Socket.do_not_reverse_lookup = true
    +    @threads = []
    +  end
    +
    +  def teardown
    +    if !@threads.empty?
    +      assert_join_threads(@threads)
    +    end
    +  ensure
    +    Socket.do_not_reverse_lookup = @do_not_reverse_lookup
    +  end
    +
    +  test "#setquota(quota_root, limit)" do
    +    with_fake_server do |server, imap|
    +      server.on "SETQUOTA", &:done_ok
    +
    +      # integer arg
    +      imap.setquota "INBOX", 512
    +      rcvd_cmd = server.commands.pop
    +      assert_equal "SETQUOTA",            rcvd_cmd.name
    +      assert_equal "INBOX (STORAGE 512)", rcvd_cmd.args
    +
    +      # string arg
    +      imap.setquota "INBOX", "512"
    +      rcvd_cmd = server.commands.pop
    +      assert_equal "SETQUOTA",            rcvd_cmd.name
    +      assert_equal "INBOX (STORAGE 512)", rcvd_cmd.args
    +
    +      # empty quota root, null limit
    +      imap.setquota "", nil
    +      rcvd_cmd = server.commands.pop
    +      assert_equal "SETQUOTA",            rcvd_cmd.name
    +      assert_equal '"" ()',               rcvd_cmd.args
    +
    +      assert_raise_with_message(ArgumentError, /invalid value for Integer/) do
    +        imap.setquota "INBOX", "512 620"
    +      end
    +    end
    +  end
    +end
    
  • test/net/imap/test_imap.rb+59 2 modified
    @@ -652,6 +652,63 @@ def test_send_invalid_number
         end
       end
     
    +  def test_send_symbol_as_flag
    +    with_fake_server do |server, imap|
    +      server.on "TEST", &:done_ok
    +
    +      imap.__send__(:send_command, "TEST", :Seen, :Flagged)
    +      assert_equal "\\Seen \\Flagged", server.commands.pop.args
    +
    +      # symbol may not contain atom-specials
    +      [
    +        :"with_parens()",
    +        :"with_list_wildcards*",
    +        :"with_list_wildcards%",
    +        :"with_resp_special]",
    +        :"with\0null",
    +        :"with\x7fcontrol_char",
    +        :'"with_quoted_specials"',
    +        :"with_quoted_specials\\",
    +        :"with\rCR",
    +        :"with\nLF",
    +      ].each do |symbol|
    +        assert_raise_with_message(Net::IMAP::DataFormatError, /\bflag\b/i) do
    +          imap.__send__(:send_command, "TEST", symbol)
    +        end
    +        assert_empty server.commands
    +      end
    +    end
    +  end
    +
    +  def test_raw_data
    +    with_fake_server do |server, imap|
    +      server.on "TEST", &:done_ok
    +
    +      imap.__send__(:send_command, "TEST", Net::IMAP::RawData.new("foo bar"))
    +      assert_equal "foo bar", server.commands.pop.args
    +
    +      imap.__send__(:send_command, "TEST",
    +                    Net::IMAP::RawData.new("{3}\r\nfoo"),
    +                    Net::IMAP::RawData.new("~{4}\r\n\0bar"))
    +      assert_equal "{3}\r\nfoo ~{4}\r\n\0bar", server.commands.pop.args
    +
    +      # RawData must pass basic validation before sending command
    +      [
    +        "with \0 NULL",
    +        "with \r CR",
    +        "with \n LF",
    +        "with \r\n CRLF",
    +        "{1234}\r\nliteral is too small",
    +        "{1}\r\n\0 literal contains NULL",
    +      ].each do |data|
    +        assert_raise(Net::IMAP::DataFormatError) do
    +          imap.__send__(:send_command, "TEST", Net::IMAP::RawData[data: data])
    +        end
    +        assert_empty server.commands
    +      end
    +    end
    +  end
    +
       test("send literal args") do
         with_fake_server do |server, imap|
           server.on "TEST", &:done_ok
    @@ -1167,9 +1224,9 @@ def test_enable
       test "#uid_store with changedsince" do
         with_fake_server select: "inbox" do |server, imap|
           server.on("UID STORE", &:done_ok)
    -      imap.uid_store 1..-1, "FLAGS", %i[Deleted], unchangedsince: 987
    +      imap.uid_store 1..-1, "+FLAGS.SILENT", %i[Deleted], unchangedsince: 987
           assert_equal(
    -        "RUBY0002 UID STORE 1:* (UNCHANGEDSINCE 987) FLAGS (\\Deleted)",
    +        "RUBY0002 UID STORE 1:* (UNCHANGEDSINCE 987) +FLAGS.SILENT (\\Deleted)",
             server.commands.pop.raw.strip
           )
         end
    
6bf02aef7e0b

🔀 Merge pull request #662 from ruby/backport/v0.5/raw_data-warnings

https://github.com/ruby/net-imapnicholas a. evansApr 23, 2026via ghsa
7 files changed · +572 45
  • lib/net/imap/command_data.rb+105 8 modified
    @@ -28,6 +28,7 @@ def validate_data(data)
             end
           when Time, Date, DateTime
           when Symbol
    +        Flag.validate(data)
           else
             data.validate
           end
    @@ -48,7 +49,7 @@ def send_data(data, tag = nil)
           when Date
             send_date_data(data)
           when Symbol
    -        send_symbol_data(data)
    +        Flag[data].send_data(self, tag)
           else
             data.send_data(self, tag)
           end
    @@ -132,11 +133,13 @@ def send_list_data(list, tag = nil)
         def send_date_data(date) put_string Net::IMAP.encode_date(date) end
         def send_time_data(time) put_string Net::IMAP.encode_time(time) end
     
    -    def send_symbol_data(symbol)
    -      put_string("\\" + symbol.to_s)
    -    end
    -
         CommandData = Data.define(:data) do # :nodoc:
    +      def self.validate(...)
    +        data = new(...)
    +        data.validate
    +        data
    +      end
    +
           def send_data(imap, tag)
             raise NoMethodError, "#{self.class} must implement #{__method__}"
           end
    @@ -145,15 +148,109 @@ 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+.
    +    #
    +    # NOTE: The current implementation does not validate whether the connection
    +    # currently supports UTF-8.  Future versions may change.
    +    #
    +    # 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:
    +      def initialize(data:)
    +        data = String(data.to_str)
    +        data = if data.encoding in Encoding::ASCII | Encoding::UTF_8
    +          -data
    +        elsif data.ascii_only?
    +          -(data.dup.force_encoding("ASCII"))
    +        else
    +          -(data.dup.force_encoding("UTF-8"))
    +        end
    +        super
    +        validate
    +      end
    +
    +      def validate
    +        if data.include?("\0")
    +          raise DataFormatError, "NULL byte must be binary literal encoded"
    +        elsif !data.valid_encoding?
    +          raise DataFormatError, "invalid UTF-8 must be 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?
    +
    +      def send_data(imap, tag) = imap.__send__(:put_string, data)
    +    end
    +
         class RawData < CommandData # :nodoc:
    -      def send_data(imap, tag)
    -        imap.__send__(:put_string, data)
    +      def initialize(data:)
    +        data = split_parts(data)
    +        super
    +        validate
    +      end
    +
    +      def send_data(imap, tag) = data.each do _1.send_data(imap, tag) end
    +
    +      def validate
    +        return unless data.last in RawText(data: text)
    +        if text.rindex(/~?\{[1-9]\d*\+?\}\z/n)
    +          raise DataFormatError, "RawData cannot end with literal continuation"
    +        end
    +      end
    +
    +      private
    +
    +      def split_parts(data)
    +        data = data.b # dups and ensures BINARY encoding
    +        parts = []
    +        while data.match(/(~)?\{(0|[1-9]\d*)(\+)?\}\r\n/n)
    +          text, binary, bytesize, non_sync, data = $`, !!$1, $2, !!$3, $'
    +          bytesize = Integer bytesize, 10
    +          parts << RawText[text] unless text.empty?
    +          parts << extract_literal(data, binary:, bytesize:, non_sync:)
    +          data[0, bytesize] = ""
    +        end
    +        parts << RawText[data] unless data.empty?
    +        parts
    +      end
    +
    +      def 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:)
           end
         end
     
         class Atom < CommandData # :nodoc:
    +      def initialize(**)
    +        super
    +        validate
    +      end
    +
    +      def validate
    +        data.to_s.ascii_only? \
    +          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"
    +      end
    +
    +      def send_data(imap, tag)
    +        imap.__send__(:put_string, data.to_s)
    +      end
    +    end
    +
    +    class Flag < Atom # :nodoc:
           def send_data(imap, tag)
    -        imap.__send__(:put_string, data)
    +        imap.__send__(:put_string, "\\#{data}")
           end
         end
     
    
  • lib/net/imap.rb+84 32 modified
    @@ -462,6 +462,9 @@ module Net
       #   +LITERAL-+, and +SPECIAL-USE+.</em>
       #
       # ==== RFC2087: +QUOTA+
    +  # +NOTE:+ Only the +STORAGE+ quota resource type is currently supported.
    +  # - Obsoleted by <tt>QUOTA=RES-*</tt> [RFC9208[https://www.rfc-editor.org/rfc/rfc9208]],
    +  #   although the commands are backward compatible.
       # - #getquota: returns the resource usage and limits for a quota root
       # - #getquotaroot: returns the list of quota roots for a mailbox, as well as
       #   their resource usage and limits.
    @@ -578,6 +581,16 @@ module Net
       #   See FetchData#emailid and FetchData#emailid.
       # - Updates #status with support for the +MAILBOXID+ status attribute.
       #
    +  # ==== RFC9208: <tt>QUOTA=RES-*</tt>
    +  # +NOTE:+ Only the +STORAGE+ quota resource type is currently supported.
    +  # - Obsoletes the +QUOTA+ [RFC2087[https://www.rfc-editor.org/rfc/rfc2087]]
    +  #   extension and provides strict semantics for different resource types.
    +  # - #getquota: returns the resource usage and limits for a quota root
    +  # - #getquotaroot: returns the list of quota roots for a mailbox, as well as
    +  #   their resource usage and limits.
    +  # - #setquota: sets the resource limits for a given quota root.
    +  # - Updates #status with <tt>"DELETED"</tt> and +DELETED-STORAGE+ attributes.
    +  #
       # ==== RFC9394: +PARTIAL+
       # - Updates #search, #uid_search with the +PARTIAL+ return option which adds
       #   ESearchResult#partial return data.
    @@ -698,13 +711,12 @@ module Net
       #
       # === \IMAP Extensions
       #
    -  # [QUOTA[https://www.rfc-editor.org/rfc/rfc9208]]::
    -  #   Melnikov, A., "IMAP QUOTA Extension", RFC 9208, DOI 10.17487/RFC9208,
    -  #   March 2022, <https://www.rfc-editor.org/info/rfc9208>.
    +  # [QUOTA[https://www.rfc-editor.org/rfc/rfc2087]]::
    +  #   Myers, J., "IMAP4 QUOTA extension", RFC 2087, DOI 10.17487/RFC2087,
    +  #   January 1997, <https://www.rfc-editor.org/info/rfc2087>.
       #
    -  #   <em>Note: obsoletes</em>
    -  #   RFC-2087[https://www.rfc-editor.org/rfc/rfc2087]<em> (January 1997)</em>.
    -  #   <em>Net::IMAP does not fully support the RFC9208 updates yet.</em>
    +  #   *NOTE*: _obsoleted_ by RFC9208[https://www.rfc-editor.org/rfc/rfc9208]
    +  #   (March 2022).
       # [IDLE[https://www.rfc-editor.org/rfc/rfc2177]]::
       #   Leiba, B., "IMAP4 IDLE command", RFC 2177, DOI 10.17487/RFC2177,
       #   June 1997, <https://www.rfc-editor.org/info/rfc2177>.
    @@ -756,6 +768,11 @@ module Net
       #   Gondwana, B., Ed., "IMAP Extension for Object Identifiers",
       #   RFC 8474, DOI 10.17487/RFC8474, September 2018,
       #   <https://www.rfc-editor.org/info/rfc8474>.
    +  # [{QUOTA=RES-*}[https://www.rfc-editor.org/rfc/rfc9208]]::
    +  #   Melnikov, A., "IMAP QUOTA Extension", RFC 9208, DOI 10.17487/RFC9208,
    +  #   March 2022, <https://www.rfc-editor.org/info/rfc9208>.
    +  #
    +  #   Obsoletes RFC2087[https://www.rfc-editor.org/rfc/rfc2087].
       # [PARTIAL[https://www.rfc-editor.org/info/rfc9394]]::
       #   Melnikov, A., Achuthan, A., Nagulakonda, V., and L. Alves,
       #   "IMAP PARTIAL Extension for Paged SEARCH and FETCH", RFC 9394,
    @@ -769,6 +786,7 @@ module Net
       #
       # === IANA registries
       # * {IMAP Capabilities}[http://www.iana.org/assignments/imap4-capabilities]
    +  #   * {IMAP Quota Resource Types}[http://www.iana.org/assignments/imap4-capabilities#imap-capabilities-2]
       # * {IMAP Response Codes}[https://www.iana.org/assignments/imap-response-codes/imap-response-codes.xhtml]
       # * {IMAP Mailbox Name Attributes}[https://www.iana.org/assignments/imap-mailbox-name-attributes/imap-mailbox-name-attributes.xhtml]
       # * {IMAP and JMAP Keywords}[https://www.iana.org/assignments/imap-jmap-keywords/imap-jmap-keywords.xhtml]
    @@ -779,8 +797,8 @@ module Net
       # * {GSSAPI/Kerberos/SASL Service Names}[https://www.iana.org/assignments/gssapi-service-names/gssapi-service-names.xhtml]:
       #   +imap+
       # * {Character sets}[https://www.iana.org/assignments/character-sets/character-sets.xhtml]
    +  #
       # ==== For currently unsupported features:
    -  # * {IMAP Quota Resource Types}[http://www.iana.org/assignments/imap4-capabilities#imap-capabilities-2]
       # * {LIST-EXTENDED options and responses}[https://www.iana.org/assignments/imap-list-extended/imap-list-extended.xhtml]
       # * {IMAP METADATA Server Entry and Mailbox Entry Registries}[https://www.iana.org/assignments/imap-metadata/imap-metadata.xhtml]
       # * {IMAP ANNOTATE Extension Entries and Attributes}[https://www.iana.org/assignments/imap-annotate-extension/imap-annotate-extension.xhtml]
    @@ -1828,12 +1846,18 @@ def xlist(refname, mailbox)
         # to both admin and user.  If this mailbox exists, it returns an array
         # containing objects of type MailboxQuotaRoot and MailboxQuota.
         #
    +    # *NOTE:* Currently, Net::IMAP only supports +QUOTA+ responses with a single
    +    # resource type.  This is usually +STORAGE+, but you may need to verify this
    +    # with UntaggedResponse#raw_data.
    +    #
         # Related: #getquota, #setquota, MailboxQuotaRoot, MailboxQuota
         #
         # ==== Capabilities
         #
    -    # The server's capabilities must include +QUOTA+
    -    # [RFC2087[https://www.rfc-editor.org/rfc/rfc2087]].
    +    # Requires +QUOTA+ [RFC2087[https://www.rfc-editor.org/rfc/rfc2087]]
    +    # capability, or a capability prefixed with <tt>QUOTA=RES-*</tt>
    +    # {[RFC9208]}[https://www.rfc-editor.org/rfc/rfc9208] for each supported
    +    # resource type.
         def getquotaroot(mailbox)
           synchronize do
             send_command("GETQUOTAROOT", mailbox)
    @@ -1845,41 +1869,59 @@ def getquotaroot(mailbox)
         end
     
         # Sends a {GETQUOTA command [RFC2087 §4.2]}[https://www.rfc-editor.org/rfc/rfc2087#section-4.2]
    -    # along with specified +mailbox+.  If this mailbox exists, then an array
    -    # containing a MailboxQuota object is returned.  This command is generally
    -    # only available to server admin.
    +    # for the +quota_root+.  If this quota root exists, then an array
    +    # containing a MailboxQuota object is returned.
    +    #
    +    # The names of quota roots that are applicable to a particular mailbox can
    +    # be discovered with #getquotaroot.
    +    #
    +    # *NOTE:* Currently, Net::IMAP only supports +QUOTA+ responses with a single
    +    # resource type.  This is usually +STORAGE+, but you may need to verify this
    +    # with UntaggedResponse#raw_data.
         #
         # Related: #getquotaroot, #setquota, MailboxQuota
         #
         # ==== Capabilities
         #
    -    # The server's capabilities must include +QUOTA+
    -    # [RFC2087[https://www.rfc-editor.org/rfc/rfc2087]].
    -    def getquota(mailbox)
    +    # Requires +QUOTA+ [RFC2087[https://www.rfc-editor.org/rfc/rfc2087]]
    +    # capability, or a capability prefixed with <tt>QUOTA=RES-*</tt>
    +    # {[RFC9208]}[https://www.rfc-editor.org/rfc/rfc9208] for each supported
    +    # resource type.
    +    def getquota(quota_root)
           synchronize do
    -        send_command("GETQUOTA", mailbox)
    +        send_command("GETQUOTA", quota_root)
             clear_responses("QUOTA")
           end
         end
     
         # Sends a {SETQUOTA command [RFC2087 §4.1]}[https://www.rfc-editor.org/rfc/rfc2087#section-4.1]
    -    # along with the specified +mailbox+ and +quota+.  If +quota+ is nil, then
    -    # +quota+ will be unset for that mailbox.  Typically one needs to be logged
    -    # in as a server admin for this to work.
    +    # along with the specified +quota_root+ and +storage_limit+.  If
    +    # +storage_limit+ is +nil+, resource limits are unset for that quota root.
    +    # If +storage_limit+ is a number, it sets the +STORAGE+ resource limit.
    +    #
    +    #   imap.setquota "#user/alice", 100
    +    #   imap.getquota "#user/alice"
    +    #   # => [#<struct Net::IMAP::MailboxQuota mailbox="#user/alice" usage=54 quota=100>]
    +    #
    +    # Typically one needs to be logged in as a server admin for this to work.
    +    #
    +    # *NOTE:* Currently, Net::IMAP only supports setting +STORAGE+ quota limits.
         #
         # Related: #getquota, #getquotaroot
         #
         # ==== Capabilities
         #
    -    # The server's capabilities must include +QUOTA+
    -    # [RFC2087[https://www.rfc-editor.org/rfc/rfc2087]].
    -    def setquota(mailbox, quota)
    -      if quota.nil?
    -        data = '()'
    +    # Requires +QUOTA+ [RFC2087[https://www.rfc-editor.org/rfc/rfc2087]]
    +    # capability, or a capability prefixed with <tt>QUOTA=RES-*</tt>
    +    # {[RFC9208]}[https://www.rfc-editor.org/rfc/rfc9208] for each supported
    +    # resource type.
    +    def setquota(quota_root, storage_limit)
    +      if storage_limit.nil?
    +        list = []
           else
    -        data = '(STORAGE ' + quota.to_s + ')'
    +        list = ["STORAGE", Integer(storage_limit)]
           end
    -      send_command("SETQUOTA", mailbox, RawData.new(data))
    +      send_command("SETQUOTA", quota_root, list)
         end
     
         # Sends a {SETACL command [RFC4314 §3.1]}[https://www.rfc-editor.org/rfc/rfc4314#section-3.1]
    @@ -1986,7 +2028,10 @@ def lsub(refname, mailbox)
         # <tt>STATUS=SIZE</tt>
         # {[RFC8483]}[https://www.rfc-editor.org/rfc/rfc8483.html].
         #
    -    # +DELETED+ requires the server's capabilities to include +IMAP4rev2+.
    +    # +DELETED+ must be supported when the server's capabilities includes
    +    # +IMAP4rev2+.
    +    # or <tt>QUOTA=RES-MESSAGES</tt>
    +    # {[RFC9208]}[https://www.rfc-editor.org/rfc/rfc9208.html].
         #
         # +HIGHESTMODSEQ+ requires the server's capabilities to include +CONDSTORE+
         # {[RFC7162]}[https://www.rfc-editor.org/rfc/rfc7162.html].
    @@ -2267,11 +2312,11 @@ def uid_expunge(uid_set)
         #     Encoded as an \IMAP date (see ::encode_date).
         #
         # [When +criteria+ is a String]
    -    #   +criteria+ will be sent directly to the server <em>without any
    -    #   validation or encoding</em>.
    +    #   +criteria+ will be sent to the server <em>with minimal validation and no
    +    #   encoding or formatting</em>.
         #
    -    #   <em>*WARNING:* This is vulnerable to injection attacks when external
    -    #   inputs are used.</em>
    +    #   <em>*WARNING:* Although CRLF is prohibited, this is vulnerable to other
    +    #   types of attribute injection attack if unvetted user input is used.</em>
         #
         # ==== Supported return options
         #
    @@ -2592,6 +2637,13 @@ def uid_search(...)
         #
         # +attr+ is a list of attributes to fetch; see FetchStruct documentation for
         # a list of supported attributes.
    +    # >>>
    +    #   When +attr+ is a String, it will be sent <em>with minimal validation and
    +    #   no encoding or formatting</em>.  When +attr+ is an Array, each String in
    +    #   +attr+ will be sent this way.
    +    #
    +    #   <em>*WARNING:* Although CRLF is prohibited, this is vulnerable to other
    +    #   types of attribute injection attack if unvetted user input is used.</em>
         #
         # +changedsince+ is an optional integer mod-sequence.  It limits results to
         # messages with a mod-sequence greater than +changedsince+.
    @@ -3712,7 +3764,7 @@ def fetch_internal(cmd, set, attr, mod = nil, partial: nil, changedsince: nil)
         end
     
         def store_internal(cmd, set, attr, flags, unchangedsince: nil)
    -      attr = RawData.new(attr) if attr.instance_of?(String)
    +      attr = Atom.new(attr) if attr.instance_of?(String)
           args = [SequenceSet.new(set)]
           args << ["UNCHANGEDSINCE", Integer(unchangedsince)] if unchangedsince
           args << attr << flags
    
  • lib/net/imap/response_data.rb+20 3 modified
    @@ -307,6 +307,14 @@ class ResponseText < Struct.new(:code, :text)
         #   because the server doesn't allow deletion of mailboxes with children.
         #   #data is +nil+.
         #
    +    # === <tt>QUOTA=RES-*</tt> response codes
    +    # See {[RFC9208]}[https://www.rfc-editor.org/rfc/rfc9208.html#section-4.3].
    +    # * +OVERQUOTA+ (also in RFC5530[https://www.rfc-editor.org/rfc/rfc5530]),
    +    #   with a tagged +NO+ response to an +APPEND+/+COPY+/+MOVE+ command when
    +    #   the command would put the target mailbox over any quota, and with an
    +    #   untagged +NO+ when a mailbox exceeds a soft quota (which may be caused
    +    #   be external events).  #data is +nil+.
    +    #
         # === +CONDSTORE+ extension
         # See {[RFC7162]}[https://www.rfc-editor.org/rfc/rfc7162.html].
         # * +NOMODSEQ+, when selecting a mailbox that does not support
    @@ -384,14 +392,23 @@ class MailboxList < Struct.new(:attr, :delim, :name)
         # and MailboxQuota objects.
         #
         # == Required capability
    +    #
         # Requires +QUOTA+ [RFC2087[https://www.rfc-editor.org/rfc/rfc2087]]
    -    # capability.
    +    # or <tt>QUOTA=RES-STORAGE</tt>
    +    # [RFC9208[https://www.rfc-editor.org/rfc/rfc9208]] capability.
         class MailboxQuota < Struct.new(:mailbox, :usage, :quota)
           ##
           # method: mailbox
           # :call-seq: mailbox -> string
           #
    -      # The mailbox with the associated quota.
    +      # The quota root with the associated quota.
    +      #
    +      # NOTE: this was mistakenly named "mailbox".  But the quota root's name may
    +      # differ from the mailbox.  A single quota root may cover multiple
    +      # mailboxes, and a single mailbox may be governed by multiple quota roots.
    +
    +      # The quota root with the associated quota.
    +      alias quota_root mailbox
     
           ##
           # method: usage
    @@ -403,7 +420,7 @@ class MailboxQuota < Struct.new(:mailbox, :usage, :quota)
           # method: quota
           # :call-seq: quota -> Integer
           #
    -      # Quota limit imposed on the mailbox.
    +      # Storage limit imposed on the mailbox.
           #
         end
     
    
  • test/net/imap/test_command_data.rb+267 0 modified
    @@ -6,8 +6,12 @@
     class CommandDataTest < Net::IMAP::TestCase
       DataFormatError = Net::IMAP::DataFormatError
     
    +  Atom = Net::IMAP::Atom
    +  Flag = Net::IMAP::Flag
       Literal = Net::IMAP::Literal
       Literal8 = Net::IMAP::Literal8
    +  RawText = Net::IMAP::RawText
    +  RawData = Net::IMAP::RawData
     
       Output = Net::IMAP::Data.define(:name, :args, :kwargs)
       TAG = Module.new.freeze
    @@ -60,6 +64,66 @@ def send_data(*data, tag: TAG)
         @imap = FakeCommandWriter.new
       end
     
    +  test "Atom" do
    +    imap.send_data(Atom[:INBOX], Atom["INBOX"], Atom["etc"])
    +    assert_equal [
    +      Output.put_string("INBOX"),
    +      Output.put_string("INBOX"),
    +      Output.put_string("etc"),
    +    ], imap.output
    +
    +    imap.clear
    +    # atom may not contain atom-specials
    +    [
    +      "with_parens()",
    +      "with_list_wildcards*",
    +      "with_list_wildcards%",
    +      "with_resp_special]",
    +      "with\0null",
    +      "with\x7fcontrol_char",
    +      '"with_quoted_specials"',
    +      "with_quoted_specials\\",
    +      "with\rCR",
    +      "with\nLF",
    +    ].each do |symbol|
    +      assert_raise_with_message(Net::IMAP::DataFormatError, /\batom\b/i) do
    +        imap.send_data Atom[symbol]
    +      end
    +    end
    +    assert_empty imap.output
    +  end
    +
    +  test "Flag" do
    +    imap.send_data(Flag[:Seen], Flag[:Flagged],
    +                   Flag["Deleted"], Flag["Answered"])
    +    assert_equal [
    +      Output.put_string("\\Seen"),
    +      Output.put_string("\\Flagged"),
    +      Output.put_string("\\Deleted"),
    +      Output.put_string("\\Answered"),
    +    ], imap.output
    +
    +    imap.clear
    +    # symbol may not contain atom-specials
    +    [
    +      :"with_parens()",
    +      :"with_list_wildcards*",
    +      :"with_list_wildcards%",
    +      :"with_resp_special]",
    +      :"with\0null",
    +      :"with\x7fcontrol_char",
    +      :'"with_quoted_specials"',
    +      :"with_quoted_specials\\",
    +      :"with\rCR",
    +      :"with\nLF",
    +    ].each do |symbol|
    +      assert_raise_with_message(Net::IMAP::DataFormatError, /\bflag\b/i) do
    +        imap.send_data Flag[symbol]
    +      end
    +    end
    +    assert_empty imap.output
    +  end
    +
       test "Literal" do
         imap.send_data Literal["foo\r\nbar"]
         imap.send_data Literal["foo\r\nbar", false]
    @@ -89,4 +153,207 @@ def send_data(*data, tag: TAG)
         ], imap.output
       end
     
    +  class RawTextTest < CommandDataTest
    +    test "basic ASCII string" do
    +      imap.send_data RawText.new('foo "bar" (baz)')
    +      assert_equal [Output.put_string('foo "bar" (baz)')], imap.output
    +    end
    +
    +    test "allows IMAP atom-special symbols" do
    +      imap.send_data RawText.new('foo "bar" (baz)')
    +      imap.send_data RawText.new("(){}[]%*\"\\")
    +      imap.send_data RawText.new("(((((((((((((((( unbalanced ]]]]]]]]]]]]]")
    +      assert_equal [
    +        Output.put_string('foo "bar" (baz)'),
    +        Output.put_string("(){}[]%*\"\\"),
    +        Output.put_string("(((((((((((((((( unbalanced ]]]]]]]]]]]]]"),
    +      ], imap.output
    +    end
    +
    +    test "ASCII compatible string with another encodings" do
    +      imap.send_data RawText.new("foo bar".encode("cp1252"))
    +      assert_equal [
    +        Output.put_string("foo bar"),
    +      ], imap.output
    +    end
    +
    +    test "allows ASCII control chars" do
    +      text = RawText.new("beep\b beep\b escape!\e delete this:\x1f")
    +      imap.send_data text
    +      assert_equal [
    +        Output.put_string("beep\b beep\b escape!\e delete this:\x1f"),
    +      ], imap.output
    +    end
    +
    +    data(
    +      "NULL" => ["with \0 NULL", /NULL\b.+\bbyte/i],
    +      "CR"   => ["with \r CR",   /CR\b.+\bbyte/i],
    +      "LF"   => ["with \n LF",   /LF\b.+\bbyte/i],
    +    )
    +    test "invalid ASCII byte" do |(text, error_message)|
    +      try_multiple_encodings(error_message, text)
    +    end
    +
    +    # See Table 3-7, Well-Formed UTF-8 Byte Sequences, in The Unicode Standard:
    +    # https://www.unicode.org/versions/Unicode17.0.0/core-spec/chapter-3/#G27506
    +    data(
    +      "incomplete 2 byte sequence" => "\xc3".b,
    +      "invalid 2 byte sequence"    => "\xc3\x7f".b,
    +      "incomplete 3 byte sequence" => "\xe0\x80\x80".b,
    +      "invalid 3 byte sequence"    => "\xe0\x80\x80".b,
    +      "incomplete 4 byte sequence" => "\xf1\x80\x80".b,
    +      "invalid 4 byte sequence"    => "\xf0\x80\x80\x80".b,
    +      "first byte too high"        => "\xff\xaa\xaa\xaa".b,
    +      "UTF-16 surrogate pair"      => "\xFE\xFF\xD8\x3D\xDC\xA3\xFE\x0F".b,
    +      "windows-1252"               => "åêïõü".encode("windows-1252"),
    +    )
    +    test "invalid UTF-8" do |text|
    +      try_multiple_encodings(/invalid UTF-8/i, text)
    +    end
    +
    +    def with_multiple_encodings(data)
    +      yield data.b # BINARY
    +      yield data.dup.force_encoding("ASCII")
    +      yield data.dup.force_encoding("UTF-8")
    +      yield data.dup.force_encoding("cp1252")
    +    end
    +
    +    def try_multiple_encodings(error_message, data)
    +      with_multiple_encodings(data) do |encoded|
    +        assert_raise_with_message(DataFormatError, error_message) do
    +          RawText[encoded]
    +        end
    +      end
    +    end
    +  end
    +
    +  class RawDataTest < CommandDataTest
    +    test "simple raw text" do
    +      raw = RawData.new('foo "bar" baz')
    +      assert_equal [RawText['foo "bar" baz']], raw.data
    +      imap.send_data raw
    +      assert_equal [Output.put_string('foo "bar" baz')], imap.output
    +    end
    +
    +    test "a single literal" do
    +      raw = RawData.new("{7}\r\nfoo bar")
    +      assert_equal [Literal["foo bar", false]], raw.data
    +      imap.send_data raw, tag: "t1"
    +      assert_equal [
    +        Output.send_literal("foo bar", "t1", non_sync: false),
    +      ], imap.output
    +    end
    +
    +    test "literals embedded between text" do
    +      raw = RawData.new("foo bar {3}\r\nbaz {4+}\r\nquux etc")
    +      assert_equal [
    +        RawText["foo bar "],
    +        Literal["baz", false],
    +        RawText[" "],
    +        Literal["quux", true], # non-synchronizing
    +        RawText[" etc"],
    +      ], raw.data
    +      imap.send_data raw, tag: "t2"
    +      assert_equal [
    +        Output.put_string("foo bar "),
    +        Output.send_literal("baz", "t2", non_sync: false),
    +        Output.put_string(" "),
    +        Output.send_literal("quux", "t2", non_sync: true),
    +        Output.put_string(" etc"),
    +      ], imap.output
    +    end
    +
    +    test "empty literals" do
    +      raw = RawData.new("{0}\r\n{0+}\r\n~{0}\r\n~{0+}\r\n")
    +      assert_equal [
    +        Literal["", false],
    +        Literal["", true],
    +        Literal8["", false],
    +        Literal8["", true],
    +      ], raw.data
    +      imap.send_data raw, tag: "t2.2"
    +      assert_equal [
    +        Output.send_literal("", "t2.2", non_sync: false),
    +        Output.send_literal("", "t2.2", non_sync: true),
    +        Output.send_binary_literal("", "t2.2", non_sync: false),
    +        Output.send_binary_literal("", "t2.2", non_sync: true),
    +      ], imap.output
    +    end
    +
    +    test "raw text embedded between literals" do
    +      raw = RawData.new("{3}\r\nfoo bar")
    +      assert_equal [
    +        Literal["foo", false],
    +        RawText[" bar"]
    +      ], raw.data
    +      imap.send_data raw, tag: "t3"
    +      assert_equal [
    +        Output.send_literal("foo", "t3", non_sync: false),
    +        Output.put_string(" bar"),
    +      ], imap.output
    +    end
    +
    +    test "raw text followed by literal" do
    +      raw = RawData.new("foo {3}\r\nbar")
    +      assert_equal [
    +        RawText["foo "],
    +        Literal["bar", false],
    +      ], raw.data
    +      imap.send_data raw, tag: "t4"
    +      assert_equal [
    +        Output.put_string("foo "),
    +        Output.send_literal("bar", "t4", non_sync: false),
    +      ], imap.output
    +      imap.clear
    +    end
    +
    +    test "binary literal with regular literal" do
    +      raw = RawData.new("foo ~{7}\r\n\0bar\r\nbaz {4}\r\nquux")
    +      assert_equal [
    +        RawText["foo "],
    +        Literal8["\0bar\r\nb", false],
    +        RawText["az "],
    +        Literal["quux", false],
    +      ], raw.data
    +      imap.send_data raw, tag: "t5"
    +      assert_equal [
    +        Output.put_string("foo "),
    +        Output.send_binary_literal("\0bar\r\nb", "t5", non_sync: false),
    +        Output.put_string("az "),
    +        Output.send_literal("quux", "t5", non_sync: false),
    +      ], imap.output
    +    end
    +
    +    data(
    +      "CR"   => "with \r byte",
    +      "LF"   => "with \n byte",
    +      "NULL" => "with \0 byte",
    +      "CRLF" => "with \r\n bytes",
    +    )
    +    test "invalid bytes in raw text" do |data|
    +      assert_raise_with_message(DataFormatError, /must be.* literal encoded/i) do
    +        RawData.new(data:)
    +      end
    +    end
    +
    +    test "invalid literal" do |data|
    +      assert_raise_with_message(DataFormatError, /too few bytes/i) do
    +        RawData.new(data: "invalid literal {123}\r\ntoo small")
    +      end
    +
    +      assert_raise_with_message(DataFormatError, /NULL byte.*in.*literal/i) do
    +        RawData.new(data: "invalid literal {10}\r\ncontains \0 null")
    +      end
    +    end
    +
    +    test "invalid literal ending ('{123}')" do
    +      assert_raise(DataFormatError) do RawData.new(data: "literal {123}") end
    +      assert_raise(DataFormatError) do RawData.new(data: "literal+ {123+}") end
    +      assert_raise(DataFormatError) do RawData.new(data: "~literal ~{123}") end
    +      assert_raise(DataFormatError) do RawData.new(data: "~literal+ ~{123+}") end
    +      raw = RawData.new(data: " {123} ")
    +      assert_equal [RawText[" {123} "]], raw.data
    +    end
    +  end
    +
     end
    
  • test/net/imap/test_imap_quota.rb+37 0 added
    @@ -0,0 +1,37 @@
    +# frozen_string_literal: true
    +
    +require "net/imap"
    +require "test/unit"
    +require_relative "fake_server"
    +
    +class IMAPQuotaTest < Net::IMAP::TestCase
    +  include Net::IMAP::FakeServer::TestHelper
    +
    +  test "#setquota(quota_root, limit)" do
    +    with_fake_server do |server, imap|
    +      server.on "SETQUOTA", &:done_ok
    +
    +      # integer arg
    +      imap.setquota "INBOX", 512
    +      rcvd_cmd = server.commands.pop
    +      assert_equal "SETQUOTA",            rcvd_cmd.name
    +      assert_equal "INBOX (STORAGE 512)", rcvd_cmd.args
    +
    +      # string arg
    +      imap.setquota "INBOX", "512"
    +      rcvd_cmd = server.commands.pop
    +      assert_equal "SETQUOTA",            rcvd_cmd.name
    +      assert_equal "INBOX (STORAGE 512)", rcvd_cmd.args
    +
    +      # empty quota root, null limit
    +      imap.setquota "", nil
    +      rcvd_cmd = server.commands.pop
    +      assert_equal "SETQUOTA",            rcvd_cmd.name
    +      assert_equal '"" ()',               rcvd_cmd.args
    +
    +      assert_raise_with_message(ArgumentError, /invalid value for Integer/) do
    +        imap.setquota "INBOX", "512 620"
    +      end
    +    end
    +  end
    +end
    
  • test/net/imap/test_imap.rb+57 0 modified
    @@ -650,6 +650,63 @@ def test_send_invalid_number
         end
       end
     
    +  def test_send_symbol_as_flag
    +    with_fake_server do |server, imap|
    +      server.on "TEST", &:done_ok
    +
    +      imap.__send__(:send_command, "TEST", :Seen, :Flagged)
    +      assert_equal "\\Seen \\Flagged", server.commands.pop.args
    +
    +      # symbol may not contain atom-specials
    +      [
    +        :"with_parens()",
    +        :"with_list_wildcards*",
    +        :"with_list_wildcards%",
    +        :"with_resp_special]",
    +        :"with\0null",
    +        :"with\x7fcontrol_char",
    +        :'"with_quoted_specials"',
    +        :"with_quoted_specials\\",
    +        :"with\rCR",
    +        :"with\nLF",
    +      ].each do |symbol|
    +        assert_raise_with_message(Net::IMAP::DataFormatError, /\bflag\b/i) do
    +          imap.__send__(:send_command, "TEST", symbol)
    +        end
    +        assert_empty server.commands
    +      end
    +    end
    +  end
    +
    +  def test_raw_data
    +    with_fake_server do |server, imap|
    +      server.on "TEST", &:done_ok
    +
    +      imap.__send__(:send_command, "TEST", Net::IMAP::RawData.new("foo bar"))
    +      assert_equal "foo bar", server.commands.pop.args
    +
    +      imap.__send__(:send_command, "TEST",
    +                    Net::IMAP::RawData.new("{3}\r\nfoo"),
    +                    Net::IMAP::RawData.new("~{4}\r\n\0bar"))
    +      assert_equal "{3}\r\nfoo ~{4}\r\n\0bar", server.commands.pop.args
    +
    +      # RawData must pass basic validation before sending command
    +      [
    +        "with \0 NULL",
    +        "with \r CR",
    +        "with \n LF",
    +        "with \r\n CRLF",
    +        "{1234}\r\nliteral is too small",
    +        "{1}\r\n\0 literal contains NULL",
    +      ].each do |data|
    +        assert_raise(Net::IMAP::DataFormatError) do
    +          imap.__send__(:send_command, "TEST", Net::IMAP::RawData[data:])
    +        end
    +        assert_empty server.commands
    +      end
    +    end
    +  end
    +
       test("send PartialRange args") do
         with_fake_server do |server, imap|
           server.on "TEST", &:done_ok
    
  • test/net/imap/test_imap_store.rb+2 2 modified
    @@ -72,9 +72,9 @@ class IMAPStoreTest < Net::IMAP::TestCase
       test "#uid_store with changedsince" do
         with_fake_server select: "inbox" do |server, imap|
           server.on("UID STORE", &:done_ok)
    -      imap.uid_store 1..-1, "FLAGS", %i[Deleted], unchangedsince: 987
    +      imap.uid_store 1..-1, "+FLAGS.SILENT", %i[Deleted], unchangedsince: 987
           assert_equal(
    -        "RUBY0002 UID STORE 1:* (UNCHANGEDSINCE 987) FLAGS (\\Deleted)",
    +        "RUBY0002 UID STORE 1:* (UNCHANGEDSINCE 987) +FLAGS.SILENT (\\Deleted)",
             server.commands.pop.raw.strip
           )
         end
    
a4f7649c3da7

🥅 Validate and send STORE `attr` as an `atom`

https://github.com/ruby/net-imapnick evansApr 20, 2026via ghsa
2 files changed · +3 3
  • lib/net/imap.rb+1 1 modified
    @@ -3786,7 +3786,7 @@ def fetch_internal(cmd, set, attr, mod = nil, partial: nil, changedsince: nil)
         end
     
         def store_internal(cmd, set, attr, flags, unchangedsince: nil)
    -      attr = RawData.new(attr) if attr.instance_of?(String)
    +      attr = Atom.new(attr) if attr.instance_of?(String)
           args = [SequenceSet.new(set)]
           args << ["UNCHANGEDSINCE", Integer(unchangedsince)] if unchangedsince
           args << attr << flags
    
  • test/net/imap/test_imap_store.rb+2 2 modified
    @@ -72,9 +72,9 @@ class IMAPStoreTest < Net::IMAP::TestCase
       test "#uid_store with changedsince" do
         with_fake_server select: "inbox" do |server, imap|
           server.on("UID STORE", &:done_ok)
    -      imap.uid_store 1..-1, "FLAGS", %i[Deleted], unchangedsince: 987
    +      imap.uid_store 1..-1, "+FLAGS.SILENT", %i[Deleted], unchangedsince: 987
           assert_equal(
    -        "RUBY0002 UID STORE 1:* (UNCHANGEDSINCE 987) FLAGS (\\Deleted)",
    +        "RUBY0002 UID STORE 1:* (UNCHANGEDSINCE 987) +FLAGS.SILENT (\\Deleted)",
             server.commands.pop.raw.strip
           )
         end
    
0ec4fd351263

🥅 Validate `#setquota` storage limit argument

https://github.com/ruby/net-imapnick evansMar 10, 2026via ghsa
2 files changed · +17 4
  • lib/net/imap.rb+8 4 modified
    @@ -1926,7 +1926,11 @@ def getquota(quota_root)
         # Sends a {SETQUOTA command [RFC2087 §4.1]}[https://www.rfc-editor.org/rfc/rfc2087#section-4.1]
         # along with the specified +quota_root+ and +storage_limit+.  If
         # +storage_limit+ is +nil+, resource limits are unset for that quota root.
    -    # Otherwise, it sets the +STORAGE+ resource limit.
    +    # If +storage_limit+ is a number, it sets the +STORAGE+ resource limit.
    +    #
    +    #   imap.setquota "#user/alice", 100
    +    #   imap.getquota "#user/alice"
    +    #   # => [#<struct Net::IMAP::MailboxQuota mailbox="#user/alice" usage=54 quota=100>]
         #
         # Typically one needs to be logged in as a server admin for this to work.
         #
    @@ -1942,11 +1946,11 @@ def getquota(quota_root)
         # resource type.
         def setquota(quota_root, storage_limit)
           if storage_limit.nil?
    -        list = '()'
    +        list = []
           else
    -        list = '(STORAGE ' + storage_limit.to_s + ')'
    +        list = ["STORAGE", NumValidator.coerce_number64(storage_limit)]
           end
    -      send_command("SETQUOTA", quota_root, RawData.new(list))
    +      send_command("SETQUOTA", quota_root, list)
         end
     
         # Sends a {SETACL command [RFC4314 §3.1]}[https://www.rfc-editor.org/rfc/rfc4314#section-3.1]
    
  • test/net/imap/test_imap_quota.rb+9 0 modified
    @@ -28,6 +28,15 @@ class IMAPQuotaTest < Net::IMAP::TestCase
           rcvd_cmd = server.commands.pop
           assert_equal "SETQUOTA",            rcvd_cmd.name
           assert_equal '"" ()',               rcvd_cmd.args
    +
    +      assert_raise_with_message(Net::IMAP::DataFormatError,
    +                                "512.0 is not a valid number64") do
    +        imap.setquota "INBOX", 512.0
    +      end
    +      assert_raise_with_message(Net::IMAP::DataFormatError,
    +                                '"512 620" is not a valid number64') do
    +        imap.setquota "INBOX", "512 620"
    +      end
         end
       end
     end
    
47c72186d272

🐛 Validate RawData and wait to continue literals

https://github.com/ruby/net-imapnick evansMar 10, 2026via ghsa
3 files changed · +312 2
  • lib/net/imap/command_data.rb+78 2 modified
    @@ -154,9 +154,85 @@ 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+.
    +    #
    +    # NOTE: The current implementation does not validate whether the connection
    +    # currently supports UTF-8.  Future versions may change.
    +    #
    +    # 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:
    +      def initialize(data:)
    +        data = String(data.to_str)
    +        data = if data.encoding in Encoding::ASCII | Encoding::UTF_8
    +          -data
    +        elsif data.ascii_only?
    +          -(data.dup.force_encoding("ASCII"))
    +        else
    +          -(data.dup.force_encoding("UTF-8"))
    +        end
    +        super
    +        validate
    +      end
    +
    +      def validate
    +        if data.include?("\0")
    +          raise DataFormatError, "NULL byte must be binary literal encoded"
    +        elsif !data.valid_encoding?
    +          raise DataFormatError, "invalid UTF-8 must be 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?
    +
    +      def send_data(imap, tag) = imap.__send__(:put_string, data)
    +    end
    +
         class RawData < CommandData # :nodoc:
    -      def send_data(imap, tag)
    -        imap.__send__(:put_string, data)
    +      def initialize(data:)
    +        data = split_parts(data)
    +        super
    +        validate
    +      end
    +
    +      def send_data(imap, tag) = data.each do _1.send_data(imap, tag) end
    +
    +      def validate
    +        return unless data.last in RawText(data: text)
    +        if text.rindex(/~?\{[1-9]\d*\+?\}\z/n)
    +          raise DataFormatError, "RawData cannot end with literal continuation"
    +        end
    +      end
    +
    +      private
    +
    +      def split_parts(data)
    +        data = data.b # dups and ensures BINARY encoding
    +        parts = []
    +        while data.match(/(~)?\{(0|[1-9]\d*)(\+)?\}\r\n/n)
    +          text, binary, bytesize, non_sync, data = $`, !!$1, $2, !!$3, $'
    +          bytesize = NumValidator.coerce_number64 bytesize
    +          parts << RawText[text] unless text.empty?
    +          parts << extract_literal(data, binary:, bytesize:, non_sync:)
    +          data.bytesplice(0, bytesize, "")
    +        end
    +        parts << RawText[data] unless data.empty?
    +        parts
    +      end
    +
    +      def 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:)
           end
         end
     
    
  • test/net/imap/test_command_data.rb+205 0 modified
    @@ -10,6 +10,8 @@ class CommandDataTest < Net::IMAP::TestCase
       Flag = Net::IMAP::Flag
       Literal = Net::IMAP::Literal
       Literal8 = Net::IMAP::Literal8
    +  RawText = Net::IMAP::RawText
    +  RawData = Net::IMAP::RawData
     
       Output = Data.define(:name, :args, :kwargs)
       TAG = Module.new.freeze
    @@ -162,4 +164,207 @@ class StringFormatterTest < Net::IMAP::TestCase
         end
       end
     
    +  class RawTextTest < CommandDataTest
    +    test "basic ASCII string" do
    +      imap.send_data RawText.new('foo "bar" (baz)')
    +      assert_equal [Output.put_string('foo "bar" (baz)')], imap.output
    +    end
    +
    +    test "allows IMAP atom-special symbols" do
    +      imap.send_data RawText.new('foo "bar" (baz)')
    +      imap.send_data RawText.new("(){}[]%*\"\\")
    +      imap.send_data RawText.new("(((((((((((((((( unbalanced ]]]]]]]]]]]]]")
    +      assert_equal [
    +        Output.put_string('foo "bar" (baz)'),
    +        Output.put_string("(){}[]%*\"\\"),
    +        Output.put_string("(((((((((((((((( unbalanced ]]]]]]]]]]]]]"),
    +      ], imap.output
    +    end
    +
    +    test "ASCII compatible string with another encodings" do
    +      imap.send_data RawText.new("foo bar".encode("cp1252"))
    +      assert_equal [
    +        Output.put_string("foo bar"),
    +      ], imap.output
    +    end
    +
    +    test "allows ASCII control chars" do
    +      text = RawText.new("beep\b beep\b escape!\e delete this:\x1f")
    +      imap.send_data text
    +      assert_equal [
    +        Output.put_string("beep\b beep\b escape!\e delete this:\x1f"),
    +      ], imap.output
    +    end
    +
    +    data(
    +      "NULL" => ["with \0 NULL", /NULL\b.+\bbyte/i],
    +      "CR"   => ["with \r CR",   /CR\b.+\bbyte/i],
    +      "LF"   => ["with \n LF",   /LF\b.+\bbyte/i],
    +    )
    +    test "invalid ASCII byte" do |(text, error_message)|
    +      try_multiple_encodings(error_message, text)
    +    end
    +
    +    # See Table 3-7, Well-Formed UTF-8 Byte Sequences, in The Unicode Standard:
    +    # https://www.unicode.org/versions/Unicode17.0.0/core-spec/chapter-3/#G27506
    +    data(
    +      "incomplete 2 byte sequence" => "\xc3".b,
    +      "invalid 2 byte sequence"    => "\xc3\x7f".b,
    +      "incomplete 3 byte sequence" => "\xe0\x80\x80".b,
    +      "invalid 3 byte sequence"    => "\xe0\x80\x80".b,
    +      "incomplete 4 byte sequence" => "\xf1\x80\x80".b,
    +      "invalid 4 byte sequence"    => "\xf0\x80\x80\x80".b,
    +      "first byte too high"        => "\xff\xaa\xaa\xaa".b,
    +      "UTF-16 surrogate pair"      => "\xFE\xFF\xD8\x3D\xDC\xA3\xFE\x0F".b,
    +      "windows-1252"               => "åêïõü".encode("windows-1252"),
    +    )
    +    test "invalid UTF-8" do |text|
    +      try_multiple_encodings(/invalid UTF-8/i, text)
    +    end
    +
    +    def with_multiple_encodings(data)
    +      yield data.b # BINARY
    +      yield data.dup.force_encoding("ASCII")
    +      yield data.dup.force_encoding("UTF-8")
    +      yield data.dup.force_encoding("cp1252")
    +    end
    +
    +    def try_multiple_encodings(error_message, data)
    +      with_multiple_encodings(data) do |encoded|
    +        assert_raise_with_message(DataFormatError, error_message) do
    +          RawText[encoded]
    +        end
    +      end
    +    end
    +  end
    +
    +  class RawDataTest < CommandDataTest
    +    test "simple raw text" do
    +      raw = RawData.new('foo "bar" baz')
    +      assert_equal [RawText['foo "bar" baz']], raw.data
    +      imap.send_data raw
    +      assert_equal [Output.put_string('foo "bar" baz')], imap.output
    +    end
    +
    +    test "a single literal" do
    +      raw = RawData.new("{7}\r\nfoo bar")
    +      assert_equal [Literal["foo bar", false]], raw.data
    +      imap.send_data raw, tag: "t1"
    +      assert_equal [
    +        Output.send_literal("foo bar", "t1", non_sync: false),
    +      ], imap.output
    +    end
    +
    +    test "literals embedded between text" do
    +      raw = RawData.new("foo bar {3}\r\nbaz {4+}\r\nquux etc")
    +      assert_equal [
    +        RawText["foo bar "],
    +        Literal["baz", false],
    +        RawText[" "],
    +        Literal["quux", true], # non-synchronizing
    +        RawText[" etc"],
    +      ], raw.data
    +      imap.send_data raw, tag: "t2"
    +      assert_equal [
    +        Output.put_string("foo bar "),
    +        Output.send_literal("baz", "t2", non_sync: false),
    +        Output.put_string(" "),
    +        Output.send_literal("quux", "t2", non_sync: true),
    +        Output.put_string(" etc"),
    +      ], imap.output
    +    end
    +
    +    test "empty literals" do
    +      raw = RawData.new("{0}\r\n{0+}\r\n~{0}\r\n~{0+}\r\n")
    +      assert_equal [
    +        Literal["", false],
    +        Literal["", true],
    +        Literal8["", false],
    +        Literal8["", true],
    +      ], raw.data
    +      imap.send_data raw, tag: "t2.2"
    +      assert_equal [
    +        Output.send_literal("", "t2.2", non_sync: false),
    +        Output.send_literal("", "t2.2", non_sync: true),
    +        Output.send_binary_literal("", "t2.2", non_sync: false),
    +        Output.send_binary_literal("", "t2.2", non_sync: true),
    +      ], imap.output
    +    end
    +
    +    test "raw text embedded between literals" do
    +      raw = RawData.new("{3}\r\nfoo bar")
    +      assert_equal [
    +        Literal["foo", false],
    +        RawText[" bar"]
    +      ], raw.data
    +      imap.send_data raw, tag: "t3"
    +      assert_equal [
    +        Output.send_literal("foo", "t3", non_sync: false),
    +        Output.put_string(" bar"),
    +      ], imap.output
    +    end
    +
    +    test "raw text followed by literal" do
    +      raw = RawData.new("foo {3}\r\nbar")
    +      assert_equal [
    +        RawText["foo "],
    +        Literal["bar", false],
    +      ], raw.data
    +      imap.send_data raw, tag: "t4"
    +      assert_equal [
    +        Output.put_string("foo "),
    +        Output.send_literal("bar", "t4", non_sync: false),
    +      ], imap.output
    +      imap.clear
    +    end
    +
    +    test "binary literal with regular literal" do
    +      raw = RawData.new("foo ~{7}\r\n\0bar\r\nbaz {4}\r\nquux")
    +      assert_equal [
    +        RawText["foo "],
    +        Literal8["\0bar\r\nb", false],
    +        RawText["az "],
    +        Literal["quux", false],
    +      ], raw.data
    +      imap.send_data raw, tag: "t5"
    +      assert_equal [
    +        Output.put_string("foo "),
    +        Output.send_binary_literal("\0bar\r\nb", "t5", non_sync: false),
    +        Output.put_string("az "),
    +        Output.send_literal("quux", "t5", non_sync: false),
    +      ], imap.output
    +    end
    +
    +    data(
    +      "CR"   => "with \r byte",
    +      "LF"   => "with \n byte",
    +      "NULL" => "with \0 byte",
    +      "CRLF" => "with \r\n bytes",
    +    )
    +    test "invalid bytes in raw text" do |data|
    +      assert_raise_with_message(DataFormatError, /must be.* literal encoded/i) do
    +        RawData.new(data:)
    +      end
    +    end
    +
    +    test "invalid literal" do |data|
    +      assert_raise_with_message(DataFormatError, /too few bytes/i) do
    +        RawData.new(data: "invalid literal {123}\r\ntoo small")
    +      end
    +
    +      assert_raise_with_message(DataFormatError, /NULL byte.*in.*literal/i) do
    +        RawData.new(data: "invalid literal {10}\r\ncontains \0 null")
    +      end
    +    end
    +
    +    test "invalid literal ending ('{123}')" do
    +      assert_raise(DataFormatError) do RawData.new(data: "literal {123}") end
    +      assert_raise(DataFormatError) do RawData.new(data: "literal+ {123+}") end
    +      assert_raise(DataFormatError) do RawData.new(data: "~literal ~{123}") end
    +      assert_raise(DataFormatError) do RawData.new(data: "~literal+ ~{123+}") end
    +      raw = RawData.new(data: " {123} ")
    +      assert_equal [RawText[" {123} "]], raw.data
    +    end
    +  end
    +
     end
    
  • test/net/imap/test_imap.rb+29 0 modified
    @@ -650,6 +650,35 @@ def test_send_symbol_as_flag
         end
       end
     
    +  def test_raw_data
    +    with_fake_server do |server, imap|
    +      server.on "TEST", &:done_ok
    +
    +      imap.__send__(:send_command, "TEST", Net::IMAP::RawData.new("foo bar"))
    +      assert_equal "foo bar", server.commands.pop.args
    +
    +      imap.__send__(:send_command, "TEST",
    +                    Net::IMAP::RawData.new("{3}\r\nfoo"),
    +                    Net::IMAP::RawData.new("~{4}\r\n\0bar"))
    +      assert_equal "{3}\r\nfoo ~{4}\r\n\0bar", server.commands.pop.args
    +
    +      # RawData must pass basic validation before sending command
    +      [
    +        "with \0 NULL",
    +        "with \r CR",
    +        "with \n LF",
    +        "with \r\n CRLF",
    +        "{1234}\r\nliteral is too small",
    +        "{1}\r\n\0 literal contains NULL",
    +      ].each do |data|
    +        assert_raise(Net::IMAP::DataFormatError) do
    +          imap.__send__(:send_command, "TEST", Net::IMAP::RawData[data:])
    +        end
    +        assert_empty server.commands
    +      end
    +    end
    +  end
    +
       test("send PartialRange args") do
         with_fake_server do |server, imap|
           server.on "TEST", &:done_ok
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

11

News mentions

1