From 75043c87880c37f1471074dd0175d855161d3756 Mon Sep 17 00:00:00 2001 From: nicholas evans Date: Sun, 16 Jul 2023 08:18:20 -0400 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Cache=20server=20capabilities;=20ad?= =?UTF-8?q?d=20#capable=3F(name)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Added methods: * `#capable?(name)` - the main API for discovering capabilities * `#capabilities` - calls `capability` when needed * `#capabilities_cached?` - whether capabilities are cached * `#clear_cached_capabilities` - clears the cache; is thread safe Fixes #31 --- lib/net/imap.rb | 164 +++++++++++++++++++++++++----------- test/net/imap/test_imap.rb | 167 +++++++++++++++++++++++++++++++++++++ 2 files changed, 282 insertions(+), 49 deletions(-) diff --git a/lib/net/imap.rb b/lib/net/imap.rb index fb28f76a9..d325732d7 100644 --- a/lib/net/imap.rb +++ b/lib/net/imap.rb @@ -173,6 +173,7 @@ module Net # == What's here? # # * {Connection control}[rdoc-ref:Net::IMAP@Connection+control+methods] + # * {Server capabilities}[rdoc-ref:Net::IMAP@Server+capabilities] # * {Core IMAP commands}[rdoc-ref:Net::IMAP@Core+IMAP+commands] # * {...for any state}[rdoc-ref:Net::IMAP@IMAP+commands+for+any+state] # * {...for the "not authenticated" state}[rdoc-ref:Net::IMAP@IMAP+commands+for+the+-22Not+Authenticated-22+state] @@ -191,6 +192,16 @@ module Net # - #disconnect: Disconnects the connection (without sending #logout first). # - #disconnected?: True if the connection has been closed. # + # === Server capabilities + # + # - #capable?: Returns whether the server supports a given capability. + # - #capabilities: Returns the server's capabilities as a list of strings. + # - #clear_cached_capabilities: Clears cached capabilities. + # + # The capabilities cache is automatically cleared after completing + # #starttls, #login, or #authenticate. + # - #capability: Sends the +CAPABILITY+ command and returns the #capabilities. + # # === Core \IMAP commands # # The following commands are defined either by @@ -227,8 +238,8 @@ module Net # # - #capability: Returns the server's capabilities as an array of strings. # - # Capabilities may change after #starttls, #authenticate, or #login - # and cached capabilities must be reloaded. + # In general, #capable? should be used rather than explicitly sending a + # +CAPABILITY+ command to the server. # - #noop: Allows the server to send unsolicited untagged #responses. # - #logout: Tells the server to end the session. Enters the "_logout_" state. # @@ -725,6 +736,9 @@ class IMAP < Protocol # Returns the initial greeting the server, an UntaggedResponse. attr_reader :greeting + # Implementation detail; only exposed for testing + attr_reader :cached_capabilities # :nodoc: + # Seconds to wait until a connection is opened. # If the IMAP object cannot open a connection within this time, # it raises a Net::OpenTimeout exception. The default value is 30 seconds. @@ -803,12 +817,11 @@ def disconnected? end # Sends a {CAPABILITY command [IMAP4rev1 §6.1.1]}[https://www.rfc-editor.org/rfc/rfc3501#section-6.1.1] - # and returns an array of capabilities that the server supports. Each - # capability is a string. + # and returns an array of capabilities that are supported by the server. + # Each capability is a string. Capabilities are case-insensitive. # - # See the {IANA IMAP4 capabilities - # registry}[http://www.iana.org/assignments/imap4-capabilities] for a list - # of all standard capabilities, and their reference RFCs. + # **NOTE**: Prefer to use #capable? or #capabilities instead, to avoid + # sending unnecessary commands and correctly invalidate cached capabilities. # # >>> # *Note* that Net::IMAP does not currently modify its @@ -817,26 +830,49 @@ def disconnected? # a certain capability is supported by a server before # using it. # - # Capability requirements—other than +IMAP4rev1+—are listed in the - # documentation for each command method. + # Related: #capable?, #capabilities, #enable # - # Related: #enable + def capability + synchronize do + send_command("CAPABILITY") + return @responses.delete("CAPABILITY")[-1] + end + end + + # Returns whether the server supports a given capability. When available, + # cached capabilities are used without sending a new #capability command to + # the server. + # + # See the {IANA IMAP4 capabilities + # registry}[http://www.iana.org/assignments/imap4-capabilities] for a list + # of all standard capabilities, and their reference RFCs. + # + # >>> + # *Note* that Net::IMAP does not currently modify its behaviour + # according to the capabilities of the server; it is up to the user of the + # class to ensure that a certain capability is supported by a server + # before using it. + # + # Capability requirements—other than +IMAP4rev1+—are listed in the + # documentation for each command method. + # + # Related: #capabilities, #capability, #enable # # ===== Basic IMAP4rev1 capabilities # - # All IMAP4rev1 servers must include +IMAP4rev1+ in their capabilities list. - # All IMAP4rev1 servers must _implement_ the +STARTTLS+, - # AUTH=PLAIN, and +LOGINDISABLED+ capabilities, and clients must - # respect their presence or absence. See the capabilities requirements on - # #starttls, #login, and #authenticate. + # IMAP4rev1 servers must include +IMAP4rev1+ in their capabilities list. + # IMAP4rev1 servers must _implement_ the +STARTTLS+, AUTH=PLAIN, + # and +LOGINDISABLED+ capabilities, and clients must respect their presence + # or absence. See the capabilities requirements on #starttls, #login, and + # #authenticate. # # ===== Using IMAP4rev1 extensions # - # IMAP4rev1 servers must not activate incompatible behavior until an - # explicit client action invokes a capability, e.g. sending a command or - # command argument specific to that capability. Extensions with backward - # compatible behavior, such as response codes or mailbox attributes, may - # be sent at any time. + # IMAP4rev1 servers must not activate behavior that is incompatible with the + # base specification until an explicit client action invokes a capability, + # e.g. sending a command or command argument specific to that capability. + # Servers may send data with backward compatible behavior, such as response + # codes or mailbox attributes, at any time without client action. # # Invoking capabilities which are unknown to Net::IMAP may cause unexpected # behavior and errors, for example ResponseParseError is raised when unknown @@ -844,22 +880,44 @@ def disconnected? # are unsupported by the server may raise NoResponseError, BadResponseError, # or cause other unexpected behavior. # + # Some capabilities must be explicitly activated using the #enable command. + # See #enable for more details. + # # ===== Caching +CAPABILITY+ responses # - # Servers may send their capability list, unsolicited, using the - # +CAPABILITY+ response code or an untagged +CAPABILITY+ response. These - # responses can be retrieved and cached using #responses or - # #add_response_handler. + # Servers may send their capability list unsolicited, using the +CAPABILITY+ + # response code or an untagged +CAPABILITY+ response. Cached capabilities + # are discarded after #starttls, #login, or #authenticate. Both caching and + # cache invalidation are handled internally by Net::IMAP. # - # But cached capabilities _must_ be discarded after #starttls, #login, or - # #authenticate. The OK TaggedResponse to #login and #authenticate may - # include +CAPABILITY+ response code data, but the TaggedResponse for - # #starttls is sent clear-text and cannot be trusted. + def capable?(capability) capabilities.include? capability.to_s.upcase end + + # Returns the server capabilities. # - def capability + # Cached capabilities are used without sending a new #capability command to + # the server. + # + # In general, #capable? should be preferred because it doesn't rely on the + # representation of capabilities as an array of uppercase strings. + # + # Related: #capable?, #capability + def capabilities + @cached_capabilities ||= capability.freeze + end + + # Returns whether capabilities have been cached. When true, #capable? and + # #capabilities don't require sending a #capability command to the server. + def capabilities_cached? + !!@cached_capabilities + end + + # Clears capabilities that are currently cached by the Net::IMAP client. + # This forces a #capability command to be sent the next time that #capable? + # or #capabilities? are called. + def clear_cached_capabilities synchronize do - send_command("CAPABILITY") - return @responses.delete("CAPABILITY")[-1] + clear_responses("CAPABILITY") + @cached_capabilities = nil end end @@ -870,8 +928,7 @@ def capability # Note that the user should first check if the server supports the ID # capability. For example: # - # capabilities = imap.capability - # if capabilities.include?("ID") + # if capable?(:ID) # id = imap.id( # name: "my IMAP client (ruby)", # version: MyIMAP::VERSION, @@ -940,7 +997,7 @@ def logout # The server's capabilities must include +STARTTLS+. # # Server capabilities may change after #starttls, #login, and #authenticate. - # Cached capabilities _must_ be invalidated after this method completes. + # Cached capabilities are invalidated after this method completes. # # The TaggedResponse to #starttls is sent clear-text, so the server must # *not* send capabilities in the #starttls response and clients must @@ -948,7 +1005,7 @@ def logout # unsolicited untagged response immeditely _after_ #starttls completes. # def starttls(options = {}, verify = true) - send_command("STARTTLS") do |resp| + ok_response = send_command("STARTTLS") do |resp| if resp.kind_of?(TaggedResponse) && resp.name == "OK" begin # for backward compatibility @@ -959,6 +1016,8 @@ def starttls(options = {}, verify = true) start_tls_session(options) end end + clear_cached_capabilities + ok_response end # :call-seq: @@ -1015,9 +1074,9 @@ def starttls(options = {}, verify = true) # "AUTH=#{mechanism}" for that mechanism is a server capability. # # Server capabilities may change after #starttls, #login, and #authenticate. - # Cached capabilities _must_ be invalidated after this method completes. - # The TaggedResponse to #authenticate may include updated capabilities in - # its ResponseCode. + # Cached capabilities are invalidated after this method completes. The + # TaggedResponse to #authenticate may include updated capabilities in its + # ResponseCode. # # ===== Example # If the authenticators ignore unhandled keyword arguments, the same config @@ -1030,18 +1089,17 @@ def starttls(options = {}, verify = true) # password: proc { password ||= ui.prompt_for_password }, # oauth2_token: proc { accesstok ||= kms.fresh_access_token }, # } - # capa = imap.capability - # if capa.include? "AUTH=OAUTHBEARER" + # if capable? "AUTH=OAUTHBEARER" # imap.authenticate "OAUTHBEARER", **creds # authcid, oauth2_token - # elsif capa.include? "AUTH=XOAUTH2" + # elsif capable? "AUTH=XOAUTH2" # imap.authenticate "XOAUTH2", **creds # authcid, oauth2_token - # elsif capa.include? "AUTH=SCRAM-SHA-256" + # elsif capable? "AUTH=SCRAM-SHA-256" # imap.authenticate "SCRAM-SHA-256", **creds # authcid, password - # elsif capa.include? "AUTH=PLAIN" + # elsif capable? "AUTH=PLAIN" # imap.authenticate "PLAIN", **creds # authcid, password - # elsif capa.include? "AUTH=DIGEST-MD5" + # elsif capable? "AUTH=DIGEST-MD5" # imap.authenticate "DIGEST-MD5", **creds # authcid, password - # elsif capa.include? "LOGINDISABLED" + # elsif capable? "LOGINDISABLED" # raise "the server has disabled login" # else # imap.login username, password @@ -1049,7 +1107,7 @@ def starttls(options = {}, verify = true) # def authenticate(mechanism, ...) authenticator = self.class.authenticator(mechanism, ...) - send_command("AUTHENTICATE", mechanism) do |resp| + ok_response = send_command("AUTHENTICATE", mechanism) do |resp| if resp.instance_of?(ContinuationRequest) data = authenticator.process(resp.data.text.unpack("m")[0]) s = [data].pack("m0") @@ -1057,6 +1115,9 @@ def authenticate(mechanism, ...) put_string(CRLF) end end + clear_cached_capabilities + # TODO: use capabilities from ok_response + ok_response end # Sends a {LOGIN command [IMAP4rev1 §6.2.3]}[https://www.rfc-editor.org/rfc/rfc3501#section-6.2.3] @@ -1081,7 +1142,10 @@ def authenticate(mechanism, ...) # ResponseCode. # def login(user, password) - send_command("LOGIN", user, password) + ok_response = send_command("LOGIN", user, password) + clear_cached_capabilities + # TODO: use capabilities from ok_response + ok_response end # Sends a {SELECT command [IMAP4rev1 §6.3.1]}[https://www.rfc-editor.org/rfc/rfc3501#section-6.3.1] @@ -1261,8 +1325,7 @@ def list(refname, mailbox) # # ===== For example: # - # capabilities = imap.capability - # if capabilities.include?("NAMESPACE") + # if capable?("NAMESPACE") # namespaces = imap.namespace # if namespace = namespaces.personal.first # prefix = namespace.prefix # e.g. "" or "INBOX." @@ -2394,6 +2457,9 @@ def record_untagged_response_code(resp) if resp.data.instance_of?(ResponseText) && (code = resp.data.code) record_response(code.name, code.data) + if code.name.casecmp?("CAPABILITY") + @cached_capabilities ||= code.data.freeze + end end end diff --git a/test/net/imap/test_imap.rb b/test/net/imap/test_imap.rb index db87c19c5..e2b53e986 100644 --- a/test/net/imap/test_imap.rb +++ b/test/net/imap/test_imap.rb @@ -849,6 +849,173 @@ def test_uidplus_uidnotsticky end end + test "#clear_cached_capabilities clears cached capabilities" do + with_mock_server do |server, imap| + assert imap.capable?(:IMAP4rev1) + assert imap.capabilities_cached? + assert_empty server.commands + imap.clear_cached_capabilities + refute imap.capabilities_cached? + assert imap.capable?(:IMAP4rev1) + assert_equal "CAPABILITY", server.commands.pop.name + end + end + + test "#capabilities automatically sends CAPABILITY command" do + with_mock_server do |server, imap| + imap.clear_cached_capabilities + assert_include imap.capabilities, "IMAP4REV1" + assert_equal "CAPABILITY", server.commands.pop.name + end + end + + test "#capable? automatically sends CAPABILITY command" do + with_mock_server do |server, imap| + imap.clear_cached_capabilities + assert imap.capable? :IMAP4rev1 + assert_equal "CAPABILITY", server.commands.pop.name + end + end + + test "#capabilities is cached" do + with_mock_server do |server, imap| + imap.clear_cached_capabilities + expected = %w[IMAP4REV1 NAMESPACE MOVE IDLE UTF8=ACCEPT] + 10.times do + assert_equal expected, imap.capabilities + end + assert_equal "CAPABILITY", server.commands.pop.name + assert_empty server.commands + end + end + + test "#capable? is cached" do + with_mock_server do |server, imap| + imap.clear_cached_capabilities + 10.times do + assert imap.capable? "IMAP4rev1" + assert imap.capable? :NAMESPACE + assert imap.capable? "idle" + refute imap.capable? "LOGINDISABLED" + refute imap.capable? "auth=plain" + end + assert_equal "CAPABILITY", server.commands.pop.name + assert_empty server.commands + end + end + + test "#capabilities caches greeting capabilities (cleartext)" do + with_mock_server( + preauth: false, cleartext_login: false, cleartext_auth: false, + ) do |server, imap| + assert imap.capabilities_cached? + assert_equal %w[IMAP4REV1 STARTTLS LOGINDISABLED], imap.capabilities + assert_empty server.commands + end + end + + test "#capabilities caches greeting capabilities (PREAUTH)" do + with_mock_server(preauth: true) do |server, imap| + assert imap.capabilities_cached? + assert_equal %w[IMAP4REV1 NAMESPACE MOVE IDLE UTF8=ACCEPT], + imap.capabilities + assert_empty server.commands + end + end + + if defined?(OpenSSL::SSL::SSLError) + test "#capabilities caches greeting capabilities (implicit TLS)" do + with_mock_server(preauth: false, implicit_tls: true) do |server, imap| + assert imap.capabilities_cached? + assert_equal %w[IMAP4REV1 AUTH=PLAIN], imap.capabilities + assert_empty server.commands + end + end + + test "#capabilities cache is cleared after #starttls" do + with_mock_server(preauth: false, cleartext_auth: false) do |server, imap| + assert imap.capable? :IMAP4rev1 + + imap.starttls(ca_file: server.config.tls[:ca_file]) + assert_equal "STARTTLS", server.commands.pop.name + refute imap.capabilities_cached? + + assert imap.capable? :IMAP4rev1 + assert_equal "CAPABILITY", server.commands.pop.name + assert_empty server.commands + end + end + end + + test "#capabilities cache is cleared after #login" do + with_mock_server(preauth: false, cleartext_login: true) do |server, imap| + assert imap.capable? :IMAP4rev1 + + imap.login("test_user", "test-password") + assert_equal "LOGIN", server.commands.pop.name + refute imap.capabilities_cached? + + assert imap.capable? :IMAP4rev1 + assert_equal "CAPABILITY", server.commands.pop.name + assert_empty server.commands + end + end + + test "#capabilities cache is cleared after #authenticate" do + with_mock_server(preauth: false, cleartext_auth: true) do |server, imap| + assert imap.capable?("AUTH=PLAIN") + + imap.authenticate("PLAIN", "test_user", "test-password") + assert_equal "AUTHENTICATE", server.commands.pop.name + refute imap.capabilities_cached? + + assert imap.capable? :IMAP4rev1 + assert_equal "CAPABILITY", server.commands.pop.name + assert_empty server.commands + end + end + + # TODO: capabilities cache IGNORES tagged OK response to STARTTLS + # TODO: capabilities cache can use tagged OK response to LOGIN + # TODO: capabilities cache can use tagged OK response to AUTHENTICATE + + test "#capabilities cache is NOT cleared after #login fails" do + with_mock_server(preauth: false, cleartext_auth: true) do |server, imap| + original_capabilities = imap.capabilities + begin + imap.login("wrong_user", "wrong-password") + rescue Net::IMAP::NoResponseError + end + assert_equal "LOGIN", server.commands.pop.name + assert_equal original_capabilities, imap.capabilities + assert_empty server.commands + end + end + + test "#capabilities cache is NOT cleared after #authenticate fails" do + with_mock_server(preauth: false, cleartext_auth: true) do |server, imap| + original_capabilities = imap.capabilities + begin + imap.authenticate("PLAIN", "wrong_user", "wrong-password") + rescue Net::IMAP::NoResponseError + end + assert_equal "AUTHENTICATE", server.commands.pop.name + assert_equal original_capabilities, imap.capabilities + assert_empty server.commands + end + end + + # NOTE: other recorded responses are cleared after #select + test "#capabilities cache is retained after selecting a mailbox" do + with_mock_server do |server, imap| + original_capabilities = imap.capabilities + imap.select "inbox" + assert_equal "SELECT", server.commands.pop.name + assert_equal original_capabilities, imap.capabilities + assert_empty server.commands + end + end + def test_enable with_mock_server( with_extensions: %i[ENABLE CONDSTORE UTF8=ACCEPT],