diff options
Diffstat (limited to 'lib/net')
| -rw-r--r-- | lib/net/ftp.rb | 1436 | ||||
| -rw-r--r-- | lib/net/http.rb | 2757 | ||||
| -rw-r--r-- | lib/net/http/backward.rb | 26 | ||||
| -rw-r--r-- | lib/net/http/exceptions.rb | 26 | ||||
| -rw-r--r-- | lib/net/http/generic_request.rb | 338 | ||||
| -rw-r--r-- | lib/net/http/header.rb | 494 | ||||
| -rw-r--r-- | lib/net/http/proxy_delta.rb | 17 | ||||
| -rw-r--r-- | lib/net/http/request.rb | 21 | ||||
| -rw-r--r-- | lib/net/http/requests.rb | 123 | ||||
| -rw-r--r-- | lib/net/http/response.rb | 419 | ||||
| -rw-r--r-- | lib/net/http/responses.rb | 299 | ||||
| -rw-r--r-- | lib/net/http/status.rb | 83 | ||||
| -rw-r--r-- | lib/net/https.rb | 166 | ||||
| -rw-r--r-- | lib/net/imap.rb | 1745 | ||||
| -rw-r--r-- | lib/net/pop.rb | 393 | ||||
| -rw-r--r-- | lib/net/protocol.rb | 120 | ||||
| -rw-r--r-- | lib/net/smtp.rb | 851 | ||||
| -rw-r--r-- | lib/net/telnet.rb | 749 |
18 files changed, 5945 insertions, 4118 deletions
diff --git a/lib/net/ftp.rb b/lib/net/ftp.rb index dfbcf1499f..9902f9dc65 100644 --- a/lib/net/ftp.rb +++ b/lib/net/ftp.rb @@ -1,11 +1,12 @@ -# +# frozen_string_literal: true +# # = net/ftp.rb - FTP Client Library -# +# # Written by Shugo Maeda <shugo@ruby-lang.org>. # # Documentation by Gavin Sinclair, sourced from "Programming Ruby" (Hunt/Thomas) # and "Ruby In a Nutshell" (Matsumoto), used with permission. -# +# # This library is distributed under the terms of the Ruby license. # You can freely distribute/modify this library. # @@ -16,15 +17,22 @@ require "socket" require "monitor" +require "net/protocol" +require "time" +begin + require "openssl" +rescue LoadError +end module Net # :stopdoc: class FTPError < StandardError; end class FTPReplyError < FTPError; end - class FTPTempError < FTPError; end - class FTPPermError < FTPError; end + class FTPTempError < FTPError; end + class FTPPermError < FTPError; end class FTPProtoError < FTPError; end + class FTPConnectionError < FTPError; end # :startdoc: # @@ -34,12 +42,12 @@ module Net # advantage of Ruby's style and strengths. # # == Example - # + # # require 'net/ftp' # # === Example 1 - # - # ftp = Net::FTP.new('ftp.netlab.co.jp') + # + # ftp = Net::FTP.new('example.com') # ftp.login # files = ftp.chdir('pub/lang/ruby/contrib') # files = ftp.list('n*') @@ -48,7 +56,7 @@ module Net # # === Example 2 # - # Net::FTP.open('ftp.netlab.co.jp') do |ftp| + # Net::FTP.open('example.com') do |ftp| # ftp.login # files = ftp.chdir('pub/lang/ruby/contrib') # files = ftp.list('n*') @@ -69,19 +77,24 @@ module Net # - #rename # - #delete # - class FTP + class FTP < Protocol include MonitorMixin - + if defined?(OpenSSL::SSL) + include OpenSSL + include SSL + end + # :stopdoc: FTP_PORT = 21 CRLF = "\r\n" - DEFAULT_BLOCKSIZE = 4096 + DEFAULT_BLOCKSIZE = BufferedIO::BUFSIZE + @@default_passive = true # :startdoc: - + # When +true+, transfers are performed in binary mode. Default: +true+. - attr_accessor :binary + attr_reader :binary - # When +true+, the connection is in passive mode. Default: +false+. + # When +true+, the connection is in passive mode. Default: +true+. attr_accessor :passive # When +true+, all traffic to and from the server is written @@ -92,6 +105,31 @@ module Net # transfers are resumed or restarted. Default: +false+. attr_accessor :resume + # Number of seconds to wait for the connection to open. Any number + # may be used, including Floats for fractional seconds. If the FTP + # object cannot open a connection in this many seconds, it raises a + # Net::OpenTimeout exception. The default value is +nil+. + attr_accessor :open_timeout + + # Number of seconds to wait for the TLS handshake. Any number + # may be used, including Floats for fractional seconds. If the FTP + # object cannot complete the TLS handshake in this many seconds, it + # raises a Net::OpenTimeout exception. The default value is +nil+. + # If +ssl_handshake_timeout+ is +nil+, +open_timeout+ is used instead. + attr_accessor :ssl_handshake_timeout + + # Number of seconds to wait for one block to be read (via one read(2) + # call). Any number may be used, including Floats for fractional + # seconds. If the FTP object cannot read data in this many seconds, + # it raises a Timeout::Error exception. The default value is 60 seconds. + attr_reader :read_timeout + + # Setter for the read_timeout attribute. + def read_timeout=(sec) + @sock.read_timeout = sec + @read_timeout = sec + end + # The server's welcome message. attr_reader :welcome @@ -101,66 +139,224 @@ module Net # The server's last response. attr_reader :last_response - + + # When +true+, connections are in passive mode per default. + # Default: +true+. + def self.default_passive=(value) + @@default_passive = value + end + + # When +true+, connections are in passive mode per default. + # Default: +true+. + def self.default_passive + @@default_passive + end + # # A synonym for <tt>FTP.new</tt>, but with a mandatory host parameter. # # If a block is given, it is passed the +FTP+ object, which will be closed # when the block finishes, or when an exception is raised. # - def FTP.open(host, user = nil, passwd = nil, acct = nil) + def FTP.open(host, *args) if block_given? - ftp = new(host, user, passwd, acct) + ftp = new(host, *args) begin yield ftp ensure ftp.close end else - new(host, user, passwd, acct) + new(host, *args) end end - + + # :call-seq: + # Net::FTP.new(host = nil, options = {}) # # Creates and returns a new +FTP+ object. If a +host+ is given, a connection - # is made. Additionally, if the +user+ is given, the given user name, - # password, and (optionally) account are used to log in. See #login. - # - def initialize(host = nil, user = nil, passwd = nil, acct = nil) + # is made. + # + # +options+ is an option hash, each key of which is a symbol. + # + # The available options are: + # + # port:: Port number (default value is 21) + # ssl:: If options[:ssl] is true, then an attempt will be made + # to use SSL (now TLS) to connect to the server. For this + # to work OpenSSL [OSSL] and the Ruby OpenSSL [RSSL] + # extensions need to be installed. If options[:ssl] is a + # hash, it's passed to OpenSSL::SSL::SSLContext#set_params + # as parameters. + # private_data_connection:: If true, TLS is used for data connections. + # Default: +true+ when options[:ssl] is true. + # username:: Username for login. If options[:username] is the string + # "anonymous" and the options[:password] is +nil+, + # "anonymous@" is used as a password. + # password:: Password for login. + # account:: Account information for ACCT. + # passive:: When +true+, the connection is in passive mode. Default: + # +true+. + # open_timeout:: Number of seconds to wait for the connection to open. + # See Net::FTP#open_timeout for details. Default: +nil+. + # read_timeout:: Number of seconds to wait for one block to be read. + # See Net::FTP#read_timeout for details. Default: +60+. + # ssl_handshake_timeout:: Number of seconds to wait for the TLS + # handshake. + # See Net::FTP#ssl_handshake_timeout for + # details. Default: +nil+. + # debug_mode:: When +true+, all traffic to and from the server is + # written to +$stdout+. Default: +false+. + # + def initialize(host = nil, user_or_options = {}, passwd = nil, acct = nil) super() + begin + options = user_or_options.to_hash + rescue NoMethodError + # for backward compatibility + options = {} + options[:username] = user_or_options + options[:password] = passwd + options[:account] = acct + end + @host = nil + if options[:ssl] + unless defined?(OpenSSL::SSL) + raise "SSL extension not installed" + end + ssl_params = options[:ssl] == true ? {} : options[:ssl] + @ssl_context = SSLContext.new + @ssl_context.set_params(ssl_params) + if defined?(VerifyCallbackProc) + @ssl_context.verify_callback = VerifyCallbackProc + end + @ssl_context.session_cache_mode = + OpenSSL::SSL::SSLContext::SESSION_CACHE_CLIENT | + OpenSSL::SSL::SSLContext::SESSION_CACHE_NO_INTERNAL_STORE + @ssl_context.session_new_cb = proc {|sock, sess| @ssl_session = sess } + @ssl_session = nil + if options[:private_data_connection].nil? + @private_data_connection = true + else + @private_data_connection = options[:private_data_connection] + end + else + @ssl_context = nil + if options[:private_data_connection] + raise ArgumentError, + "private_data_connection can be set to true only when ssl is enabled" + end + @private_data_connection = false + end @binary = true - @passive = false - @debug_mode = false + if options[:passive].nil? + @passive = @@default_passive + else + @passive = options[:passive] + end + if options[:debug_mode].nil? + @debug_mode = false + else + @debug_mode = options[:debug_mode] + end @resume = false + @bare_sock = @sock = NullSocket.new + @logged_in = false + @open_timeout = options[:open_timeout] + @ssl_handshake_timeout = options[:ssl_handshake_timeout] + @read_timeout = options[:read_timeout] || 60 if host - connect(host) - if user - login(user, passwd, acct) - end + connect(host, options[:port] || FTP_PORT) + if options[:username] + login(options[:username], options[:password], options[:account]) + end + end + end + + # A setter to toggle transfers in binary mode. + # +newmode+ is either +true+ or +false+ + def binary=(newmode) + if newmode != @binary + @binary = newmode + send_type_command if @logged_in + end + end + + # Sends a command to destination host, with the current binary sendmode + # type. + # + # If binary mode is +true+, then "TYPE I" (image) is sent, otherwise "TYPE + # A" (ascii) is sent. + def send_type_command # :nodoc: + if @binary + voidcmd("TYPE I") + else + voidcmd("TYPE A") end end + private :send_type_command + + # Toggles transfers in binary mode and yields to a block. + # This preserves your current binary send mode, but allows a temporary + # transaction with binary sendmode of +newmode+. + # + # +newmode+ is either +true+ or +false+ + def with_binary(newmode) # :nodoc: + oldmode = binary + self.binary = newmode + begin + yield + ensure + self.binary = oldmode + end + end + private :with_binary # Obsolete - def return_code - $stderr.puts("warning: Net::FTP#return_code is obsolete and do nothing") + def return_code # :nodoc: + warn("Net::FTP#return_code is obsolete and do nothing", uplevel: 1) return "\n" end # Obsolete - def return_code=(s) - $stderr.puts("warning: Net::FTP#return_code= is obsolete and do nothing") + def return_code=(s) # :nodoc: + warn("Net::FTP#return_code= is obsolete and do nothing", uplevel: 1) end - def open_socket(host, port) - if defined? SOCKSsocket and ENV["SOCKS_SERVER"] - @passive = true - return SOCKSsocket.open(host, port) - else - return TCPSocket.open(host, port) - end + # Constructs a socket with +host+ and +port+. + # + # If SOCKSSocket is defined and the environment (ENV) defines + # SOCKS_SERVER, then a SOCKSSocket is returned, else a Socket is + # returned. + def open_socket(host, port) # :nodoc: + return Timeout.timeout(@open_timeout, OpenTimeout) { + if defined? SOCKSSocket and ENV["SOCKS_SERVER"] + @passive = true + SOCKSSocket.open(host, port) + else + Socket.tcp(host, port) + end + } end private :open_socket - + + def start_tls_session(sock) + ssl_sock = SSLSocket.new(sock, @ssl_context) + ssl_sock.sync_close = true + ssl_sock.hostname = @host if ssl_sock.respond_to? :hostname= + if @ssl_session && + Process.clock_gettime(Process::CLOCK_REALTIME) < @ssl_session.time.to_f + @ssl_session.timeout + # ProFTPD returns 425 for data connections if session is not reused. + ssl_sock.session = @ssl_session + end + ssl_socket_connect(ssl_sock, @ssl_handshake_timeout || @open_timeout) + if @ssl_context.verify_mode != VERIFY_NONE + ssl_sock.post_connection_check(@host) + end + return ssl_sock + end + private :start_tls_session + # # Establishes an FTP connection to host, optionally overriding the default # port. If the environment variable +SOCKS_SERVER+ is set, sets up the @@ -169,226 +365,261 @@ module Net # def connect(host, port = FTP_PORT) if @debug_mode - print "connect: ", host, ", ", port, "\n" + print "connect: ", host, ", ", port, "\n" end synchronize do - @sock = open_socket(host, port) - voidresp + @host = host + @bare_sock = open_socket(host, port) + @sock = BufferedSocket.new(@bare_sock, read_timeout: @read_timeout) + voidresp + if @ssl_context + begin + voidcmd("AUTH TLS") + ssl_sock = start_tls_session(@bare_sock) + @sock = BufferedSSLSocket.new(ssl_sock, read_timeout: @read_timeout) + if @private_data_connection + voidcmd("PBSZ 0") + voidcmd("PROT P") + end + rescue OpenSSL::SSL::SSLError, OpenTimeout + @sock.close + raise + end + end end end # - # WRITEME or make private + # Set the socket used to connect to the FTP server. # + # May raise FTPReplyError if +get_greeting+ is false. def set_socket(sock, get_greeting = true) synchronize do - @sock = sock - if get_greeting - voidresp - end + @sock = sock + if get_greeting + voidresp + end end end - def sanitize(s) + # If string +s+ includes the PASS command (password), then the contents of + # the password are cleaned from the string using "*" + def sanitize(s) # :nodoc: if s =~ /^PASS /i - return s[0, 5] + "*" * (s.length - 5) + return s[0, 5] + "*" * (s.length - 5) else - return s + return s end end private :sanitize - - def putline(line) + + # Ensures that +line+ has a control return / line feed (CRLF) and writes + # it to the socket. + def putline(line) # :nodoc: if @debug_mode - print "put: ", sanitize(line), "\n" + print "put: ", sanitize(line), "\n" + end + if /[\r\n]/ =~ line + raise ArgumentError, "A line must not contain CR or LF" end line = line + CRLF @sock.write(line) end private :putline - - def getline + + # Reads a line from the sock. If EOF, then it will raise EOFError + def getline # :nodoc: line = @sock.readline # if get EOF, raise EOFError line.sub!(/(\r\n|\n|\r)\z/n, "") if @debug_mode - print "get: ", sanitize(line), "\n" + print "get: ", sanitize(line), "\n" end return line end private :getline - - def getmultiline - line = getline - buff = line - if line[3] == ?- - code = line[0, 3] - begin - line = getline - buff << "\n" << line - end until line[0, 3] == code and line[3] != ?- - end - return buff << "\n" + + # Receive a section of lines until the response code's match. + def getmultiline # :nodoc: + lines = [] + lines << getline + code = lines.last.slice(/\A([0-9a-zA-Z]{3})-/, 1) + if code + delimiter = code + " " + begin + lines << getline + end until lines.last.start_with?(delimiter) + end + return lines.join("\n") + "\n" end private :getmultiline - - def getresp + + # Receives a response from the destination host. + # + # Returns the response code or raises FTPTempError, FTPPermError, or + # FTPProtoError + def getresp # :nodoc: @last_response = getmultiline @last_response_code = @last_response[0, 3] case @last_response_code when /\A[123]/ - return @last_response + return @last_response when /\A4/ - raise FTPTempError, @last_response + raise FTPTempError, @last_response when /\A5/ - raise FTPPermError, @last_response + raise FTPPermError, @last_response else - raise FTPProtoError, @last_response + raise FTPProtoError, @last_response end end private :getresp - - def voidresp + + # Receives a response. + # + # Raises FTPReplyError if the first position of the response code is not + # equal 2. + def voidresp # :nodoc: resp = getresp - if resp[0] != ?2 - raise FTPReplyError, resp + if !resp.start_with?("2") + raise FTPReplyError, resp end end private :voidresp - + # # Sends a command and returns the response. # def sendcmd(cmd) synchronize do - putline(cmd) - return getresp + putline(cmd) + return getresp end end - + # # Sends a command and expect a response beginning with '2'. # def voidcmd(cmd) synchronize do - putline(cmd) - voidresp - end - end - - def sendport(host, port) - af = (@sock.peeraddr)[0] - if af == "AF_INET" - hbytes = host.split(".") - pbytes = [port / 256, port % 256] - bytes = hbytes + pbytes - cmd = "PORT " + bytes.join(",") - elsif af == "AF_INET6" - cmd = "EPRT |2|" + host + "|" + sprintf("%d", port) + "|" + putline(cmd) + voidresp + end + end + + # Constructs and send the appropriate PORT (or EPRT) command + def sendport(host, port) # :nodoc: + remote_address = @bare_sock.remote_address + if remote_address.ipv4? + cmd = "PORT " + (host.split(".") + port.divmod(256)).join(",") + elsif remote_address.ipv6? + cmd = sprintf("EPRT |2|%s|%d|", host, port) else - raise FTPProtoError, host + raise FTPProtoError, host end voidcmd(cmd) end private :sendport - - def makeport - sock = TCPServer.open(@sock.addr[3], 0) - port = sock.addr[1] - host = sock.addr[3] - resp = sendport(host, port) - return sock + + # Constructs a TCPServer socket + def makeport # :nodoc: + Addrinfo.tcp(@bare_sock.local_address.ip_address, 0).listen end private :makeport - - def makepasv - if @sock.peeraddr[0] == "AF_INET" - host, port = parse227(sendcmd("PASV")) + + # sends the appropriate command to enable a passive connection + def makepasv # :nodoc: + if @bare_sock.remote_address.ipv4? + host, port = parse227(sendcmd("PASV")) else - host, port = parse229(sendcmd("EPSV")) - # host, port = parse228(sendcmd("LPSV")) + host, port = parse229(sendcmd("EPSV")) + # host, port = parse228(sendcmd("LPSV")) end return host, port end private :makepasv - - def transfercmd(cmd, rest_offset = nil) + + # Constructs a connection for transferring data + def transfercmd(cmd, rest_offset = nil) # :nodoc: if @passive - host, port = makepasv - conn = open_socket(host, port) - if @resume and rest_offset - resp = sendcmd("REST " + rest_offset.to_s) - if resp[0] != ?3 - raise FTPReplyError, resp - end - end - resp = sendcmd(cmd) - if resp[0] != ?1 - raise FTPReplyError, resp - end + host, port = makepasv + conn = open_socket(host, port) + if @resume and rest_offset + resp = sendcmd("REST " + rest_offset.to_s) + if !resp.start_with?("3") + raise FTPReplyError, resp + end + end + resp = sendcmd(cmd) + # skip 2XX for some ftp servers + resp = getresp if resp.start_with?("2") + if !resp.start_with?("1") + raise FTPReplyError, resp + end else - sock = makeport - if @resume and rest_offset - resp = sendcmd("REST " + rest_offset.to_s) - if resp[0] != ?3 - raise FTPReplyError, resp - end - end - resp = sendcmd(cmd) - if resp[0] != ?1 - raise FTPReplyError, resp - end - conn = sock.accept - sock.close - end - return conn - end - private :transfercmd - - def getaddress - thishost = Socket.gethostname - if not thishost.index(".") - thishost = Socket.gethostbyname(thishost)[0] - end - if ENV.has_key?("LOGNAME") - realuser = ENV["LOGNAME"] - elsif ENV.has_key?("USER") - realuser = ENV["USER"] + sock = makeport + begin + addr = sock.local_address + sendport(addr.ip_address, addr.ip_port) + if @resume and rest_offset + resp = sendcmd("REST " + rest_offset.to_s) + if !resp.start_with?("3") + raise FTPReplyError, resp + end + end + resp = sendcmd(cmd) + # skip 2XX for some ftp servers + resp = getresp if resp.start_with?("2") + if !resp.start_with?("1") + raise FTPReplyError, resp + end + conn, = sock.accept + sock.shutdown(Socket::SHUT_WR) rescue nil + sock.read rescue nil + ensure + sock.close + end + end + if @private_data_connection + return BufferedSSLSocket.new(start_tls_session(conn), + read_timeout: @read_timeout) else - realuser = "anonymous" + return BufferedSocket.new(conn, read_timeout: @read_timeout) end - return realuser + "@" + thishost end - private :getaddress - + private :transfercmd + # - # Logs in to the remote host. The session must have been previously - # connected. If +user+ is the string "anonymous" and the +password+ is - # +nil+, a password of <tt>user@host</tt> is synthesized. If the +acct+ - # parameter is not +nil+, an FTP ACCT command is sent following the - # successful login. Raises an exception on error (typically - # <tt>Net::FTPPermError</tt>). + # Logs in to the remote host. The session must have been + # previously connected. If +user+ is the string "anonymous" and + # the +password+ is +nil+, "anonymous@" is used as a password. If + # the +acct+ parameter is not +nil+, an FTP ACCT command is sent + # following the successful login. Raises an exception on error + # (typically <tt>Net::FTPPermError</tt>). # def login(user = "anonymous", passwd = nil, acct = nil) if user == "anonymous" and passwd == nil - passwd = getaddress + passwd = "anonymous@" end - + resp = "" synchronize do - resp = sendcmd('USER ' + user) - if resp[0] == ?3 - resp = sendcmd('PASS ' + passwd) - end - if resp[0] == ?3 - resp = sendcmd('ACCT ' + acct) - end + resp = sendcmd('USER ' + user) + if resp.start_with?("3") + raise FTPReplyError, resp if passwd.nil? + resp = sendcmd('PASS ' + passwd) + end + if resp.start_with?("3") + raise FTPReplyError, resp if acct.nil? + resp = sendcmd('ACCT ' + acct) + end end - if resp[0] != ?2 - raise FTPReplyError, resp + if !resp.start_with?("2") + raise FTPReplyError, resp end @welcome = resp + send_type_command + @logged_in = true end - + # # Puts the connection into binary (image) mode, issues the given command, # and fetches the data returned, passing it to the associated block in @@ -397,18 +628,25 @@ module Net # def retrbinary(cmd, blocksize, rest_offset = nil) # :yield: data synchronize do - voidcmd("TYPE I") - conn = transfercmd(cmd, rest_offset) - loop do - data = conn.read(blocksize) - break if data == nil - yield(data) - end - conn.close - voidresp + with_binary(true) do + begin + conn = transfercmd(cmd, rest_offset) + loop do + data = conn.read(blocksize) + break if data == nil + yield(data) + end + conn.shutdown(Socket::SHUT_WR) + conn.read_timeout = 1 + conn.read + ensure + conn.close if conn + end + voidresp + end end end - + # # Puts the connection into ASCII (text) mode, issues the given command, and # passes the resulting data, one line at a time, to the associated block. If @@ -417,110 +655,149 @@ module Net # def retrlines(cmd) # :yield: line synchronize do - voidcmd("TYPE A") - conn = transfercmd(cmd) - loop do - line = conn.gets - break if line == nil - if line[-2, 2] == CRLF - line = line[0 .. -3] - elsif line[-1] == ?\n - line = line[0 .. -2] - end - yield(line) - end - conn.close - voidresp - end - end - + with_binary(false) do + begin + conn = transfercmd(cmd) + loop do + line = conn.gets + break if line == nil + yield(line.sub(/\r?\n\z/, ""), !line.match(/\n\z/).nil?) + end + conn.shutdown(Socket::SHUT_WR) + conn.read_timeout = 1 + conn.read + ensure + conn.close if conn + end + voidresp + end + end + end + # # Puts the connection into binary (image) mode, issues the given server-side # command (such as "STOR myfile"), and sends the contents of the file named # +file+ to the server. If the optional block is given, it also passes it # the data, in chunks of +blocksize+ characters. # - def storbinary(cmd, file, blocksize, rest_offset = nil, &block) # :yield: data + def storbinary(cmd, file, blocksize, rest_offset = nil) # :yield: data if rest_offset file.seek(rest_offset, IO::SEEK_SET) end synchronize do - voidcmd("TYPE I") - conn = transfercmd(cmd, rest_offset) - loop do - buf = file.read(blocksize) - break if buf == nil - conn.write(buf) - yield(buf) if block - end - conn.close - voidresp + with_binary(true) do + conn = transfercmd(cmd) + loop do + buf = file.read(blocksize) + break if buf == nil + conn.write(buf) + yield(buf) if block_given? + end + conn.close + voidresp + end end + rescue Errno::EPIPE + # EPIPE, in this case, means that the data connection was unexpectedly + # terminated. Rather than just raising EPIPE to the caller, check the + # response on the control connection. If getresp doesn't raise a more + # appropriate exception, re-raise the original exception. + getresp + raise end - + # # Puts the connection into ASCII (text) mode, issues the given server-side # command (such as "STOR myfile"), and sends the contents of the file # named +file+ to the server, one line at a time. If the optional block is # given, it also passes it the lines. # - def storlines(cmd, file, &block) # :yield: line + def storlines(cmd, file) # :yield: line synchronize do - voidcmd("TYPE A") - conn = transfercmd(cmd) - loop do - buf = file.gets - break if buf == nil - if buf[-2, 2] != CRLF - buf = buf.chomp + CRLF - end - conn.write(buf) - yield(buf) if block - end - conn.close - voidresp + with_binary(false) do + conn = transfercmd(cmd) + loop do + buf = file.gets + break if buf == nil + if buf[-2, 2] != CRLF + buf = buf.chomp + CRLF + end + conn.write(buf) + yield(buf) if block_given? + end + conn.close + voidresp + end end + rescue Errno::EPIPE + # EPIPE, in this case, means that the data connection was unexpectedly + # terminated. Rather than just raising EPIPE to the caller, check the + # response on the control connection. If getresp doesn't raise a more + # appropriate exception, re-raise the original exception. + getresp + raise end # # Retrieves +remotefile+ in binary mode, storing the result in +localfile+. + # If +localfile+ is nil, returns retrieved data. # If a block is supplied, it is passed the retrieved data in +blocksize+ # chunks. # def getbinaryfile(remotefile, localfile = File.basename(remotefile), - blocksize = DEFAULT_BLOCKSIZE, &block) # :yield: data - if @resume - rest_offset = File.size?(localfile) - f = open(localfile, "a") - else - rest_offset = nil - f = open(localfile, "w") + blocksize = DEFAULT_BLOCKSIZE, &block) # :yield: data + f = nil + result = nil + if localfile + if @resume + rest_offset = File.size?(localfile) + f = File.open(localfile, "a") + else + rest_offset = nil + f = File.open(localfile, "w") + end + elsif !block_given? + result = String.new end begin - f.binmode - retrbinary("RETR " + remotefile, blocksize, rest_offset) do |data| - f.write(data) - yield(data) if block - end + f&.binmode + retrbinary("RETR #{remotefile}", blocksize, rest_offset) do |data| + f&.write(data) + block&.(data) + result&.concat(data) + end + return result ensure - f.close + f&.close end end - + # # Retrieves +remotefile+ in ASCII (text) mode, storing the result in - # +localfile+. If a block is supplied, it is passed the retrieved data one + # +localfile+. + # If +localfile+ is nil, returns retrieved data. + # If a block is supplied, it is passed the retrieved data one # line at a time. # - def gettextfile(remotefile, localfile = File.basename(remotefile), &block) # :yield: line - f = open(localfile, "w") + def gettextfile(remotefile, localfile = File.basename(remotefile), + &block) # :yield: line + f = nil + result = nil + if localfile + f = File.open(localfile, "w") + elsif !block_given? + result = String.new + end begin - retrlines("RETR " + remotefile) do |line| - f.puts(line) - yield(line) if block - end + retrlines("RETR #{remotefile}") do |line, newline| + l = newline ? line + "\n" : line + f&.print(l) + block&.(line, newline) + result&.concat(l) + end + return result ensure - f.close + f&.close end end @@ -529,21 +806,21 @@ module Net # binary). See #gettextfile and #getbinaryfile. # def get(remotefile, localfile = File.basename(remotefile), - blocksize = DEFAULT_BLOCKSIZE, &block) # :yield: data - unless @binary - gettextfile(remotefile, localfile, &block) + blocksize = DEFAULT_BLOCKSIZE, &block) # :yield: data + if @binary + getbinaryfile(remotefile, localfile, blocksize, &block) else - getbinaryfile(remotefile, localfile, blocksize, &block) + gettextfile(remotefile, localfile, &block) end end - + # # Transfers +localfile+ to the server in binary mode, storing the result in # +remotefile+. If a block is supplied, calls it, passing in the transmitted # data in +blocksize+ chunks. # def putbinaryfile(localfile, remotefile = File.basename(localfile), - blocksize = DEFAULT_BLOCKSIZE, &block) # :yield: data + blocksize = DEFAULT_BLOCKSIZE, &block) # :yield: data if @resume begin rest_offset = size(remotefile) @@ -551,28 +828,32 @@ module Net rest_offset = nil end else - rest_offset = nil + rest_offset = nil end - f = open(localfile) + f = File.open(localfile) begin - f.binmode - storbinary("STOR " + remotefile, f, blocksize, rest_offset, &block) + f.binmode + if rest_offset + storbinary("APPE #{remotefile}", f, blocksize, rest_offset, &block) + else + storbinary("STOR #{remotefile}", f, blocksize, rest_offset, &block) + end ensure - f.close + f.close end end - + # # Transfers +localfile+ to the server in ASCII (text) mode, storing the result # in +remotefile+. If callback or an associated block is supplied, calls it, # passing in the transmitted data one line at a time. # def puttextfile(localfile, remotefile = File.basename(localfile), &block) # :yield: line - f = open(localfile) + f = File.open(localfile) begin - storlines("STOR " + remotefile, f, &block) + storlines("STOR #{remotefile}", f, &block) ensure - f.close + f.close end end @@ -581,37 +862,40 @@ module Net # (text or binary). See #puttextfile and #putbinaryfile. # def put(localfile, remotefile = File.basename(localfile), - blocksize = DEFAULT_BLOCKSIZE, &block) - unless @binary - puttextfile(localfile, remotefile, &block) + blocksize = DEFAULT_BLOCKSIZE, &block) + if @binary + putbinaryfile(localfile, remotefile, blocksize, &block) else - putbinaryfile(localfile, remotefile, blocksize, &block) + puttextfile(localfile, remotefile, &block) end end # - # Sends the ACCT command. TODO: more info. + # Sends the ACCT command. + # + # This is a less common FTP command, to send account + # information if the destination host requires it. # def acct(account) cmd = "ACCT " + account voidcmd(cmd) end - + # # Returns an array of filenames in the remote directory. # def nlst(dir = nil) cmd = "NLST" if dir - cmd = cmd + " " + dir + cmd = "#{cmd} #{dir}" end files = [] retrlines(cmd) do |line| - files.push(line) + files.push(line) end return files end - + # # Returns an array of file information in the directory (the output is like # `ls -l`). If a block is given, it iterates through the listing. @@ -619,103 +903,303 @@ module Net def list(*args, &block) # :yield: line cmd = "LIST" args.each do |arg| - cmd = cmd + " " + arg + cmd = "#{cmd} #{arg}" + end + lines = [] + retrlines(cmd) do |line| + lines << line end if block - retrlines(cmd, &block) - else - lines = [] - retrlines(cmd) do |line| - lines << line - end - return lines + lines.each(&block) end + return lines end alias ls list alias dir list - + + # + # MLSxEntry represents an entry in responses of MLST/MLSD. + # Each entry has the facts (e.g., size, last modification time, etc.) + # and the pathname. + # + class MLSxEntry + attr_reader :facts, :pathname + + def initialize(facts, pathname) + @facts = facts + @pathname = pathname + end + + standard_facts = %w(size modify create type unique perm + lang media-type charset) + standard_facts.each do |factname| + define_method factname.gsub(/-/, "_") do + facts[factname] + end + end + + # + # Returns +true+ if the entry is a file (i.e., the value of the type + # fact is file). + # + def file? + return facts["type"] == "file" + end + + # + # Returns +true+ if the entry is a directory (i.e., the value of the + # type fact is dir, cdir, or pdir). + # + def directory? + if /\A[cp]?dir\z/.match(facts["type"]) + return true + else + return false + end + end + + # + # Returns +true+ if the APPE command may be applied to the file. + # + def appendable? + return facts["perm"].include?(?a) + end + + # + # Returns +true+ if files may be created in the directory by STOU, + # STOR, APPE, and RNTO. + # + def creatable? + return facts["perm"].include?(?c) + end + + # + # Returns +true+ if the file or directory may be deleted by DELE/RMD. + # + def deletable? + return facts["perm"].include?(?d) + end + + # + # Returns +true+ if the directory may be entered by CWD/CDUP. + # + def enterable? + return facts["perm"].include?(?e) + end + + # + # Returns +true+ if the file or directory may be renamed by RNFR. + # + def renamable? + return facts["perm"].include?(?f) + end + + # + # Returns +true+ if the listing commands, LIST, NLST, and MLSD are + # applied to the directory. + # + def listable? + return facts["perm"].include?(?l) + end + + # + # Returns +true+ if the MKD command may be used to create a new + # directory within the directory. + # + def directory_makable? + return facts["perm"].include?(?m) + end + + # + # Returns +true+ if the objects in the directory may be deleted, or + # the directory may be purged. + # + def purgeable? + return facts["perm"].include?(?p) + end + + # + # Returns +true+ if the RETR command may be applied to the file. + # + def readable? + return facts["perm"].include?(?r) + end + + # + # Returns +true+ if the STOR command may be applied to the file. + # + def writable? + return facts["perm"].include?(?w) + end + end + + CASE_DEPENDENT_PARSER = ->(value) { value } + CASE_INDEPENDENT_PARSER = ->(value) { value.downcase } + DECIMAL_PARSER = ->(value) { value.to_i } + OCTAL_PARSER = ->(value) { value.to_i(8) } + TIME_PARSER = ->(value, local = false) { + unless /\A(?<year>\d{4})(?<month>\d{2})(?<day>\d{2}) + (?<hour>\d{2})(?<min>\d{2})(?<sec>\d{2}) + (\.(?<fractions>\d+))?/x =~ value + raise FTPProtoError, "invalid time-val: #{value}" + end + usec = fractions.to_i * 10 ** (6 - fractions.to_s.size) + Time.send(local ? :local : :utc, year, month, day, hour, min, sec, usec) + } + FACT_PARSERS = Hash.new(CASE_DEPENDENT_PARSER) + FACT_PARSERS["size"] = DECIMAL_PARSER + FACT_PARSERS["modify"] = TIME_PARSER + FACT_PARSERS["create"] = TIME_PARSER + FACT_PARSERS["type"] = CASE_INDEPENDENT_PARSER + FACT_PARSERS["unique"] = CASE_DEPENDENT_PARSER + FACT_PARSERS["perm"] = CASE_INDEPENDENT_PARSER + FACT_PARSERS["lang"] = CASE_INDEPENDENT_PARSER + FACT_PARSERS["media-type"] = CASE_INDEPENDENT_PARSER + FACT_PARSERS["charset"] = CASE_INDEPENDENT_PARSER + FACT_PARSERS["unix.mode"] = OCTAL_PARSER + FACT_PARSERS["unix.owner"] = DECIMAL_PARSER + FACT_PARSERS["unix.group"] = DECIMAL_PARSER + FACT_PARSERS["unix.ctime"] = TIME_PARSER + FACT_PARSERS["unix.atime"] = TIME_PARSER + + def parse_mlsx_entry(entry) + facts, pathname = entry.chomp.split(/ /, 2) + unless pathname + raise FTPProtoError, entry + end + return MLSxEntry.new( + facts.scan(/(.*?)=(.*?);/).each_with_object({}) { + |(factname, value), h| + name = factname.downcase + h[name] = FACT_PARSERS[name].(value) + }, + pathname) + end + private :parse_mlsx_entry + + # + # Returns data (e.g., size, last modification time, entry type, etc.) + # about the file or directory specified by +pathname+. + # If +pathname+ is omitted, the current directory is assumed. + # + def mlst(pathname = nil) + cmd = pathname ? "MLST #{pathname}" : "MLST" + resp = sendcmd(cmd) + if !resp.start_with?("250") + raise FTPReplyError, resp + end + line = resp.lines[1] + unless line + raise FTPProtoError, resp + end + entry = line.sub(/\A(250-| *)/, "") + return parse_mlsx_entry(entry) + end + + # + # Returns an array of the entries of the directory specified by + # +pathname+. + # Each entry has the facts (e.g., size, last modification time, etc.) + # and the pathname. + # If a block is given, it iterates through the listing. + # If +pathname+ is omitted, the current directory is assumed. + # + def mlsd(pathname = nil, &block) # :yield: entry + cmd = pathname ? "MLSD #{pathname}" : "MLSD" + entries = [] + retrlines(cmd) do |line| + entries << parse_mlsx_entry(line) + end + if block + entries.each(&block) + end + return entries + end + # # Renames a file on the server. # def rename(fromname, toname) - resp = sendcmd("RNFR " + fromname) - if resp[0] != ?3 - raise FTPReplyError, resp + resp = sendcmd("RNFR #{fromname}") + if !resp.start_with?("3") + raise FTPReplyError, resp end - voidcmd("RNTO " + toname) + voidcmd("RNTO #{toname}") end - + # # Deletes a file on the server. # def delete(filename) - resp = sendcmd("DELE " + filename) - if resp[0, 3] == "250" - return - elsif resp[0] == ?5 - raise FTPPermError, resp + resp = sendcmd("DELE #{filename}") + if resp.start_with?("250") + return + elsif resp.start_with?("5") + raise FTPPermError, resp else - raise FTPReplyError, resp + raise FTPReplyError, resp end end - + # # Changes the (remote) directory. # def chdir(dirname) if dirname == ".." - begin - voidcmd("CDUP") - return - rescue FTPPermError - if $![0, 3] != "500" - raise FTPPermError, $! - end - end - end - cmd = "CWD " + dirname + begin + voidcmd("CDUP") + return + rescue FTPPermError => e + if e.message[0, 3] != "500" + raise e + end + end + end + cmd = "CWD #{dirname}" voidcmd(cmd) end - + + def get_body(resp) # :nodoc: + resp.slice(/\A[0-9a-zA-Z]{3} (.*)$/, 1) + end + private :get_body + # # Returns the size of the given (remote) filename. # def size(filename) - voidcmd("TYPE I") - resp = sendcmd("SIZE " + filename) - if resp[0, 3] != "213" - raise FTPReplyError, resp + with_binary(true) do + resp = sendcmd("SIZE #{filename}") + if !resp.start_with?("213") + raise FTPReplyError, resp + end + return get_body(resp).to_i end - return resp[3..-1].strip.to_i end - - MDTM_REGEXP = /^(\d\d\d\d)(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)/ # :nodoc: - + # # Returns the last modification time of the (remote) file. If +local+ is # +true+, it is returned as a local time, otherwise it's a UTC time. # def mtime(filename, local = false) - str = mdtm(filename) - ary = str.scan(MDTM_REGEXP)[0].collect {|i| i.to_i} - return local ? Time.local(*ary) : Time.gm(*ary) + return TIME_PARSER.(mdtm(filename), local) end - + # # Creates a remote directory. # def mkdir(dirname) - resp = sendcmd("MKD " + dirname) + resp = sendcmd("MKD #{dirname}") return parse257(resp) end - + # # Removes a remote directory. # def rmdir(dirname) - voidcmd("RMD " + dirname) + voidcmd("RMD #{dirname}") end - + # # Returns the current remote directory. # @@ -724,18 +1208,18 @@ module Net return parse257(resp) end alias getdir pwd - + # # Returns system information. # def system resp = sendcmd("SYST") - if resp[0, 3] != "215" - raise FTPReplyError, resp + if !resp.start_with?("215") + raise FTPReplyError, resp end - return resp[4 .. -1] + return get_body(resp) end - + # # Aborts the previous command (ABOR command). # @@ -745,42 +1229,50 @@ module Net @sock.send(line, Socket::MSG_OOB) resp = getmultiline unless ["426", "226", "225"].include?(resp[0, 3]) - raise FTPProtoError, resp + raise FTPProtoError, resp end return resp end - + # # Returns the status (STAT command). + # pathname - when stat is invoked with pathname as a parameter it acts like + # list but alot faster and over the same tcp session. # - def status - line = "STAT" + CRLF - print "put: STAT\n" if @debug_mode - @sock.send(line, Socket::MSG_OOB) + def status(pathname = nil) + line = pathname ? "STAT #{pathname}" : "STAT" + if /[\r\n]/ =~ line + raise ArgumentError, "A line must not contain CR or LF" + end + print "put: #{line}\n" if @debug_mode + @sock.send(line + CRLF, Socket::MSG_OOB) return getresp end - + # - # Issues the MDTM command. TODO: more info. + # Returns the raw last modification time of the (remote) file in the format + # "YYYYMMDDhhmmss" (MDTM command). + # + # Use +mtime+ if you want a parsed Time instance. # def mdtm(filename) - resp = sendcmd("MDTM " + filename) - if resp[0, 3] == "213" - return resp[3 .. -1].strip + resp = sendcmd("MDTM #{filename}") + if resp.start_with?("213") + return get_body(resp) end end - + # # Issues the HELP command. # def help(arg = nil) cmd = "HELP" if arg - cmd = cmd + " " + arg + cmd = cmd + " " + arg end sendcmd(cmd) end - + # # Exits the FTP session. # @@ -791,6 +1283,8 @@ module Net # # Issues a NOOP command. # + # Does nothing except return a response. + # def noop voidcmd("NOOP") end @@ -802,122 +1296,198 @@ module Net cmd = "SITE " + arg voidcmd(cmd) end - + # # Closes the connection. Further operations are impossible until you open # a new connection with #connect. # def close - @sock.close if @sock and not @sock.closed? + if @sock and not @sock.closed? + begin + @sock.shutdown(Socket::SHUT_WR) rescue nil + orig, self.read_timeout = self.read_timeout, 3 + @sock.read rescue nil + ensure + @sock.close + self.read_timeout = orig + end + end end - + # # Returns +true+ iff the connection is closed. # def closed? @sock == nil or @sock.closed? end - - def parse227(resp) - if resp[0, 3] != "227" - raise FTPReplyError, resp - end - left = resp.index("(") - right = resp.index(")") - if left == nil or right == nil - raise FTPProtoError, resp + + # handler for response code 227 + # (Entering Passive Mode (h1,h2,h3,h4,p1,p2)) + # + # Returns host and port. + def parse227(resp) # :nodoc: + if !resp.start_with?("227") + raise FTPReplyError, resp end - numbers = resp[left + 1 .. right - 1].split(",") - if numbers.length != 6 - raise FTPProtoError, resp + if m = /\((?<host>\d+(,\d+){3}),(?<port>\d+,\d+)\)/.match(resp) + return parse_pasv_ipv4_host(m["host"]), parse_pasv_port(m["port"]) + else + raise FTPProtoError, resp end - host = numbers[0, 4].join(".") - port = (numbers[4].to_i << 8) + numbers[5].to_i - return host, port end private :parse227 - - def parse228(resp) - if resp[0, 3] != "228" - raise FTPReplyError, resp - end - left = resp.index("(") - right = resp.index(")") - if left == nil or right == nil - raise FTPProtoError, resp - end - numbers = resp[left + 1 .. right - 1].split(",") - if numbers[0] == "4" - if numbers.length != 9 || numbers[1] != "4" || numbers[2 + 4] != "2" - raise FTPProtoError, resp - end - host = numbers[2, 4].join(".") - port = (numbers[7].to_i << 8) + numbers[8].to_i - elsif numbers[0] == "6" - if numbers.length != 21 || numbers[1] != "16" || numbers[2 + 16] != "2" - raise FTPProtoError, resp - end - v6 = ["", "", "", "", "", "", "", ""] - for i in 0 .. 7 - v6[i] = sprintf("%02x%02x", numbers[(i * 2) + 2].to_i, - numbers[(i * 2) + 3].to_i) - end - host = v6[0, 8].join(":") - port = (numbers[19].to_i << 8) + numbers[20].to_i - end - return host, port + + # handler for response code 228 + # (Entering Long Passive Mode) + # + # Returns host and port. + def parse228(resp) # :nodoc: + if !resp.start_with?("228") + raise FTPReplyError, resp + end + if m = /\(4,4,(?<host>\d+(,\d+){3}),2,(?<port>\d+,\d+)\)/.match(resp) + return parse_pasv_ipv4_host(m["host"]), parse_pasv_port(m["port"]) + elsif m = /\(6,16,(?<host>\d+(,(\d+)){15}),2,(?<port>\d+,\d+)\)/.match(resp) + return parse_pasv_ipv6_host(m["host"]), parse_pasv_port(m["port"]) + else + raise FTPProtoError, resp + end end private :parse228 - - def parse229(resp) - if resp[0, 3] != "229" - raise FTPReplyError, resp - end - left = resp.index("(") - right = resp.index(")") - if left == nil or right == nil - raise FTPProtoError, resp - end - numbers = resp[left + 1 .. right - 1].split(resp[left + 1, 1]) - if numbers.length != 4 - raise FTPProtoError, resp - end - port = numbers[3].to_i - host = (@sock.peeraddr())[3] - return host, port + + def parse_pasv_ipv4_host(s) + return s.tr(",", ".") + end + private :parse_pasv_ipv4_host + + def parse_pasv_ipv6_host(s) + return s.split(/,/).map { |i| + "%02x" % i.to_i + }.each_slice(2).map(&:join).join(":") + end + private :parse_pasv_ipv6_host + + def parse_pasv_port(s) + return s.split(/,/).map(&:to_i).inject { |x, y| + (x << 8) + y + } + end + private :parse_pasv_port + + # handler for response code 229 + # (Extended Passive Mode Entered) + # + # Returns host and port. + def parse229(resp) # :nodoc: + if !resp.start_with?("229") + raise FTPReplyError, resp + end + if m = /\((?<d>[!-~])\k<d>\k<d>(?<port>\d+)\k<d>\)/.match(resp) + return @bare_sock.remote_address.ip_address, m["port"].to_i + else + raise FTPProtoError, resp + end end private :parse229 - - def parse257(resp) - if resp[0, 3] != "257" - raise FTPReplyError, resp - end - if resp[3, 2] != ' "' - return "" - end - dirname = "" - i = 5 - n = resp.length - while i < n - c = resp[i, 1] - i = i + 1 - if c == '"' - if i > n or resp[i, 1] != '"' - break - end - i = i + 1 - end - dirname = dirname + c - end - return dirname + + # handler for response code 257 + # ("PATHNAME" created) + # + # Returns host and port. + def parse257(resp) # :nodoc: + if !resp.start_with?("257") + raise FTPReplyError, resp + end + return resp.slice(/"(([^"]|"")*)"/, 1).to_s.gsub(/""/, '"') end private :parse257 - end + # :stopdoc: + class NullSocket + def read_timeout=(sec) + end + + def closed? + true + end + + def close + end + + def method_missing(mid, *args) + raise FTPConnectionError, "not connected" + end + end + + class BufferedSocket < BufferedIO + [:local_address, :remote_address, :addr, :peeraddr, :send, :shutdown].each do |method| + define_method(method) { |*args| + @io.__send__(method, *args) + } + end + + def read(len = nil) + if len + s = super(len, String.new, true) + return s.empty? ? nil : s + else + result = String.new + while s = super(DEFAULT_BLOCKSIZE, String.new, true) + break if s.empty? + result << s + end + return result + end + end + + def gets + line = readuntil("\n", true) + return line.empty? ? nil : line + end + + def readline + line = gets + if line.nil? + raise EOFError, "end of file reached" + end + return line + end + end + + if defined?(OpenSSL::SSL::SSLSocket) + class BufferedSSLSocket < BufferedSocket + def initialize(*args) + super + @is_shutdown = false + end + + def shutdown(*args) + # SSL_shutdown() will be called from SSLSocket#close, and + # SSL_shutdown() will send the "close notify" alert to the peer, + # so shutdown(2) should not be called. + @is_shutdown = true + end + + def send(mesg, flags, dest = nil) + # Ignore flags and dest. + @io.write(mesg) + end + + private + + def rbuf_fill + if @is_shutdown + raise EOFError, "shutdown has been called" + else + super + end + end + end + end + # :startdoc: + end end # Documentation comments: # - sourced from pickaxe and nutshell, with improvements (hopefully) -# - three methods should be private (search WRITEME) -# - two methods need more information (search TODO) diff --git a/lib/net/http.rb b/lib/net/http.rb index d518f32cbb..961ef398c3 100644 --- a/lib/net/http.rb +++ b/lib/net/http.rb @@ -1,32 +1,28 @@ +# frozen_string_literal: false # # = net/http.rb # -# Copyright (c) 1999-2006 Yukihiro Matsumoto -# Copyright (c) 1999-2006 Minero Aoki +# Copyright (c) 1999-2007 Yukihiro Matsumoto +# Copyright (c) 1999-2007 Minero Aoki # Copyright (c) 2001 GOTOU Yuuzou -# +# # Written and maintained by Minero Aoki <aamine@loveruby.net>. # HTTPS support added by GOTOU Yuuzou <gotoyuzo@notwork.org>. # # This file is derived from "http-access.rb". # # Documented by Minero Aoki; converted to RDoc by William Webber. -# +# # This program is free software. You can re-distribute and/or # modify this program under the same terms of ruby itself --- # Ruby Distribution License or GNU General Public License. # -# See Net::HTTP for an overview and examples. -# -# NOTE: You can find Japanese version of this document here: -# http://www.ruby-lang.org/ja/man/?cmd=view;name=net%2Fhttp.rb -# -#-- -# $Id: http.rb,v 1.100.2.14 2006/07/26 13:27:18 aamine Exp $ -#++ - -require 'net/protocol' +# See Net::HTTP for an overview and examples. +# + +require_relative 'protocol' require 'uri' +autoload :OpenSSL, 'openssl' module Net #:nodoc: @@ -35,282 +31,392 @@ module Net #:nodoc: class HTTPHeaderSyntaxError < StandardError; end # :startdoc: - # == What Is This Library? - # - # This library provides your program functions to access WWW - # documents via HTTP, Hyper Text Transfer Protocol version 1.1. - # For details of HTTP, refer [RFC2616] - # (http://www.ietf.org/rfc/rfc2616.txt). - # - # == Examples - # - # === Getting Document From WWW Server - # - # Example #1: Simple GET+print - # - # require 'net/http' - # Net::HTTP.get_print 'www.example.com', '/index.html' - # - # Example #2: Simple GET+print by URL - # - # require 'net/http' - # require 'uri' - # Net::HTTP.get_print URI.parse('http://www.example.com/index.html') - # - # Example #3: More generic GET+print - # - # require 'net/http' - # require 'uri' - # - # url = URI.parse('http://www.example.com/index.html') - # res = Net::HTTP.start(url.host, url.port) {|http| - # http.get('/index.html') - # } - # puts res.body - # - # Example #4: More generic GET+print - # - # require 'net/http' - # - # url = URI.parse('http://www.example.com/index.html') - # req = Net::HTTP::Get.new(url.path) - # res = Net::HTTP.start(url.host, url.port) {|http| - # http.request(req) - # } - # puts res.body - # - # === Posting Form Data - # - # require 'net/http' - # require 'uri' - # - # #1: Simple POST - # res = Net::HTTP.post_form(URI.parse('http://www.example.com/search.cgi'), - # {'q'=>'ruby', 'max'=>'50'}) - # puts res.body - # - # #2: POST with basic authentication - # res = Net::HTTP.post_form(URI.parse('http://jack:pass@www.example.com/todo.cgi'), - # {'from'=>'2005-01-01', 'to'=>'2005-03-31'}) - # puts res.body - # - # #3: Detailed control - # url = URI.parse('http://www.example.com/todo.cgi') - # req = Net::HTTP::Post.new(url.path) - # req.basic_auth 'jack', 'pass' - # req.set_form_data({'from'=>'2005-01-01', 'to'=>'2005-03-31'}, ';') - # res = Net::HTTP.new(url.host, url.port).start {|http| http.request(req) } - # case res - # when Net::HTTPSuccess, Net::HTTPRedirection - # # OK + # == An HTTP client API for Ruby. + # + # Net::HTTP provides a rich library which can be used to build HTTP + # user-agents. For more details about HTTP see + # [RFC2616](http://www.ietf.org/rfc/rfc2616.txt) + # + # Net::HTTP is designed to work closely with URI. URI::HTTP#host, + # URI::HTTP#port and URI::HTTP#request_uri are designed to work with + # Net::HTTP. + # + # If you are only performing a few GET requests you should try OpenURI. + # + # == Simple Examples + # + # All examples assume you have loaded Net::HTTP with: + # + # require 'net/http' + # + # This will also require 'uri' so you don't need to require it separately. + # + # The Net::HTTP methods in the following section do not persist + # connections. They are not recommended if you are performing many HTTP + # requests. + # + # === GET + # + # Net::HTTP.get('example.com', '/index.html') # => String + # + # === GET by URI + # + # uri = URI('http://example.com/index.html?count=10') + # Net::HTTP.get(uri) # => String + # + # === GET with Dynamic Parameters + # + # uri = URI('http://example.com/index.html') + # params = { :limit => 10, :page => 3 } + # uri.query = URI.encode_www_form(params) + # + # res = Net::HTTP.get_response(uri) + # puts res.body if res.is_a?(Net::HTTPSuccess) + # + # === POST + # + # uri = URI('http://www.example.com/search.cgi') + # res = Net::HTTP.post_form(uri, 'q' => 'ruby', 'max' => '50') + # puts res.body + # + # === POST with Multiple Values + # + # uri = URI('http://www.example.com/search.cgi') + # res = Net::HTTP.post_form(uri, 'q' => ['ruby', 'perl'], 'max' => '50') + # puts res.body + # + # == How to use Net::HTTP + # + # The following example code can be used as the basis of a HTTP user-agent + # which can perform a variety of request types using persistent + # connections. + # + # uri = URI('http://example.com/some_path?query=string') + # + # Net::HTTP.start(uri.host, uri.port) do |http| + # request = Net::HTTP::Get.new uri + # + # response = http.request request # Net::HTTPResponse object + # end + # + # Net::HTTP::start immediately creates a connection to an HTTP server which + # is kept open for the duration of the block. The connection will remain + # open for multiple requests in the block if the server indicates it + # supports persistent connections. + # + # The request types Net::HTTP supports are listed below in the section "HTTP + # Request Classes". + # + # If you wish to re-use a connection across multiple HTTP requests without + # automatically closing it you can use ::new instead of ::start. #request + # will automatically open a connection to the server if one is not currently + # open. You can manually close the connection with #finish. + # + # For all the Net::HTTP request objects and shortcut request methods you may + # supply either a String for the request path or a URI from which Net::HTTP + # will extract the request path. + # + # === Response Data + # + # uri = URI('http://example.com/index.html') + # res = Net::HTTP.get_response(uri) + # + # # Headers + # res['Set-Cookie'] # => String + # res.get_fields('set-cookie') # => Array + # res.to_hash['set-cookie'] # => Array + # puts "Headers: #{res.to_hash.inspect}" + # + # # Status + # puts res.code # => '200' + # puts res.message # => 'OK' + # puts res.class.name # => 'HTTPOK' + # + # # Body + # puts res.body if res.response_body_permitted? + # + # === Following Redirection + # + # Each Net::HTTPResponse object belongs to a class for its response code. + # + # For example, all 2XX responses are instances of a Net::HTTPSuccess + # subclass, a 3XX response is an instance of a Net::HTTPRedirection + # subclass and a 200 response is an instance of the Net::HTTPOK class. For + # details of response classes, see the section "HTTP Response Classes" + # below. + # + # Using a case statement you can handle various types of responses properly: + # + # def fetch(uri_str, limit = 10) + # # You should choose a better exception. + # raise ArgumentError, 'too many HTTP redirects' if limit == 0 + # + # response = Net::HTTP.get_response(URI(uri_str)) + # + # case response + # when Net::HTTPSuccess then + # response + # when Net::HTTPRedirection then + # location = response['location'] + # warn "redirected to #{location}" + # fetch(location, limit - 1) # else - # res.error! + # response.value # end - # - # === Accessing via Proxy - # - # Net::HTTP.Proxy creates http proxy class. It has same - # methods of Net::HTTP but its instances always connect to - # proxy, instead of given host. - # - # require 'net/http' - # - # proxy_addr = 'your.proxy.host' - # proxy_port = 8080 - # : - # Net::HTTP::Proxy(proxy_addr, proxy_port).start('www.example.com') {|http| - # # always connect to your.proxy.addr:8080 - # : - # } - # - # Since Net::HTTP.Proxy returns Net::HTTP itself when proxy_addr is nil, - # there's no need to change code if there's proxy or not. - # - # There are two additional parameters in Net::HTTP.Proxy which allow to - # specify proxy user name and password: - # - # Net::HTTP::Proxy(proxy_addr, proxy_port, proxy_user = nil, proxy_pass = nil) - # - # You may use them to work with authorization-enabled proxies: - # - # require 'net/http' - # require 'uri' - # - # proxy_host = 'your.proxy.host' - # proxy_port = 8080 - # uri = URI.parse(ENV['http_proxy']) - # proxy_user, proxy_pass = uri.userinfo.split(/:/) if uri.userinfo - # Net::HTTP::Proxy(proxy_host, proxy_port, - # proxy_user, proxy_pass).start('www.example.com') {|http| - # # always connect to your.proxy.addr:8080 using specified username and password - # : - # } - # - # Note that net/http never rely on HTTP_PROXY environment variable. - # If you want to use proxy, set it explicitly. - # - # === Following Redirection - # - # require 'net/http' - # require 'uri' - # - # def fetch(uri_str, limit = 10) - # # You should choose better exception. - # raise ArgumentError, 'HTTP redirect too deep' if limit == 0 - # - # response = Net::HTTP.get_response(URI.parse(uri_str)) - # case response - # when Net::HTTPSuccess then response - # when Net::HTTPRedirection then fetch(response['location'], limit - 1) - # else - # response.error! + # end + # + # print fetch('http://www.ruby-lang.org') + # + # === POST + # + # A POST can be made using the Net::HTTP::Post request class. This example + # creates a urlencoded POST body: + # + # uri = URI('http://www.example.com/todo.cgi') + # req = Net::HTTP::Post.new(uri) + # req.set_form_data('from' => '2005-01-01', 'to' => '2005-03-31') + # + # res = Net::HTTP.start(uri.hostname, uri.port) do |http| + # http.request(req) + # end + # + # case res + # when Net::HTTPSuccess, Net::HTTPRedirection + # # OK + # else + # res.value + # end + # + # At this time Net::HTTP does not support multipart/form-data. To send + # multipart/form-data use Net::HTTPRequest#body= and + # Net::HTTPRequest#content_type=: + # + # req = Net::HTTP::Post.new(uri) + # req.body = multipart_data + # req.content_type = 'multipart/form-data' + # + # Other requests that can contain a body such as PUT can be created in the + # same way using the corresponding request class (Net::HTTP::Put). + # + # === Setting Headers + # + # The following example performs a conditional GET using the + # If-Modified-Since header. If the files has not been modified since the + # time in the header a Not Modified response will be returned. See RFC 2616 + # section 9.3 for further details. + # + # uri = URI('http://example.com/cached_response') + # file = File.stat 'cached_response' + # + # req = Net::HTTP::Get.new(uri) + # req['If-Modified-Since'] = file.mtime.rfc2822 + # + # res = Net::HTTP.start(uri.hostname, uri.port) {|http| + # http.request(req) + # } + # + # open 'cached_response', 'w' do |io| + # io.write res.body + # end if res.is_a?(Net::HTTPSuccess) + # + # === Basic Authentication + # + # Basic authentication is performed according to + # [RFC2617](http://www.ietf.org/rfc/rfc2617.txt) + # + # uri = URI('http://example.com/index.html?key=value') + # + # req = Net::HTTP::Get.new(uri) + # req.basic_auth 'user', 'pass' + # + # res = Net::HTTP.start(uri.hostname, uri.port) {|http| + # http.request(req) + # } + # puts res.body + # + # === Streaming Response Bodies + # + # By default Net::HTTP reads an entire response into memory. If you are + # handling large files or wish to implement a progress bar you can instead + # stream the body directly to an IO. + # + # uri = URI('http://example.com/large_file') + # + # Net::HTTP.start(uri.host, uri.port) do |http| + # request = Net::HTTP::Get.new uri + # + # http.request request do |response| + # open 'large_file', 'w' do |io| + # response.read_body do |chunk| + # io.write chunk + # end # end # end - # - # print fetch('http://www.ruby-lang.org') - # - # Net::HTTPSuccess and Net::HTTPRedirection is a HTTPResponse class. - # All HTTPResponse objects belong to its own response class which - # indicate HTTP result status. For details of response classes, - # see section "HTTP Response Classes". - # - # === Basic Authentication - # - # require 'net/http' - # - # Net::HTTP.start('www.example.com') {|http| - # req = Net::HTTP::Get.new('/secret-page.html') - # req.basic_auth 'account', 'password' - # response = http.request(req) - # print response.body - # } - # - # === HTTP Request Classes - # - # Here is HTTP request class hierarchy. - # - # Net::HTTPRequest - # Net::HTTP::Get - # Net::HTTP::Head - # Net::HTTP::Post - # Net::HTTP::Put - # Net::HTTP::Proppatch - # Net::HTTP::Lock - # Net::HTTP::Unlock - # Net::HTTP::Options - # Net::HTTP::Propfind - # Net::HTTP::Delete - # Net::HTTP::Move - # Net::HTTP::Copy - # Net::HTTP::Mkcol - # Net::HTTP::Trace - # - # === HTTP Response Classes - # - # Here is HTTP response class hierarchy. - # All classes are defined in Net module. - # - # HTTPResponse - # HTTPUnknownResponse - # HTTPInformation # 1xx - # HTTPContinue # 100 - # HTTPSwitchProtocl # 101 - # HTTPSuccess # 2xx - # HTTPOK # 200 - # HTTPCreated # 201 - # HTTPAccepted # 202 - # HTTPNonAuthoritativeInformation # 203 - # HTTPNoContent # 204 - # HTTPResetContent # 205 - # HTTPPartialContent # 206 - # HTTPRedirection # 3xx - # HTTPMultipleChoice # 300 - # HTTPMovedPermanently # 301 - # HTTPFound # 302 - # HTTPSeeOther # 303 - # HTTPNotModified # 304 - # HTTPUseProxy # 305 - # HTTPTemporaryRedirect # 307 - # HTTPClientError # 4xx - # HTTPBadRequest # 400 - # HTTPUnauthorized # 401 - # HTTPPaymentRequired # 402 - # HTTPForbidden # 403 - # HTTPNotFound # 404 - # HTTPMethodNotAllowed # 405 - # HTTPNotAcceptable # 406 - # HTTPProxyAuthenticationRequired # 407 - # HTTPRequestTimeOut # 408 - # HTTPConflict # 409 - # HTTPGone # 410 - # HTTPLengthRequired # 411 - # HTTPPreconditionFailed # 412 - # HTTPRequestEntityTooLarge # 413 - # HTTPRequestURITooLong # 414 - # HTTPUnsupportedMediaType # 415 - # HTTPRequestedRangeNotSatisfiable # 416 - # HTTPExpectationFailed # 417 - # HTTPServerError # 5xx - # HTTPInternalServerError # 500 - # HTTPNotImplemented # 501 - # HTTPBadGateway # 502 - # HTTPServiceUnavailable # 503 - # HTTPGatewayTimeOut # 504 - # HTTPVersionNotSupported # 505 - # - # == Switching Net::HTTP versions - # - # You can use net/http.rb 1.1 features (bundled with Ruby 1.6) - # by calling HTTP.version_1_1. Calling Net::HTTP.version_1_2 - # allows you to use 1.2 features again. - # - # # example - # Net::HTTP.start {|http1| ...(http1 has 1.2 features)... } - # - # Net::HTTP.version_1_1 - # Net::HTTP.start {|http2| ...(http2 has 1.1 features)... } - # - # Net::HTTP.version_1_2 - # Net::HTTP.start {|http3| ...(http3 has 1.2 features)... } - # - # This function is NOT thread-safe. + # end + # + # === HTTPS + # + # HTTPS is enabled for an HTTP connection by Net::HTTP#use_ssl=. + # + # uri = URI('https://secure.example.com/some_path?query=string') + # + # Net::HTTP.start(uri.host, uri.port, :use_ssl => true) do |http| + # request = Net::HTTP::Get.new uri + # response = http.request request # Net::HTTPResponse object + # end + # + # Or if you simply want to make a GET request, you may pass in an URI + # object that has a HTTPS URL. Net::HTTP automatically turn on TLS + # verification if the URI object has a 'https' URI scheme. + # + # uri = URI('https://example.com/') + # Net::HTTP.get(uri) # => String + # + # In previous versions of Ruby you would need to require 'net/https' to use + # HTTPS. This is no longer true. + # + # === Proxies + # + # Net::HTTP will automatically create a proxy from the +http_proxy+ + # environment variable if it is present. To disable use of +http_proxy+, + # pass +nil+ for the proxy address. + # + # You may also create a custom proxy: + # + # proxy_addr = 'your.proxy.host' + # proxy_port = 8080 + # + # Net::HTTP.new('example.com', nil, proxy_addr, proxy_port).start { |http| + # # always proxy via your.proxy.addr:8080 + # } + # + # See Net::HTTP.new for further details and examples such as proxies that + # require a username and password. + # + # === Compression + # + # Net::HTTP automatically adds Accept-Encoding for compression of response + # bodies and automatically decompresses gzip and deflate responses unless a + # Range header was sent. + # + # Compression can be disabled through the Accept-Encoding: identity header. + # + # == HTTP Request Classes + # + # Here is the HTTP request class hierarchy. + # + # * Net::HTTPRequest + # * Net::HTTP::Get + # * Net::HTTP::Head + # * Net::HTTP::Post + # * Net::HTTP::Patch + # * Net::HTTP::Put + # * Net::HTTP::Proppatch + # * Net::HTTP::Lock + # * Net::HTTP::Unlock + # * Net::HTTP::Options + # * Net::HTTP::Propfind + # * Net::HTTP::Delete + # * Net::HTTP::Move + # * Net::HTTP::Copy + # * Net::HTTP::Mkcol + # * Net::HTTP::Trace + # + # == HTTP Response Classes + # + # Here is HTTP response class hierarchy. All classes are defined in Net + # module and are subclasses of Net::HTTPResponse. + # + # HTTPUnknownResponse:: For unhandled HTTP extensions + # HTTPInformation:: 1xx + # HTTPContinue:: 100 + # HTTPSwitchProtocol:: 101 + # HTTPSuccess:: 2xx + # HTTPOK:: 200 + # HTTPCreated:: 201 + # HTTPAccepted:: 202 + # HTTPNonAuthoritativeInformation:: 203 + # HTTPNoContent:: 204 + # HTTPResetContent:: 205 + # HTTPPartialContent:: 206 + # HTTPMultiStatus:: 207 + # HTTPIMUsed:: 226 + # HTTPRedirection:: 3xx + # HTTPMultipleChoices:: 300 + # HTTPMovedPermanently:: 301 + # HTTPFound:: 302 + # HTTPSeeOther:: 303 + # HTTPNotModified:: 304 + # HTTPUseProxy:: 305 + # HTTPTemporaryRedirect:: 307 + # HTTPClientError:: 4xx + # HTTPBadRequest:: 400 + # HTTPUnauthorized:: 401 + # HTTPPaymentRequired:: 402 + # HTTPForbidden:: 403 + # HTTPNotFound:: 404 + # HTTPMethodNotAllowed:: 405 + # HTTPNotAcceptable:: 406 + # HTTPProxyAuthenticationRequired:: 407 + # HTTPRequestTimeOut:: 408 + # HTTPConflict:: 409 + # HTTPGone:: 410 + # HTTPLengthRequired:: 411 + # HTTPPreconditionFailed:: 412 + # HTTPRequestEntityTooLarge:: 413 + # HTTPRequestURITooLong:: 414 + # HTTPUnsupportedMediaType:: 415 + # HTTPRequestedRangeNotSatisfiable:: 416 + # HTTPExpectationFailed:: 417 + # HTTPUnprocessableEntity:: 422 + # HTTPLocked:: 423 + # HTTPFailedDependency:: 424 + # HTTPUpgradeRequired:: 426 + # HTTPPreconditionRequired:: 428 + # HTTPTooManyRequests:: 429 + # HTTPRequestHeaderFieldsTooLarge:: 431 + # HTTPUnavailableForLegalReasons:: 451 + # HTTPServerError:: 5xx + # HTTPInternalServerError:: 500 + # HTTPNotImplemented:: 501 + # HTTPBadGateway:: 502 + # HTTPServiceUnavailable:: 503 + # HTTPGatewayTimeOut:: 504 + # HTTPVersionNotSupported:: 505 + # HTTPInsufficientStorage:: 507 + # HTTPNetworkAuthenticationRequired:: 511 + # + # There is also the Net::HTTPBadResponse exception which is raised when + # there is a protocol error. # class HTTP < Protocol # :stopdoc: - Revision = %q$Revision: 1.100.2.14 $.split[1] + Revision = %q$Revision$.split[1] HTTPVersion = '1.1' - @newimpl = true + begin + require 'zlib' + require 'stringio' #for our purposes (unpacking gzip) lump these together + HAVE_ZLIB=true + rescue LoadError + HAVE_ZLIB=false + end # :startdoc: - # Turns on net/http 1.2 (ruby 1.8) features. - # Defaults to ON in ruby 1.8. - # - # I strongly recommend to call this method always. - # - # require 'net/http' - # Net::HTTP.version_1_2 - # + # Turns on net/http 1.2 (Ruby 1.8) features. + # Defaults to ON in Ruby 1.8 or later. def HTTP.version_1_2 - @newimpl = true + true end - # Turns on net/http 1.1 (ruby 1.6) features. - # Defaults to OFF in ruby 1.8. - def HTTP.version_1_1 - @newimpl = false - end - - # true if net/http is in version 1.2 mode. + # Returns true if net/http is in version 1.2 mode. # Defaults to true. def HTTP.version_1_2? - @newimpl + true end - # true if net/http is in version 1.1 compatible mode. - # Defaults to true. - def HTTP.version_1_1? - not @newimpl + def HTTP.version_1_1? #:nodoc: + false end class << HTTP @@ -323,11 +429,11 @@ module Net #:nodoc: # # - # Get body from target and output it to +$stdout+. The - # target can either be specified as (+uri+), or as - # (+host+, +path+, +port+ = 80); so: + # Gets the body text from the target and outputs it to $stdout. The + # target can either be specified as + # (+uri+), or as (+host+, +path+, +port+ = 80); so: # - # Net::HTTP.get_print URI.parse('http://www.example.com/index.html') + # Net::HTTP.get_print URI('http://www.example.com/index.html') # # or: # @@ -342,11 +448,11 @@ module Net #:nodoc: nil end - # Send a GET request to the target and return the response + # Sends a GET request to the target and returns the HTTP response # as a string. The target can either be specified as # (+uri+), or as (+host+, +path+, +port+ = 80); so: - # - # print Net::HTTP.get(URI.parse('http://www.example.com/index.html')) + # + # print Net::HTTP.get(URI('http://www.example.com/index.html')) # # or: # @@ -356,11 +462,11 @@ module Net #:nodoc: get_response(uri_or_host, path, port).body end - # Send a GET request to the target and return the response + # Sends a GET request to the target and returns the HTTP response # as a Net::HTTPResponse object. The target can either be specified as # (+uri+), or as (+host+, +path+, +port+ = 80); so: - # - # res = Net::HTTP.get_response(URI.parse('http://www.example.com/index.html')) + # + # res = Net::HTTP.get_response(URI('http://www.example.com/index.html')) # print res.body # # or: @@ -376,32 +482,55 @@ module Net #:nodoc: } else uri = uri_or_host - new(uri.host, uri.port).start {|http| - return http.request_get(uri.request_uri, &block) + start(uri.hostname, uri.port, + :use_ssl => uri.scheme == 'https') {|http| + return http.request_get(uri, &block) } end end - # Posts HTML form data to the +URL+. - # Form data must be represented as a Hash of String to String, e.g: + # Posts data to the specified URI object. + # + # Example: + # + # require 'net/http' + # require 'uri' + # + # Net::HTTP.post URI('http://www.example.com/api/search'), + # { "q" => "ruby", "max" => "50" }.to_json, + # "Content-Type" => "application/json" + # + def HTTP.post(url, data, header = nil) + start(url.hostname, url.port, + :use_ssl => url.scheme == 'https' ) {|http| + http.post(url, data, header) + } + end + + # Posts HTML form data to the specified URI object. + # The form data must be provided as a Hash mapping from String to String. + # Example: # # { "cmd" => "search", "q" => "ruby", "max" => "50" } # - # This method also does Basic Authentication iff +URL+.user exists. + # This method also does Basic Authentication iff +url+.user exists. + # But userinfo for authentication is deprecated (RFC3986). + # So this feature will be removed. # # Example: # # require 'net/http' # require 'uri' # - # HTTP.post_form URI.parse('http://www.example.com/search.cgi'), - # { "q" => "ruby", "max" => "50" } + # Net::HTTP.post_form URI('http://www.example.com/search.cgi'), + # { "q" => "ruby", "max" => "50" } # def HTTP.post_form(url, params) - req = Post.new(url.path) + req = Post.new(url) req.form_data = params req.basic_auth url.user, url.password if url.user - new(url.host, url.port).start {|http| + start(url.hostname, url.port, + :use_ssl => url.scheme == 'https' ) {|http| http.request(req) } end @@ -429,90 +558,230 @@ module Net #:nodoc: BufferedIO end - # creates a new Net::HTTP object and opens its TCP connection and - # HTTP session. If the optional block is given, the newly - # created Net::HTTP object is passed to it and closed when the + # :call-seq: + # HTTP.start(address, port, p_addr, p_port, p_user, p_pass, &block) + # HTTP.start(address, port=nil, p_addr=:ENV, p_port=nil, p_user=nil, p_pass=nil, opt, &block) + # + # Creates a new Net::HTTP object, then additionally opens the TCP + # connection and HTTP session. + # + # Arguments are the following: + # _address_ :: hostname or IP address of the server + # _port_ :: port of the server + # _p_addr_ :: address of proxy + # _p_port_ :: port of proxy + # _p_user_ :: user of proxy + # _p_pass_ :: pass of proxy + # _opt_ :: optional hash + # + # _opt_ sets following values by its accessor. + # The keys are ca_file, ca_path, cert, cert_store, ciphers, + # close_on_empty_response, key, open_timeout, read_timeout, ssl_timeout, + # ssl_version, use_ssl, verify_callback, verify_depth and verify_mode. + # If you set :use_ssl as true, you can use https and default value of + # verify_mode is set as OpenSSL::SSL::VERIFY_PEER. + # + # If the optional block is given, the newly + # created Net::HTTP object is passed to it and closed when the # block finishes. In this case, the return value of this method # is the return value of the block. If no block is given, the # return value of this method is the newly created Net::HTTP object - # itself, and the caller is responsible for closing it upon completion. - def HTTP.start(address, port = nil, p_addr = nil, p_port = nil, p_user = nil, p_pass = nil, &block) # :yield: +http+ - new(address, port, p_addr, p_port, p_user, p_pass).start(&block) + # itself, and the caller is responsible for closing it upon completion + # using the finish() method. + def HTTP.start(address, *arg, &block) # :yield: +http+ + arg.pop if opt = Hash.try_convert(arg[-1]) + port, p_addr, p_port, p_user, p_pass = *arg + p_addr = :ENV if arg.size < 2 + port = https_default_port if !port && opt && opt[:use_ssl] + http = new(address, port, p_addr, p_port, p_user, p_pass) + + if opt + if opt[:use_ssl] + opt = {verify_mode: OpenSSL::SSL::VERIFY_PEER}.update(opt) + end + http.methods.grep(/\A(\w+)=\z/) do |meth| + key = $1.to_sym + opt.key?(key) or next + http.__send__(meth, opt[key]) + end + end + + http.start(&block) end class << HTTP - alias newobj new - end + alias newobj new # :nodoc: + end + + # Creates a new Net::HTTP object without opening a TCP connection or + # HTTP session. + # + # The +address+ should be a DNS hostname or IP address, the +port+ is the + # port the server operates on. If no +port+ is given the default port for + # HTTP or HTTPS is used. + # + # If none of the +p_+ arguments are given, the proxy host and port are + # taken from the +http_proxy+ environment variable (or its uppercase + # equivalent) if present. If the proxy requires authentication you must + # supply it by hand. See URI::Generic#find_proxy for details of proxy + # detection from the environment. To disable proxy detection set +p_addr+ + # to nil. + # + # If you are connecting to a custom proxy, +p_addr+ specifies the DNS name + # or IP address of the proxy host, +p_port+ the port to use to access the + # proxy, +p_user+ and +p_pass+ the username and password if authorization + # is required to use the proxy, and p_no_proxy hosts which do not + # use the proxy. + # + def HTTP.new(address, port = nil, p_addr = :ENV, p_port = nil, p_user = nil, p_pass = nil, p_no_proxy = nil) + http = super address, port + + if proxy_class? then # from Net::HTTP::Proxy() + http.proxy_from_env = @proxy_from_env + http.proxy_address = @proxy_address + http.proxy_port = @proxy_port + http.proxy_user = @proxy_user + http.proxy_pass = @proxy_pass + elsif p_addr == :ENV then + http.proxy_from_env = true + else + if p_addr && p_no_proxy && !URI::Generic.use_proxy?(p_addr, p_addr, p_port, p_no_proxy) + p_addr = nil + p_port = nil + end + http.proxy_address = p_addr + http.proxy_port = p_port || default_port + http.proxy_user = p_user + http.proxy_pass = p_pass + end - # Creates a new Net::HTTP object. - # If +proxy_addr+ is given, creates an Net::HTTP object with proxy support. - # This method does not open the TCP connection. - def HTTP.new(address, port = nil, p_addr = nil, p_port = nil, p_user = nil, p_pass = nil) - h = Proxy(p_addr, p_port, p_user, p_pass).newobj(address, port) - h.instance_eval { - @newimpl = ::Net::HTTP.version_1_2? - } - h + http end - # Creates a new Net::HTTP object for the specified +address+. - # This method does not open the TCP connection. + # Creates a new Net::HTTP object for the specified server address, + # without opening the TCP connection or initializing the HTTP session. + # The +address+ should be a DNS hostname or IP address. def initialize(address, port = nil) @address = address @port = (port || HTTP.default_port) + @local_host = nil + @local_port = nil @curr_http_version = HTTPVersion - @seems_1_0_server = false + @keep_alive_timeout = 2 + @last_communicated = nil @close_on_empty_response = false @socket = nil @started = false - @open_timeout = nil + @open_timeout = 60 @read_timeout = 60 + @continue_timeout = nil + @max_retries = 1 @debug_output = nil + + @proxy_from_env = false + @proxy_uri = nil + @proxy_address = nil + @proxy_port = nil + @proxy_user = nil + @proxy_pass = nil + @use_ssl = false @ssl_context = nil + @ssl_session = nil + @sspi_enabled = false + SSL_IVNAMES.each do |ivname| + instance_variable_set ivname, nil + end end def inspect "#<#{self.class} #{@address}:#{@port} open=#{started?}>" end - # *WARNING* This method causes serious security hole. + # *WARNING* This method opens a serious security hole. # Never use this method in production code. # - # Set an output stream for debugging. + # Sets an output stream for debugging. # - # http = Net::HTTP.new + # http = Net::HTTP.new(hostname) # http.set_debug_output $stderr # http.start { .... } # def set_debug_output(output) - warn 'Net::HTTP#set_debug_output called after HTTP started' if started? + warn 'Net::HTTP#set_debug_output called after HTTP started', uplevel: 1 if started? @debug_output = output end - # The host name to connect to. + # The DNS host name or IP address to connect to. attr_reader :address # The port number to connect to. attr_reader :port - # Seconds to wait until connection is opened. - # If the HTTP object cannot open a connection in this many seconds, - # it raises a TimeoutError exception. + # The local host used to establish the connection. + attr_accessor :local_host + + # The local port used to establish the connection. + attr_accessor :local_port + + attr_writer :proxy_from_env + attr_writer :proxy_address + attr_writer :proxy_port + attr_writer :proxy_user + attr_writer :proxy_pass + + # Number of seconds to wait for the connection to open. Any number + # may be used, including Floats for fractional seconds. If the HTTP + # object cannot open a connection in this many seconds, it raises a + # Net::OpenTimeout exception. The default value is 60 seconds. attr_accessor :open_timeout - # Seconds to wait until reading one block (by one read(2) call). - # If the HTTP object cannot open a connection in this many seconds, - # it raises a TimeoutError exception. + # Number of seconds to wait for one block to be read (via one read(2) + # call). Any number may be used, including Floats for fractional + # seconds. If the HTTP object cannot read data in this many seconds, + # it raises a Net::ReadTimeout exception. The default value is 60 seconds. attr_reader :read_timeout + # Maximum number of times to retry an idempotent request in case of + # Net::ReadTimeout, IOError, EOFError, Errno::ECONNRESET, + # Errno::ECONNABORTED, Errno::EPIPE, OpenSSL::SSL::SSLError, + # Timeout::Error. + # Should be a non-negative integer number. Zero means no retries. + # The default value is 1. + def max_retries=(retries) + retries = retries.to_int + if retries < 0 + raise ArgumentError, 'max_retries should be non-negative integer number' + end + @max_retries = retries + end + + attr_reader :max_retries + # Setter for the read_timeout attribute. def read_timeout=(sec) @socket.read_timeout = sec if @socket @read_timeout = sec end - # returns true if the HTTP session is started. + # Seconds to wait for 100 Continue response. If the HTTP object does not + # receive a response in this many seconds it sends the request body. The + # default value is +nil+. + attr_reader :continue_timeout + + # Setter for the continue_timeout attribute. + def continue_timeout=(sec) + @socket.continue_timeout = sec if @socket + @continue_timeout = sec + end + + # Seconds to reuse the connection of the previous request. + # If the idle time is less than this Keep-Alive Timeout, + # Net::HTTP reuses the TCP/IP socket used by the previous communication. + # The default value is 2 seconds. + attr_accessor :keep_alive_timeout + + # Returns true if the HTTP session has been started. def started? @started end @@ -521,19 +790,117 @@ module Net #:nodoc: attr_accessor :close_on_empty_response - # returns true if use SSL/TLS with HTTP. + # Returns true if SSL/TLS is being used with HTTP. def use_ssl? - false # redefined in net/https + @use_ssl end - # Opens TCP connection and HTTP session. - # - # When this method is called with block, gives a HTTP object - # to the block and closes the TCP connection / HTTP session - # after the block executed. + # Turn on/off SSL. + # This flag must be set before starting session. + # If you change use_ssl value after session started, + # a Net::HTTP object raises IOError. + def use_ssl=(flag) + flag = flag ? true : false + if started? and @use_ssl != flag + raise IOError, "use_ssl value changed, but session already started" + end + @use_ssl = flag + end + + SSL_IVNAMES = [ + :@ca_file, + :@ca_path, + :@cert, + :@cert_store, + :@ciphers, + :@key, + :@ssl_timeout, + :@ssl_version, + :@min_version, + :@max_version, + :@verify_callback, + :@verify_depth, + :@verify_mode, + ] + SSL_ATTRIBUTES = [ + :ca_file, + :ca_path, + :cert, + :cert_store, + :ciphers, + :key, + :ssl_timeout, + :ssl_version, + :min_version, + :max_version, + :verify_callback, + :verify_depth, + :verify_mode, + ] + + # Sets path of a CA certification file in PEM format. + # + # The file can contain several CA certificates. + attr_accessor :ca_file + + # Sets path of a CA certification directory containing certifications in + # PEM format. + attr_accessor :ca_path + + # Sets an OpenSSL::X509::Certificate object as client certificate. + # (This method is appeared in Michal Rokos's OpenSSL extension). + attr_accessor :cert + + # Sets the X509::Store to verify peer certificate. + attr_accessor :cert_store + + # Sets the available ciphers. See OpenSSL::SSL::SSLContext#ciphers= + attr_accessor :ciphers + + # Sets an OpenSSL::PKey::RSA or OpenSSL::PKey::DSA object. + # (This method is appeared in Michal Rokos's OpenSSL extension.) + attr_accessor :key + + # Sets the SSL timeout seconds. + attr_accessor :ssl_timeout + + # Sets the SSL version. See OpenSSL::SSL::SSLContext#ssl_version= + attr_accessor :ssl_version + + # Sets the minimum SSL version. See OpenSSL::SSL::SSLContext#min_version= + attr_accessor :min_version + + # Sets the maximum SSL version. See OpenSSL::SSL::SSLContext#max_version= + attr_accessor :max_version + + # Sets the verify callback for the server certification verification. + attr_accessor :verify_callback + + # Sets the maximum depth for the certificate chain verification. + attr_accessor :verify_depth + + # Sets the flags for server the certification verification at beginning of + # SSL/TLS session. + # + # OpenSSL::SSL::VERIFY_NONE or OpenSSL::SSL::VERIFY_PEER are acceptable. + attr_accessor :verify_mode + + # Returns the X.509 certificates the server presented. + def peer_cert + if not use_ssl? or not @socket + return nil + end + @socket.io.peer_cert + end + + # Opens a TCP connection and HTTP session. + # + # When this method is called with a block, it passes the Net::HTTP + # object to the block, and closes the TCP connection and HTTP session + # after the block has been executed. # - # When called with a block, returns the return value of the - # block; otherwise, returns self. + # When called with a block, it returns the return value of the + # block; otherwise, it returns self. # def start # :yield: http raise IOError, 'HTTP session already opened' if @started @@ -556,39 +923,81 @@ module Net #:nodoc: private :do_start def connect - D "opening connection to #{conn_address()}..." - s = timeout(@open_timeout) { TCPSocket.open(conn_address(), conn_port()) } - D "opened" - if use_ssl? - unless @ssl_context.verify_mode - warn "warning: peer certificate won't be verified in this SSL session" - @ssl_context.verify_mode = OpenSSL::SSL::VERIFY_NONE - end - s = OpenSSL::SSL::SSLSocket.new(s, @ssl_context) - s.sync_close = true + if proxy? then + conn_address = proxy_address + conn_port = proxy_port + else + conn_address = address + conn_port = port end - @socket = BufferedIO.new(s) - @socket.read_timeout = @read_timeout - @socket.debug_output = @debug_output + + D "opening connection to #{conn_address}:#{conn_port}..." + s = Timeout.timeout(@open_timeout, Net::OpenTimeout) { + begin + TCPSocket.open(conn_address, conn_port, @local_host, @local_port) + rescue => e + raise e, "Failed to open TCP connection to " + + "#{conn_address}:#{conn_port} (#{e.message})" + end + } + s.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1) + D "opened" if use_ssl? if proxy? - @socket.writeline sprintf('CONNECT %s:%s HTTP/%s', - @address, @port, HTTPVersion) - @socket.writeline "Host: #{@address}:#{@port}" + plain_sock = BufferedIO.new(s, read_timeout: @read_timeout, + continue_timeout: @continue_timeout, + debug_output: @debug_output) + buf = "CONNECT #{@address}:#{@port} HTTP/#{HTTPVersion}\r\n" + buf << "Host: #{@address}:#{@port}\r\n" if proxy_user - credential = ["#{proxy_user}:#{proxy_pass}"].pack('m') - credential.delete!("\r\n") - @socket.writeline "Proxy-Authorization: Basic #{credential}" + credential = ["#{proxy_user}:#{proxy_pass}"].pack('m0') + buf << "Proxy-Authorization: Basic #{credential}\r\n" + end + buf << "\r\n" + plain_sock.write(buf) + HTTPResponse.read_new(plain_sock).value + # assuming nothing left in buffers after successful CONNECT response + end + + ssl_parameters = Hash.new + iv_list = instance_variables + SSL_IVNAMES.each_with_index do |ivname, i| + if iv_list.include?(ivname) and + value = instance_variable_get(ivname) + ssl_parameters[SSL_ATTRIBUTES[i]] = value if value end - @socket.writeline '' - HTTPResponse.read_new(@socket).value end - s.connect + @ssl_context = OpenSSL::SSL::SSLContext.new + @ssl_context.set_params(ssl_parameters) + @ssl_context.session_cache_mode = + OpenSSL::SSL::SSLContext::SESSION_CACHE_CLIENT | + OpenSSL::SSL::SSLContext::SESSION_CACHE_NO_INTERNAL_STORE + @ssl_context.session_new_cb = proc {|sock, sess| @ssl_session = sess } + D "starting SSL for #{conn_address}:#{conn_port}..." + s = OpenSSL::SSL::SSLSocket.new(s, @ssl_context) + s.sync_close = true + # Server Name Indication (SNI) RFC 3546 + s.hostname = @address if s.respond_to? :hostname= + if @ssl_session and + Process.clock_gettime(Process::CLOCK_REALTIME) < @ssl_session.time.to_f + @ssl_session.timeout + s.session = @ssl_session + end + ssl_socket_connect(s, @open_timeout) if @ssl_context.verify_mode != OpenSSL::SSL::VERIFY_NONE s.post_connection_check(@address) end + D "SSL established" end + @socket = BufferedIO.new(s, read_timeout: @read_timeout, + continue_timeout: @continue_timeout, + debug_output: @debug_output) on_connect + rescue => exception + if s + D "Conn close because of connect error #{exception}" + s.close + end + raise end private :connect @@ -596,8 +1005,8 @@ module Net #:nodoc: end private :on_connect - # Finishes HTTP session and closes TCP connection. - # Raises IOError if not started. + # Finishes the HTTP session and closes the TCP connection. + # Raises IOError if the session has not been started. def finish raise IOError, 'HTTP session not yet started' unless started? do_finish @@ -605,7 +1014,7 @@ module Net #:nodoc: def do_finish @started = false - @socket.close if @socket and not @socket.closed? + @socket.close if @socket @socket = nil end private :do_finish @@ -618,77 +1027,118 @@ module Net #:nodoc: # no proxy @is_proxy_class = false + @proxy_from_env = false @proxy_addr = nil @proxy_port = nil @proxy_user = nil @proxy_pass = nil - # Creates an HTTP proxy class. - # Arguments are address/port of proxy host and username/password - # if authorization on proxy server is required. - # You can replace the HTTP class with created proxy class. - # - # If ADDRESS is nil, this method returns self (Net::HTTP). - # - # # Example - # proxy_class = Net::HTTP::Proxy('proxy.example.com', 8080) - # : - # proxy_class.start('www.ruby-lang.org') {|http| - # # connecting proxy.foo.org:8080 - # : - # } - # - def HTTP.Proxy(p_addr, p_port = nil, p_user = nil, p_pass = nil) + # Creates an HTTP proxy class which behaves like Net::HTTP, but + # performs all access via the specified proxy. + # + # This class is obsolete. You may pass these same parameters directly to + # Net::HTTP.new. See Net::HTTP.new for details of the arguments. + def HTTP.Proxy(p_addr = :ENV, p_port = nil, p_user = nil, p_pass = nil) return self unless p_addr - delta = ProxyDelta - proxyclass = Class.new(self) - proxyclass.module_eval { - include delta - # with proxy + + Class.new(self) { @is_proxy_class = true - @proxy_address = p_addr - @proxy_port = p_port || default_port() - @proxy_user = p_user - @proxy_pass = p_pass + + if p_addr == :ENV then + @proxy_from_env = true + @proxy_address = nil + @proxy_port = nil + else + @proxy_from_env = false + @proxy_address = p_addr + @proxy_port = p_port || default_port + end + + @proxy_user = p_user + @proxy_pass = p_pass } - proxyclass end class << HTTP # returns true if self is a class which was created by HTTP::Proxy. def proxy_class? - @is_proxy_class + defined?(@is_proxy_class) ? @is_proxy_class : false end + # Address of proxy host. If Net::HTTP does not use a proxy, nil. attr_reader :proxy_address + + # Port number of proxy host. If Net::HTTP does not use a proxy, nil. attr_reader :proxy_port + + # User name for accessing proxy. If Net::HTTP does not use a proxy, nil. attr_reader :proxy_user + + # User password for accessing proxy. If Net::HTTP does not use a proxy, + # nil. attr_reader :proxy_pass end - # True if self is a HTTP proxy class. + # True if requests for this connection will be proxied def proxy? - self.class.proxy_class? + !!(@proxy_from_env ? proxy_uri : @proxy_address) + end + + # True if the proxy for this connection is determined from the environment + def proxy_from_env? + @proxy_from_env + end + + # The proxy URI determined from the environment for this connection. + def proxy_uri # :nodoc: + return if @proxy_uri == false + @proxy_uri ||= URI::HTTP.new( + "http".freeze, nil, address, port, nil, nil, nil, nil, nil + ).find_proxy || false + @proxy_uri || nil end - # Address of proxy host. If self does not use a proxy, nil. + # The address of the proxy server, if one is configured. def proxy_address - self.class.proxy_address + if @proxy_from_env then + proxy_uri&.hostname + else + @proxy_address + end end - # Port number of proxy host. If self does not use a proxy, nil. + # The port of the proxy server, if one is configured. def proxy_port - self.class.proxy_port + if @proxy_from_env then + proxy_uri&.port + else + @proxy_port + end + end + + # [Bug #12921] + if /linux|freebsd|darwin/ =~ RUBY_PLATFORM + ENVIRONMENT_VARIABLE_IS_MULTIUSER_SAFE = true + else + ENVIRONMENT_VARIABLE_IS_MULTIUSER_SAFE = false end - # User name for accessing proxy. If self does not use a proxy, nil. + # The username of the proxy server, if one is configured. def proxy_user - self.class.proxy_user + if ENVIRONMENT_VARIABLE_IS_MULTIUSER_SAFE && @proxy_from_env + proxy_uri&.user + else + @proxy_user + end end - # User password for accessing proxy. If self does not use a proxy, nil. + # The password of the proxy server, if one is configured. def proxy_pass - self.class.proxy_pass + if ENVIRONMENT_VARIABLE_IS_MULTIUSER_SAFE && @proxy_from_env + proxy_uri&.password + else + @proxy_pass + end end alias proxyaddr proxy_address #:nodoc: obsolete @@ -696,33 +1146,25 @@ module Net #:nodoc: private - # without proxy + # without proxy, obsolete - def conn_address + def conn_address # :nodoc: address() end - def conn_port + def conn_port # :nodoc: port() end def edit_path(path) - path - end - - module ProxyDelta #:nodoc: internal use only - private - - def conn_address - proxy_address() - end - - def conn_port - proxy_port() - end - - def edit_path(path) - use_ssl? ? path : "http://#{addr_port()}#{path}" + if proxy? + if path.start_with?("ftp://") || use_ssl? + path + else + "http://#{addr_port}#{path}" + end + else + path end end @@ -732,13 +1174,23 @@ module Net #:nodoc: public - # Gets data from +path+ on the connected-to host. - # +header+ must be a Hash like { 'Accept' => '*/*', ... }. + # Retrieves data from +path+ on the connected-to host which may be an + # absolute path String or a URI to extract the path from. + # + # +initheader+ must be a Hash like { 'Accept' => '*/*', ... }, + # and it defaults to an empty hash. + # If +initheader+ doesn't have the key 'accept-encoding', then + # a value of "gzip;q=1.0,deflate;q=0.6,identity;q=0.3" is used, + # so that gzip compression is used in preference to deflate + # compression, which is used in preference to no compression. + # Ruby doesn't have libraries to support the compress (Lempel-Ziv) + # compression, so that is not supported. The intent of this is + # to reduce bandwidth by default. If this routine sets up + # compression, then it does the decompression also, removing + # the header as well to prevent confusion. Otherwise + # it leaves the body as it found it. # - # In version 1.1 (ruby 1.6), this method returns a pair of objects, - # a Net::HTTPResponse object and the entity body string. - # In version 1.2 (ruby 1.8), this method returns a Net::HTTPResponse - # object. + # This method returns a Net::HTTPResponse object. # # If called with a block, yields each fragment of the # entity body in turn as a string as it is read from @@ -748,18 +1200,10 @@ module Net #:nodoc: # +dest+ argument is obsolete. # It still works but you must not use it. # - # In version 1.1, this method might raise an exception for - # 3xx (redirect). In this case you can get a HTTPResponse object - # by "anException.response". - # - # In version 1.2, this method never raises exception. - # - # # version 1.1 (bundled with Ruby 1.6) - # response, body = http.get('/index.html') + # This method never raises an exception. # - # # version 1.2 (bundled with Ruby 1.8 or later) # response = http.get('/index.html') - # + # # # using block # File.open('result.txt', 'w') {|f| # http.get('/~foo/') do |str| @@ -773,62 +1217,43 @@ module Net #:nodoc: r.read_body dest, &block res = r } - unless @newimpl - res.value - return res, res.body - end - res end # Gets only the header from +path+ on the connected-to host. # +header+ is a Hash like { 'Accept' => '*/*', ... }. - # + # # This method returns a Net::HTTPResponse object. - # - # In version 1.1, this method might raise an exception for - # 3xx (redirect). On the case you can get a HTTPResponse object - # by "anException.response". - # In version 1.2, this method never raises an exception. - # + # + # This method never raises an exception. + # # response = nil # Net::HTTP.start('some.www.server', 80) {|http| # response = http.head('/index.html') # } # p response['content-type'] # - def head(path, initheader = nil) - res = request(Head.new(path, initheader)) - res.value unless @newimpl - res + def head(path, initheader = nil) + request(Head.new(path, initheader)) end # Posts +data+ (must be a String) to +path+. +header+ must be a Hash # like { 'Accept' => '*/*', ... }. - # - # In version 1.1 (ruby 1.6), this method returns a pair of objects, a - # Net::HTTPResponse object and an entity body string. - # In version 1.2 (ruby 1.8), this method returns a Net::HTTPResponse object. - # + # + # This method returns a Net::HTTPResponse object. + # # If called with a block, yields each fragment of the - # entity body in turn as a string as it are read from + # entity body in turn as a string as it is read from # the socket. Note that in this case, the returned response # object will *not* contain a (meaningful) body. # # +dest+ argument is obsolete. # It still works but you must not use it. - # - # In version 1.1, this method might raise an exception for - # 3xx (redirect). In this case you can get an HTTPResponse object - # by "anException.response". - # In version 1.2, this method never raises exception. - # - # # version 1.1 - # response, body = http.post('/cgi-bin/search.rb', 'query=foo') - # - # # version 1.2 + # + # This method never raises exception. + # # response = http.post('/cgi-bin/search.rb', 'query=foo') - # + # # # using block # File.open('result.txt', 'w') {|f| # http.post('/cgi-bin/search.rb', 'query=foo') do |str| @@ -841,22 +1266,17 @@ module Net #:nodoc: # "application/x-www-form-urlencoded" by default. # def post(path, data, initheader = nil, dest = nil, &block) # :yield: +body_segment+ - res = nil - request(Post.new(path, initheader), data) {|r| - r.read_body dest, &block - res = r - } - unless @newimpl - res.value - return res, res.body - end - res + send_entity(path, data, initheader, dest, Post, &block) + end + + # Sends a PATCH request to the +path+ and gets a response, + # as an HTTPResponse object. + def patch(path, data, initheader = nil, dest = nil, &block) # :yield: +body_segment+ + send_entity(path, data, initheader, dest, Patch, &block) end def put(path, data, initheader = nil) #:nodoc: - res = request(Put.new(path, initheader), data) - res.value unless @newimpl - res + request(Put.new(path, initheader), data) end # Sends a PROPPATCH request to the +path+ and gets a response, @@ -919,24 +1339,24 @@ module Net #:nodoc: request(Trace.new(path, initheader)) end - # Sends a GET request to the +path+ and gets a response, - # as an HTTPResponse object. - # - # When called with a block, yields an HTTPResponse object. - # The body of this response will not have been read yet; - # the caller can process it using HTTPResponse#read_body, + # Sends a GET request to the +path+. + # Returns the response as a Net::HTTPResponse object. + # + # When called with a block, passes an HTTPResponse object to the block. + # The body of the response will not have been read yet; + # the block can process it using HTTPResponse#read_body, # if desired. # # Returns the response. - # + # # This method never raises Net::* exceptions. - # + # # response = http.request_get('/index.html') - # # The entity body is already read here. + # # The entity body is already read in this case. # p response['content-type'] # puts response.body - # - # # using block + # + # # Using a block # http.request_get('/index.html') {|response| # p response['content-type'] # response.read_body do |str| # read body now @@ -948,13 +1368,13 @@ module Net #:nodoc: request(Get.new(path, initheader), &block) end - # Sends a HEAD request to the +path+ and gets a response, - # as an HTTPResponse object. + # Sends a HEAD request to the +path+ and returns the response + # as a Net::HTTPResponse object. # # Returns the response. - # + # # This method never raises Net::* exceptions. - # + # # response = http.request_head('/index.html') # p response['content-type'] # @@ -962,23 +1382,23 @@ module Net #:nodoc: request(Head.new(path, initheader), &block) end - # Sends a POST request to the +path+ and gets a response, - # as an HTTPResponse object. - # - # When called with a block, yields an HTTPResponse object. - # The body of this response will not have been read yet; - # the caller can process it using HTTPResponse#read_body, - # if desired. + # Sends a POST request to the +path+. + # + # Returns the response as a Net::HTTPResponse object. + # + # When called with a block, the block is passed an HTTPResponse + # object. The body of that response will not have been read yet; + # the block can process it using HTTPResponse#read_body, if desired. # # Returns the response. - # + # # This method never raises Net::* exceptions. - # + # # # example # response = http.request_post('/cgi-bin/nice.rb', 'datadatadata...') # p response.status - # puts response.body # body is already read - # + # puts response.body # body is already read in this case + # # # using block # http.request_post('/cgi-bin/nice.rb', 'datadatadata...') {|response| # p response.status @@ -1003,31 +1423,34 @@ module Net #:nodoc: # Sends an HTTP request to the HTTP server. - # This method also sends DATA string if DATA is given. + # Also sends a DATA string if +data+ is given. + # + # Returns a Net::HTTPResponse object. # - # Returns a HTTPResponse object. - # # This method never raises Net::* exceptions. # # response = http.send_request('GET', '/index.html') # puts response.body # def send_request(name, path, data = nil, header = nil) - r = HTTPGenericRequest.new(name,(data ? true : false),true,path,header) + has_response_body = name != 'HEAD' + r = HTTPGenericRequest.new(name,(data ? true : false),has_response_body,path,header) request r, data end - # Sends an HTTPRequest object REQUEST to the HTTP server. - # This method also sends DATA string if REQUEST is a post/put request. - # Giving DATA for get/head request causes ArgumentError. - # - # When called with a block, yields an HTTPResponse object. - # The body of this response will not have been read yet; - # the caller can process it using HTTPResponse#read_body, + # Sends an HTTPRequest object +req+ to the HTTP server. + # + # If +req+ is a Net::HTTP::Post or Net::HTTP::Put request containing + # data, the data is also sent. Providing data for a Net::HTTP::Head or + # Net::HTTP::Get request results in an ArgumentError. + # + # Returns an HTTPResponse object. + # + # When called with a block, passes an HTTPResponse object to the block. + # The body of the response will not have been read yet; + # the block can process it using HTTPResponse#read_body, # if desired. # - # Returns a HTTPResponse object. - # # This method never raises Net::* exceptions. # def request(req, body = nil, &block) # :yield: +response+ @@ -1038,51 +1461,110 @@ module Net #:nodoc: } end if proxy_user() - unless use_ssl? - req.proxy_basic_auth proxy_user(), proxy_pass() - end + req.proxy_basic_auth proxy_user(), proxy_pass() unless use_ssl? end - req.set_body_internal body - begin_transport req - req.exec @socket, @curr_http_version, edit_path(req.path) - begin - res = HTTPResponse.read_new(@socket) - end while res.kind_of?(HTTPContinue) + res = transport_request(req, &block) + if sspi_auth?(res) + sspi_auth(req) + res = transport_request(req, &block) + end + res + end + + private + + # Executes a request which uses a representation + # and returns its body. + def send_entity(path, data, initheader, dest, type, &block) + res = nil + request(type.new(path, initheader), data) {|r| + r.read_body dest, &block + res = r + } + res + end + + IDEMPOTENT_METHODS_ = %w/GET HEAD PUT DELETE OPTIONS TRACE/ # :nodoc: + + def transport_request(req) + count = 0 + begin + begin_transport req + res = catch(:response) { + req.exec @socket, @curr_http_version, edit_path(req.path) + begin + res = HTTPResponse.read_new(@socket) + res.decode_content = req.decode_content + end while res.kind_of?(HTTPInformation) + + res.uri = req.uri + + res + } res.reading_body(@socket, req.response_body_permitted?) { yield res if block_given? } - end_transport req, res + rescue Net::OpenTimeout + raise + rescue Net::ReadTimeout, IOError, EOFError, + Errno::ECONNRESET, Errno::ECONNABORTED, Errno::EPIPE, + # avoid a dependency on OpenSSL + defined?(OpenSSL::SSL) ? OpenSSL::SSL::SSLError : IOError, + Timeout::Error => exception + if count < max_retries && IDEMPOTENT_METHODS_.include?(req.method) + count += 1 + @socket.close if @socket + D "Conn close because of error #{exception}, and retry" + retry + end + D "Conn close because of error #{exception}" + @socket.close if @socket + raise + end + end_transport req, res res + rescue => exception + D "Conn close because of error #{exception}" + @socket.close if @socket + raise exception end - private - def begin_transport(req) if @socket.closed? connect + elsif @last_communicated + if @last_communicated + @keep_alive_timeout < Process.clock_gettime(Process::CLOCK_MONOTONIC) + D 'Conn close because of keep_alive_timeout' + @socket.close + connect + elsif @socket.io.to_io.wait_readable(0) && @socket.eof? + D "Conn close because of EOF" + @socket.close + connect + end end - if @seems_1_0_server - req['connection'] ||= 'close' - end + if not req.response_body_permitted? and @close_on_empty_response req['connection'] ||= 'close' end + + req.update_uri address, port, use_ssl? req['host'] ||= addr_port() end def end_transport(req, res) @curr_http_version = res.http_version - if not res.body and @close_on_empty_response + @last_communicated = nil + if @socket.closed? + D 'Conn socket closed' + elsif not res.body and @close_on_empty_response D 'Conn close' @socket.close elsif keep_alive?(req, res) D 'Conn keep-alive' - if @socket.closed? - D 'Conn (but seems 1.0 server)' - @seems_1_0_server = true - end + @last_communicated = Process.clock_gettime(Process::CLOCK_MONOTONIC) else D 'Conn close' @socket.close @@ -1090,1188 +1572,75 @@ module Net #:nodoc: end def keep_alive?(req, res) - return false if /close/i =~ req['connection'].to_s - return false if @seems_1_0_server - return true if /keep-alive/i =~ res['connection'].to_s - return false if /close/i =~ res['connection'].to_s - return true if /keep-alive/i =~ res['proxy-connection'].to_s - return false if /close/i =~ res['proxy-connection'].to_s - (@curr_http_version == '1.1') - end - - # - # utils - # - - private - - def addr_port - if use_ssl? - address() + (port == HTTP.https_default_port ? '' : ":#{port()}") - else - address() + (port == HTTP.http_default_port ? '' : ":#{port()}") + return false if req.connection_close? + if @curr_http_version <= '1.0' + res.connection_keep_alive? + else # HTTP/1.1 or later + not res.connection_close? end end - def D(msg) - return unless @debug_output - @debug_output << msg - @debug_output << "\n" - end - - end - - HTTPSession = HTTP - - - # - # Header module. - # - # Provides access to @header in the mixed-into class as a hash-like - # object, except with case-insensitive keys. Also provides - # methods for accessing commonly-used header values in a more - # convenient format. - # - module HTTPHeader - - def initialize_http_header(initheader) - @header = {} - return unless initheader - initheader.each do |key, value| - warn "net/http: warning: duplicated HTTP header: #{key}" if key?(key) and $VERBOSE - @header[key.downcase] = [value.strip] - end - end - - def size #:nodoc: obsolete - @header.size - end - - alias length size #:nodoc: obsolete - - # Returns the header field corresponding to the case-insensitive key. - # For example, a key of "Content-Type" might return "text/html" - def [](key) - a = @header[key.downcase] or return nil - a.join(', ') - end - - # Sets the header field corresponding to the case-insensitive key. - def []=(key, val) - unless val - @header.delete key.downcase - return val - end - @header[key.downcase] = [val] - end - - # [Ruby 1.8.3] - # Adds header field instead of replace. - # Second argument +val+ must be a String. - # See also #[]=, #[] and #get_fields. - # - # request.add_field 'X-My-Header', 'a' - # p request['X-My-Header'] #=> "a" - # p request.get_fields('X-My-Header') #=> ["a"] - # request.add_field 'X-My-Header', 'b' - # p request['X-My-Header'] #=> "a, b" - # p request.get_fields('X-My-Header') #=> ["a", "b"] - # request.add_field 'X-My-Header', 'c' - # p request['X-My-Header'] #=> "a, b, c" - # p request.get_fields('X-My-Header') #=> ["a", "b", "c"] - # - def add_field(key, val) - if @header.key?(key.downcase) - @header[key.downcase].push val - else - @header[key.downcase] = [val] - end - end - - # [Ruby 1.8.3] - # Returns an array of header field strings corresponding to the - # case-insensitive +key+. This method allows you to get duplicated - # header fields without any processing. See also #[]. - # - # p response.get_fields('Set-Cookie') - # #=> ["session=al98axx; expires=Fri, 31-Dec-1999 23:58:23", - # "query=rubyscript; expires=Fri, 31-Dec-1999 23:58:23"] - # p response['Set-Cookie'] - # #=> "session=al98axx; expires=Fri, 31-Dec-1999 23:58:23, query=rubyscript; expires=Fri, 31-Dec-1999 23:58:23" - # - def get_fields(key) - return nil unless @header[key.downcase] - @header[key.downcase].dup - end - - # Returns the header field corresponding to the case-insensitive key. - # Returns the default value +args+, or the result of the block, or nil, - # if there's no header field named key. See Hash#fetch - def fetch(key, *args, &block) #:yield: +key+ - a = @header.fetch(key.downcase, *args, &block) - a.join(', ') - end - - # Iterates for each header names and values. - def each_header #:yield: +key+, +value+ - @header.each do |k,va| - yield k, va.join(', ') - end - end - - alias each each_header - - # Iterates for each header names. - def each_name(&block) #:yield: +key+ - @header.each_key(&block) - end - - alias each_key each_name - - # Iterates for each capitalized header names. - def each_capitalized_name(&block) #:yield: +key+ - @header.each_key do |k| - yield capitalize(k) - end - end - - # Iterates for each header values. - def each_value #:yield: +value+ - @header.each_value do |va| - yield va.join(', ') - end - end - - # Removes a header field. - def delete(key) - @header.delete(key.downcase) - end - - # true if +key+ header exists. - def key?(key) - @header.key?(key.downcase) - end - - # Returns a Hash consist of header names and values. - def to_hash - @header.dup - end - - # As for #each_header, except the keys are provided in capitalized form. - def each_capitalized - @header.each do |k,v| - yield capitalize(k), v.join(', ') - end - end - - alias canonical_each each_capitalized - - def capitalize(name) - name.split(/-/).map {|s| s.capitalize }.join('-') - end - private :capitalize - - # Returns an Array of Range objects which represents Range: header field, - # or +nil+ if there is no such header. - def range - return nil unless @header['range'] - self['Range'].split(/,/).map {|spec| - m = /bytes\s*=\s*(\d+)?\s*-\s*(\d+)?/i.match(spec) or - raise HTTPHeaderSyntaxError, "wrong Range: #{spec}" - d1 = m[1].to_i - d2 = m[2].to_i - if m[1] and m[2] then d1..d2 - elsif m[1] then d1..-1 - elsif m[2] then -d2..-1 - else - raise HTTPHeaderSyntaxError, 'range is not specified' - end - } - end - - # Set Range: header from Range (arg r) or beginning index and - # length from it (arg idx&len). - # - # req.range = (0..1023) - # req.set_range 0, 1023 - # - def set_range(r, e = nil) - unless r - @header.delete 'range' - return r - end - r = (r...r+e) if e - case r - when Numeric - n = r.to_i - rangestr = (n > 0 ? "0-#{n-1}" : "-#{-n}") - when Range - first = r.first - last = r.last - last -= 1 if r.exclude_end? - if last == -1 - rangestr = (first > 0 ? "#{first}-" : "-#{-first}") - else - raise HTTPHeaderSyntaxError, 'range.first is negative' if first < 0 - raise HTTPHeaderSyntaxError, 'range.last is negative' if last < 0 - raise HTTPHeaderSyntaxError, 'must be .first < .last' if first > last - rangestr = "#{first}-#{last}" + def sspi_auth?(res) + return false unless @sspi_enabled + if res.kind_of?(HTTPProxyAuthenticationRequired) and + proxy? and res["Proxy-Authenticate"].include?("Negotiate") + begin + require 'win32/sspi' + true + rescue LoadError + false end else - raise TypeError, 'Range/Integer is required' - end - @header['range'] = ["bytes=#{rangestr}"] - r - end - - alias range= set_range - - # Returns an Integer object which represents the Content-Length: header field - # or +nil+ if that field is not provided. - def content_length - return nil unless key?('Content-Length') - len = self['Content-Length'].slice(/\d+/) or - raise HTTPHeaderSyntaxError, 'wrong Content-Length format' - len.to_i - end - - def content_length=(len) - unless len - @header.delete 'content-length' - return nil - end - @header['content-length'] = [len.to_i.to_s] - end - - # Returns "true" if the "transfer-encoding" header is present and - # set to "chunked". This is an HTTP/1.1 feature, allowing the - # the content to be sent in "chunks" without at the outset - # stating the entire content length. - def chunked? - return false unless @header['transfer-encoding'] - field = self['Transfer-Encoding'] - (/(?:\A|[^\-\w])chunked(?![\-\w])/i =~ field) ? true : false - end - - # Returns a Range object which represents Content-Range: header field. - # This indicates, for a partial entity body, where this fragment - # fits inside the full entity body, as range of byte offsets. - def content_range - return nil unless @header['content-range'] - m = %r<bytes\s+(\d+)-(\d+)/(\d+|\*)>i.match(self['Content-Range']) or - raise HTTPHeaderSyntaxError, 'wrong Content-Range format' - m[1].to_i .. m[2].to_i + 1 - end - - # The length of the range represented in Content-Range: header. - def range_length - r = content_range() or return nil - r.end - r.begin - end - - # Returns a content type string such as "text/html". - # This method returns nil if Content-Type: header field does not exist. - def content_type - return nil unless main_type() - if sub_type() - then "#{main_type()}/#{sub_type()}" - else main_type() - end - end - - # Returns a content type string such as "text". - # This method returns nil if Content-Type: header field does not exist. - def main_type - return nil unless @header['content-type'] - self['Content-Type'].split(';').first.to_s.split('/')[0].to_s.strip - end - - # Returns a content type string such as "html". - # This method returns nil if Content-Type: header field does not exist - # or sub-type is not given (e.g. "Content-Type: text"). - def sub_type - return nil unless @header['content-type'] - main, sub = *self['Content-Type'].split(';').first.to_s.split('/') - return nil unless sub - sub.strip - end - - # Returns content type parameters as a Hash as like - # {"charset" => "iso-2022-jp"}. - def type_params - result = {} - list = self['Content-Type'].to_s.split(';') - list.shift - list.each do |param| - k, v = *param.split('=', 2) - result[k.strip] = v.strip + false end - result - end - - # Set Content-Type: header field by +type+ and +params+. - # +type+ must be a String, +params+ must be a Hash. - def set_content_type(type, params = {}) - @header['content-type'] = [type + params.map{|k,v|"; #{k}=#{v}"}.join('')] - end - - alias content_type= set_content_type - - # Set header fields and a body from HTML form data. - # +params+ should be a Hash containing HTML form data. - # Optional argument +sep+ means data record separator. - # - # This method also set Content-Type: header field to - # application/x-www-form-urlencoded. - def set_form_data(params, sep = '&') - self.body = params.map {|k,v| "#{urlencode(k.to_s)}=#{urlencode(v.to_s)}" }.join(sep) - self.content_type = 'application/x-www-form-urlencoded' - end - - alias form_data= set_form_data - - def urlencode(str) - str.gsub(/[^a-zA-Z0-9_\.\-]/n) {|s| sprintf('%%%02x', s[0]) } - end - private :urlencode - - # Set the Authorization: header for "Basic" authorization. - def basic_auth(account, password) - @header['authorization'] = [basic_encode(account, password)] - end - - # Set Proxy-Authorization: header for "Basic" authorization. - def proxy_basic_auth(account, password) - @header['proxy-authorization'] = [basic_encode(account, password)] - end - - def basic_encode(account, password) - 'Basic ' + ["#{account}:#{password}"].pack('m').delete("\r\n") - end - private :basic_encode - - end - - - # - # Parent of HTTPRequest class. Do not use this directly; use - # a subclass of HTTPRequest. - # - # Mixes in the HTTPHeader module. - # - class HTTPGenericRequest - - include HTTPHeader - - def initialize(m, reqbody, resbody, path, initheader = nil) - @method = m - @request_has_body = reqbody - @response_has_body = resbody - raise ArgumentError, "HTTP request path is empty" if path.empty? - @path = path - initialize_http_header initheader - self['Accept'] ||= '*/*' - @body = nil - @body_stream = nil - end - - attr_reader :method - attr_reader :path - - def inspect - "\#<#{self.class} #{@method}>" - end - - def request_body_permitted? - @request_has_body - end - - def response_body_permitted? - @response_has_body - end - - def body_exist? - warn "Net::HTTPRequest#body_exist? is obsolete; use response_body_permitted?" if $VERBOSE - response_body_permitted? - end - - attr_reader :body - - def body=(str) - @body = str - @body_stream = nil - str end - attr_reader :body_stream - - def body_stream=(input) - @body = nil - @body_stream = input - input - end - - def set_body_internal(str) #:nodoc: internal use only - raise ArgumentError, "both of body argument and HTTPRequest#body set" if str and (@body or @body_stream) - self.body = str if str + def sspi_auth(req) + n = Win32::SSPI::NegotiateAuth.new + req["Proxy-Authorization"] = "Negotiate #{n.get_initial_token}" + # Some versions of ISA will close the connection if this isn't present. + req["Connection"] = "Keep-Alive" + req["Proxy-Connection"] = "Keep-Alive" + res = transport_request(req) + authphrase = res["Proxy-Authenticate"] or return res + req["Proxy-Authorization"] = "Negotiate #{n.complete_authentication(authphrase)}" + rescue => err + raise HTTPAuthenticationError.new('HTTP authentication failed', err) end # - # write + # utils # - def exec(sock, ver, path) #:nodoc: internal use only - if @body - send_request_with_body sock, ver, path, @body - elsif @body_stream - send_request_with_body_stream sock, ver, path, @body_stream - else - write_header sock, ver, path - end - end - private - def send_request_with_body(sock, ver, path, body) - self.content_length = body.length - delete 'Transfer-Encoding' - supply_default_content_type - write_header sock, ver, path - sock.write body - end - - def send_request_with_body_stream(sock, ver, path, f) - unless content_length() or chunked? - raise ArgumentError, - "Content-Length not given and Transfer-Encoding is not `chunked'" - end - supply_default_content_type - write_header sock, ver, path - if chunked? - while s = f.read(1024) - sock.write(sprintf("%x\r\n", s.length) << s << "\r\n") - end - sock.write "0\r\n\r\n" - else - while s = f.read(1024) - sock.write s - end - end - end - - def supply_default_content_type - return if content_type() - warn 'net/http: warning: Content-Type did not set; using application/x-www-form-urlencoded' if $VERBOSE - set_content_type 'application/x-www-form-urlencoded' - end - - def write_header(sock, ver, path) - buf = "#{@method} #{path} HTTP/#{ver}\r\n" - each_capitalized do |k,v| - buf << "#{k}: #{v}\r\n" - end - buf << "\r\n" - sock.write buf - end - - end - - - # - # HTTP request class. This class wraps request header and entity path. - # You *must* use its subclass, Net::HTTP::Get, Post, Head. - # - class HTTPRequest < HTTPGenericRequest - - # Creates HTTP request object. - def initialize(path, initheader = nil) - super self.class::METHOD, - self.class::REQUEST_HAS_BODY, - self.class::RESPONSE_HAS_BODY, - path, initheader - end - end - - - class HTTP # reopen - # - # HTTP 1.1 methods --- RFC2616 - # - - class Get < HTTPRequest - METHOD = 'GET' - REQUEST_HAS_BODY = false - RESPONSE_HAS_BODY = true - end - - class Head < HTTPRequest - METHOD = 'HEAD' - REQUEST_HAS_BODY = false - RESPONSE_HAS_BODY = false - end - - class Post < HTTPRequest - METHOD = 'POST' - REQUEST_HAS_BODY = true - RESPONSE_HAS_BODY = true - end - - class Put < HTTPRequest - METHOD = 'PUT' - REQUEST_HAS_BODY = true - RESPONSE_HAS_BODY = true - end - - class Delete < HTTPRequest - METHOD = 'DELETE' - REQUEST_HAS_BODY = false - RESPONSE_HAS_BODY = true - end - - class Options < HTTPRequest - METHOD = 'OPTIONS' - REQUEST_HAS_BODY = false - RESPONSE_HAS_BODY = false - end - - class Trace < HTTPRequest - METHOD = 'TRACE' - REQUEST_HAS_BODY = false - RESPONSE_HAS_BODY = true - end - - # - # WebDAV methods --- RFC2518 - # - - class Propfind < HTTPRequest - METHOD = 'PROPFIND' - REQUEST_HAS_BODY = true - RESPONSE_HAS_BODY = true - end - - class Proppatch < HTTPRequest - METHOD = 'PROPPATCH' - REQUEST_HAS_BODY = true - RESPONSE_HAS_BODY = true - end - - class Mkcol < HTTPRequest - METHOD = 'MKCOL' - REQUEST_HAS_BODY = true - RESPONSE_HAS_BODY = true - end - - class Copy < HTTPRequest - METHOD = 'COPY' - REQUEST_HAS_BODY = false - RESPONSE_HAS_BODY = true - end - - class Move < HTTPRequest - METHOD = 'MOVE' - REQUEST_HAS_BODY = false - RESPONSE_HAS_BODY = true - end - - class Lock < HTTPRequest - METHOD = 'LOCK' - REQUEST_HAS_BODY = true - RESPONSE_HAS_BODY = true - end - - class Unlock < HTTPRequest - METHOD = 'UNLOCK' - REQUEST_HAS_BODY = true - RESPONSE_HAS_BODY = true + def addr_port + addr = address + addr = "[#{addr}]" if addr.include?(":") + default_port = use_ssl? ? HTTP.https_default_port : HTTP.http_default_port + default_port == port ? addr : "#{addr}:#{port}" end - end - - ### - ### Response - ### - - # HTTP exception class. - # You must use its subclasses. - module HTTPExceptions - def initialize(msg, res) #:nodoc: - super msg - @response = res + def D(msg) + return unless @debug_output + @debug_output << msg + @debug_output << "\n" end - attr_reader :response - alias data response #:nodoc: obsolete - end - class HTTPError < ProtocolError - include HTTPExceptions - end - class HTTPRetriableError < ProtoRetriableError - include HTTPExceptions - end - class HTTPServerException < ProtoServerError - # We cannot use the name "HTTPServerError", it is the name of the response. - include HTTPExceptions - end - class HTTPFatalError < ProtoFatalError - include HTTPExceptions - end - - - # HTTP response class. This class wraps response header and entity. - # Mixes in the HTTPHeader module, which provides access to response - # header values both via hash-like methods and individual readers. - # Note that each possible HTTP response code defines its own - # HTTPResponse subclass. These are listed below. - # All classes are - # defined under the Net module. Indentation indicates inheritance. - # - # xxx HTTPResponse - # - # 1xx HTTPInformation - # 100 HTTPContinue - # 101 HTTPSwitchProtocol - # - # 2xx HTTPSuccess - # 200 HTTPOK - # 201 HTTPCreated - # 202 HTTPAccepted - # 203 HTTPNonAuthoritativeInformation - # 204 HTTPNoContent - # 205 HTTPResetContent - # 206 HTTPPartialContent - # - # 3xx HTTPRedirection - # 300 HTTPMultipleChoice - # 301 HTTPMovedPermanently - # 302 HTTPFound - # 303 HTTPSeeOther - # 304 HTTPNotModified - # 305 HTTPUseProxy - # 307 HTTPTemporaryRedirect - # - # 4xx HTTPClientError - # 400 HTTPBadRequest - # 401 HTTPUnauthorized - # 402 HTTPPaymentRequired - # 403 HTTPForbidden - # 404 HTTPNotFound - # 405 HTTPMethodNotAllowed - # 406 HTTPNotAcceptable - # 407 HTTPProxyAuthenticationRequired - # 408 HTTPRequestTimeOut - # 409 HTTPConflict - # 410 HTTPGone - # 411 HTTPLengthRequired - # 412 HTTPPreconditionFailed - # 413 HTTPRequestEntityTooLarge - # 414 HTTPRequestURITooLong - # 415 HTTPUnsupportedMediaType - # 416 HTTPRequestedRangeNotSatisfiable - # 417 HTTPExpectationFailed - # - # 5xx HTTPServerError - # 500 HTTPInternalServerError - # 501 HTTPNotImplemented - # 502 HTTPBadGateway - # 503 HTTPServiceUnavailable - # 504 HTTPGatewayTimeOut - # 505 HTTPVersionNotSupported - # - # xxx HTTPUnknownResponse - # - class HTTPResponse - # true if the response has body. - def HTTPResponse.body_permitted? - self::HAS_BODY - end - - def HTTPResponse.exception_type # :nodoc: internal use only - self::EXCEPTION_TYPE - end - end # reopened after - - # :stopdoc: - - class HTTPUnknownResponse < HTTPResponse - HAS_BODY = true - EXCEPTION_TYPE = HTTPError - end - class HTTPInformation < HTTPResponse # 1xx - HAS_BODY = false - EXCEPTION_TYPE = HTTPError - end - class HTTPSuccess < HTTPResponse # 2xx - HAS_BODY = true - EXCEPTION_TYPE = HTTPError - end - class HTTPRedirection < HTTPResponse # 3xx - HAS_BODY = true - EXCEPTION_TYPE = HTTPRetriableError - end - class HTTPClientError < HTTPResponse # 4xx - HAS_BODY = true - EXCEPTION_TYPE = HTTPServerException # for backward compatibility - end - class HTTPServerError < HTTPResponse # 5xx - HAS_BODY = true - EXCEPTION_TYPE = HTTPFatalError # for backward compatibility - end - - class HTTPContinue < HTTPInformation # 100 - HAS_BODY = false - end - class HTTPSwitchProtocol < HTTPInformation # 101 - HAS_BODY = false - end - - class HTTPOK < HTTPSuccess # 200 - HAS_BODY = true - end - class HTTPCreated < HTTPSuccess # 201 - HAS_BODY = true - end - class HTTPAccepted < HTTPSuccess # 202 - HAS_BODY = true - end - class HTTPNonAuthoritativeInformation < HTTPSuccess # 203 - HAS_BODY = true - end - class HTTPNoContent < HTTPSuccess # 204 - HAS_BODY = false - end - class HTTPResetContent < HTTPSuccess # 205 - HAS_BODY = false - end - class HTTPPartialContent < HTTPSuccess # 206 - HAS_BODY = true end - class HTTPMultipleChoice < HTTPRedirection # 300 - HAS_BODY = true - end - class HTTPMovedPermanently < HTTPRedirection # 301 - HAS_BODY = true - end - class HTTPFound < HTTPRedirection # 302 - HAS_BODY = true - end - HTTPMovedTemporarily = HTTPFound - class HTTPSeeOther < HTTPRedirection # 303 - HAS_BODY = true - end - class HTTPNotModified < HTTPRedirection # 304 - HAS_BODY = false - end - class HTTPUseProxy < HTTPRedirection # 305 - HAS_BODY = false - end - # 306 unused - class HTTPTemporaryRedirect < HTTPRedirection # 307 - HAS_BODY = true - end +end - class HTTPBadRequest < HTTPClientError # 400 - HAS_BODY = true - end - class HTTPUnauthorized < HTTPClientError # 401 - HAS_BODY = true - end - class HTTPPaymentRequired < HTTPClientError # 402 - HAS_BODY = true - end - class HTTPForbidden < HTTPClientError # 403 - HAS_BODY = true - end - class HTTPNotFound < HTTPClientError # 404 - HAS_BODY = true - end - class HTTPMethodNotAllowed < HTTPClientError # 405 - HAS_BODY = true - end - class HTTPNotAcceptable < HTTPClientError # 406 - HAS_BODY = true - end - class HTTPProxyAuthenticationRequired < HTTPClientError # 407 - HAS_BODY = true - end - class HTTPRequestTimeOut < HTTPClientError # 408 - HAS_BODY = true - end - class HTTPConflict < HTTPClientError # 409 - HAS_BODY = true - end - class HTTPGone < HTTPClientError # 410 - HAS_BODY = true - end - class HTTPLengthRequired < HTTPClientError # 411 - HAS_BODY = true - end - class HTTPPreconditionFailed < HTTPClientError # 412 - HAS_BODY = true - end - class HTTPRequestEntityTooLarge < HTTPClientError # 413 - HAS_BODY = true - end - class HTTPRequestURITooLong < HTTPClientError # 414 - HAS_BODY = true - end - HTTPRequestURITooLarge = HTTPRequestURITooLong - class HTTPUnsupportedMediaType < HTTPClientError # 415 - HAS_BODY = true - end - class HTTPRequestedRangeNotSatisfiable < HTTPClientError # 416 - HAS_BODY = true - end - class HTTPExpectationFailed < HTTPClientError # 417 - HAS_BODY = true - end - - class HTTPInternalServerError < HTTPServerError # 500 - HAS_BODY = true - end - class HTTPNotImplemented < HTTPServerError # 501 - HAS_BODY = true - end - class HTTPBadGateway < HTTPServerError # 502 - HAS_BODY = true - end - class HTTPServiceUnavailable < HTTPServerError # 503 - HAS_BODY = true - end - class HTTPGatewayTimeOut < HTTPServerError # 504 - HAS_BODY = true - end - class HTTPVersionNotSupported < HTTPServerError # 505 - HAS_BODY = true - end - - # :startdoc: - - - class HTTPResponse # reopen - - CODE_CLASS_TO_OBJ = { - '1' => HTTPInformation, - '2' => HTTPSuccess, - '3' => HTTPRedirection, - '4' => HTTPClientError, - '5' => HTTPServerError - } - CODE_TO_OBJ = { - '100' => HTTPContinue, - '101' => HTTPSwitchProtocol, - - '200' => HTTPOK, - '201' => HTTPCreated, - '202' => HTTPAccepted, - '203' => HTTPNonAuthoritativeInformation, - '204' => HTTPNoContent, - '205' => HTTPResetContent, - '206' => HTTPPartialContent, - - '300' => HTTPMultipleChoice, - '301' => HTTPMovedPermanently, - '302' => HTTPFound, - '303' => HTTPSeeOther, - '304' => HTTPNotModified, - '305' => HTTPUseProxy, - '307' => HTTPTemporaryRedirect, - - '400' => HTTPBadRequest, - '401' => HTTPUnauthorized, - '402' => HTTPPaymentRequired, - '403' => HTTPForbidden, - '404' => HTTPNotFound, - '405' => HTTPMethodNotAllowed, - '406' => HTTPNotAcceptable, - '407' => HTTPProxyAuthenticationRequired, - '408' => HTTPRequestTimeOut, - '409' => HTTPConflict, - '410' => HTTPGone, - '411' => HTTPLengthRequired, - '412' => HTTPPreconditionFailed, - '413' => HTTPRequestEntityTooLarge, - '414' => HTTPRequestURITooLong, - '415' => HTTPUnsupportedMediaType, - '416' => HTTPRequestedRangeNotSatisfiable, - '417' => HTTPExpectationFailed, - - '500' => HTTPInternalServerError, - '501' => HTTPNotImplemented, - '502' => HTTPBadGateway, - '503' => HTTPServiceUnavailable, - '504' => HTTPGatewayTimeOut, - '505' => HTTPVersionNotSupported - } - - class << HTTPResponse - def read_new(sock) #:nodoc: internal use only - httpv, code, msg = read_status_line(sock) - res = response_class(code).new(httpv, code, msg) - each_response_header(sock) do |k,v| - res.add_field k, v - end - res - end - - private - - def read_status_line(sock) - str = sock.readline - m = /\AHTTP(?:\/(\d+\.\d+))?\s+(\d\d\d)\s*(.*)\z/in.match(str) or - raise HTTPBadResponse, "wrong status line: #{str.dump}" - m.captures - end - - def response_class(code) - CODE_TO_OBJ[code] or - CODE_CLASS_TO_OBJ[code[0,1]] or - HTTPUnknownResponse - end - - def each_response_header(sock) - while true - line = sock.readuntil("\n", true).sub(/\s+\z/, '') - break if line.empty? - m = /\A([^:]+):\s*/.match(line) or - raise HTTPBadResponse, 'wrong header line format' - yield m[1], m.post_match - end - end - end +require_relative 'http/exceptions' - # next is to fix bug in RDoc, where the private inside class << self - # spills out. - public +require_relative 'http/header' - include HTTPHeader +require_relative 'http/generic_request' +require_relative 'http/request' +require_relative 'http/requests' - def initialize(httpv, code, msg) #:nodoc: internal use only - @http_version = httpv - @code = code - @message = msg - initialize_http_header nil - @body = nil - @read = false - end - - # The HTTP version supported by the server. - attr_reader :http_version - - # HTTP result code string. For example, '302'. You can also - # determine the response type by which response subclass the - # response object is an instance of. - attr_reader :code - - # HTTP result message. For example, 'Not Found'. - attr_reader :message - alias msg message # :nodoc: obsolete - - def inspect - "#<#{self.class} #{@code} #{@message} readbody=#{@read}>" - end - - # For backward compatibility. - # To allow Net::HTTP 1.1 style assignment - # e.g. - # response, body = Net::HTTP.get(....) - # - def to_ary - warn "net/http.rb: warning: Net::HTTP v1.1 style assignment found at #{caller(1)[0]}; use `response = http.get(...)' instead." if $VERBOSE - res = self.dup - class << res - undef to_ary - end - [res, res.body] - end - - # - # response <-> exception relationship - # - - def code_type #:nodoc: - self.class - end - - def error! #:nodoc: - raise error_type().new(@code + ' ' + @message.dump, self) - end - - def error_type #:nodoc: - self.class::EXCEPTION_TYPE - end - - # Raises HTTP error if the response is not 2xx. - def value - error! unless self.kind_of?(HTTPSuccess) - end - - # - # header (for backward compatibility only; DO NOT USE) - # - - def response #:nodoc: - warn "#{caller(1)[0]}: warning: HTTPResponse#response is obsolete" if $VERBOSE - self - end - - def header #:nodoc: - warn "#{caller(1)[0]}: warning: HTTPResponse#header is obsolete" if $VERBOSE - self - end - - def read_header #:nodoc: - warn "#{caller(1)[0]}: warning: HTTPResponse#read_header is obsolete" if $VERBOSE - self - end - - # - # body - # - - def reading_body(sock, reqmethodallowbody) #:nodoc: internal use only - @socket = sock - @body_exist = reqmethodallowbody && self.class.body_permitted? - begin - yield - self.body # ensure to read body - ensure - @socket = nil - end - end - - # Gets entity body. If the block given, yields it to +block+. - # The body is provided in fragments, as it is read in from the socket. - # - # Calling this method a second or subsequent time will return the - # already read string. - # - # http.request_get('/index.html') {|res| - # puts res.read_body - # } - # - # http.request_get('/index.html') {|res| - # p res.read_body.object_id # 538149362 - # p res.read_body.object_id # 538149362 - # } - # - # # using iterator - # http.request_get('/index.html') {|res| - # res.read_body do |segment| - # print segment - # end - # } - # - def read_body(dest = nil, &block) - if @read - raise IOError, "#{self.class}\#read_body called twice" if dest or block - return @body - end - to = procdest(dest, block) - stream_check - if @body_exist - read_body_0 to - @body = to - else - @body = nil - end - @read = true - - @body - end - - # Returns the entity body. - # - # Calling this method a second or subsequent time will return the - # already read string. - # - # http.request_get('/index.html') {|res| - # puts res.body - # } - # - # http.request_get('/index.html') {|res| - # p res.body.object_id # 538149362 - # p res.body.object_id # 538149362 - # } - # - def body - read_body() - end - - alias entity body #:nodoc: obsolete - - private - - def read_body_0(dest) - if chunked? - read_chunked dest - return - end - clen = content_length() - if clen - @socket.read clen, dest, true # ignore EOF - return - end - clen = range_length() - if clen - @socket.read clen, dest - return - end - @socket.read_all dest - end - - def read_chunked(dest) - len = nil - total = 0 - while true - line = @socket.readline - hexlen = line.slice(/[0-9a-fA-F]+/) or - raise HTTPBadResponse, "wrong chunk size line: #{line}" - len = hexlen.hex - break if len == 0 - @socket.read len, dest; total += len - @socket.read 2 # \r\n - end - until @socket.readline.empty? - # none - end - end - - def stream_check - raise IOError, 'attempt to read body out of block' if @socket.closed? - end - - def procdest(dest, block) - raise ArgumentError, 'both arg and block given for HTTP method' \ - if dest and block - if block - ReadAdapter.new(block) - else - dest || '' - end - end - - end - - - # :enddoc: - - #-- - # for backward compatibility - class HTTP - ProxyMod = ProxyDelta - end - module NetPrivate - HTTPRequest = ::Net::HTTPRequest - end +require_relative 'http/response' +require_relative 'http/responses' - HTTPInformationCode = HTTPInformation - HTTPSuccessCode = HTTPSuccess - HTTPRedirectionCode = HTTPRedirection - HTTPRetriableCode = HTTPRedirection - HTTPClientErrorCode = HTTPClientError - HTTPFatalErrorCode = HTTPClientError - HTTPServerErrorCode = HTTPServerError - HTTPResponceReceiver = HTTPResponse +require_relative 'http/proxy_delta' -end # module Net +require_relative 'http/backward' diff --git a/lib/net/http/backward.rb b/lib/net/http/backward.rb new file mode 100644 index 0000000000..9e24eae32c --- /dev/null +++ b/lib/net/http/backward.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: false +# for backward compatibility + +# :enddoc: + +class Net::HTTP + ProxyMod = ProxyDelta +end + +module Net + HTTPSession = Net::HTTP +end + +module Net::NetPrivate + HTTPRequest = ::Net::HTTPRequest +end + +Net::HTTPInformationCode = Net::HTTPInformation +Net::HTTPSuccessCode = Net::HTTPSuccess +Net::HTTPRedirectionCode = Net::HTTPRedirection +Net::HTTPRetriableCode = Net::HTTPRedirection +Net::HTTPClientErrorCode = Net::HTTPClientError +Net::HTTPFatalErrorCode = Net::HTTPClientError +Net::HTTPServerErrorCode = Net::HTTPServerError +Net::HTTPResponceReceiver = Net::HTTPResponse + diff --git a/lib/net/http/exceptions.rb b/lib/net/http/exceptions.rb new file mode 100644 index 0000000000..0d34526616 --- /dev/null +++ b/lib/net/http/exceptions.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: false +# Net::HTTP exception class. +# You cannot use Net::HTTPExceptions directly; instead, you must use +# its subclasses. +module Net::HTTPExceptions + def initialize(msg, res) #:nodoc: + super msg + @response = res + end + attr_reader :response + alias data response #:nodoc: obsolete +end +class Net::HTTPError < Net::ProtocolError + include Net::HTTPExceptions +end +class Net::HTTPRetriableError < Net::ProtoRetriableError + include Net::HTTPExceptions +end +class Net::HTTPServerException < Net::ProtoServerError + # We cannot use the name "HTTPServerError", it is the name of the response. + include Net::HTTPExceptions +end +class Net::HTTPFatalError < Net::ProtoFatalError + include Net::HTTPExceptions +end + diff --git a/lib/net/http/generic_request.rb b/lib/net/http/generic_request.rb new file mode 100644 index 0000000000..526cc333fc --- /dev/null +++ b/lib/net/http/generic_request.rb @@ -0,0 +1,338 @@ +# frozen_string_literal: false +# HTTPGenericRequest is the parent of the HTTPRequest class. +# Do not use this directly; use a subclass of HTTPRequest. +# +# Mixes in the HTTPHeader module to provide easier access to HTTP headers. +# +class Net::HTTPGenericRequest + + include Net::HTTPHeader + + def initialize(m, reqbody, resbody, uri_or_path, initheader = nil) + @method = m + @request_has_body = reqbody + @response_has_body = resbody + + if URI === uri_or_path then + @uri = uri_or_path.dup + host = @uri.hostname.dup + host << ":".freeze << @uri.port.to_s if @uri.port != @uri.default_port + @path = uri_or_path.request_uri + raise ArgumentError, "no HTTP request path given" unless @path + else + @uri = nil + host = nil + raise ArgumentError, "no HTTP request path given" unless uri_or_path + raise ArgumentError, "HTTP request path is empty" if uri_or_path.empty? + @path = uri_or_path.dup + end + + @decode_content = false + + if @response_has_body and Net::HTTP::HAVE_ZLIB then + if !initheader || + !initheader.keys.any? { |k| + %w[accept-encoding range].include? k.downcase + } then + @decode_content = true + initheader = initheader ? initheader.dup : {} + initheader["accept-encoding"] = + "gzip;q=1.0,deflate;q=0.6,identity;q=0.3" + end + end + + initialize_http_header initheader + self['Accept'] ||= '*/*' + self['User-Agent'] ||= 'Ruby' + self['Host'] ||= host if host + @body = nil + @body_stream = nil + @body_data = nil + end + + attr_reader :method + attr_reader :path + attr_reader :uri + + # Automatically set to false if the user sets the Accept-Encoding header. + # This indicates they wish to handle Content-encoding in responses + # themselves. + attr_reader :decode_content + + def inspect + "\#<#{self.class} #{@method}>" + end + + ## + # Don't automatically decode response content-encoding if the user indicates + # they want to handle it. + + def []=(key, val) # :nodoc: + @decode_content = false if key.downcase == 'accept-encoding' + + super key, val + end + + def request_body_permitted? + @request_has_body + end + + def response_body_permitted? + @response_has_body + end + + def body_exist? + warn "Net::HTTPRequest#body_exist? is obsolete; use response_body_permitted?", uplevel: 1 if $VERBOSE + response_body_permitted? + end + + attr_reader :body + + def body=(str) + @body = str + @body_stream = nil + @body_data = nil + str + end + + attr_reader :body_stream + + def body_stream=(input) + @body = nil + @body_stream = input + @body_data = nil + input + end + + def set_body_internal(str) #:nodoc: internal use only + raise ArgumentError, "both of body argument and HTTPRequest#body set" if str and (@body or @body_stream) + self.body = str if str + if @body.nil? && @body_stream.nil? && @body_data.nil? && request_body_permitted? + self.body = '' + end + end + + # + # write + # + + def exec(sock, ver, path) #:nodoc: internal use only + if @body + send_request_with_body sock, ver, path, @body + elsif @body_stream + send_request_with_body_stream sock, ver, path, @body_stream + elsif @body_data + send_request_with_body_data sock, ver, path, @body_data + else + write_header sock, ver, path + end + end + + def update_uri(addr, port, ssl) # :nodoc: internal use only + # reflect the connection and @path to @uri + return unless @uri + + if ssl + scheme = 'https'.freeze + klass = URI::HTTPS + else + scheme = 'http'.freeze + klass = URI::HTTP + end + + if host = self['host'] + host.sub!(/:.*/s, ''.freeze) + elsif host = @uri.host + else + host = addr + end + # convert the class of the URI + if @uri.is_a?(klass) + @uri.host = host + @uri.port = port + else + @uri = klass.new( + scheme, @uri.userinfo, + host, port, nil, + @uri.path, nil, @uri.query, nil) + end + end + + private + + class Chunker #:nodoc: + def initialize(sock) + @sock = sock + @prev = nil + end + + def write(buf) + # avoid memcpy() of buf, buf can huge and eat memory bandwidth + @sock.write("#{buf.bytesize.to_s(16)}\r\n") + rv = @sock.write(buf) + @sock.write("\r\n") + rv + end + + def finish + @sock.write("0\r\n\r\n") + end + end + + def send_request_with_body(sock, ver, path, body) + self.content_length = body.bytesize + delete 'Transfer-Encoding' + supply_default_content_type + write_header sock, ver, path + wait_for_continue sock, ver if sock.continue_timeout + sock.write body + end + + def send_request_with_body_stream(sock, ver, path, f) + unless content_length() or chunked? + raise ArgumentError, + "Content-Length not given and Transfer-Encoding is not `chunked'" + end + supply_default_content_type + write_header sock, ver, path + wait_for_continue sock, ver if sock.continue_timeout + if chunked? + chunker = Chunker.new(sock) + IO.copy_stream(f, chunker) + chunker.finish + else + # copy_stream can sendfile() to sock.io unless we use SSL. + # If sock.io is an SSLSocket, copy_stream will hit SSL_write() + IO.copy_stream(f, sock.io) + end + end + + def send_request_with_body_data(sock, ver, path, params) + if /\Amultipart\/form-data\z/i !~ self.content_type + self.content_type = 'application/x-www-form-urlencoded' + return send_request_with_body(sock, ver, path, URI.encode_www_form(params)) + end + + opt = @form_option.dup + require 'securerandom' unless defined?(SecureRandom) + opt[:boundary] ||= SecureRandom.urlsafe_base64(40) + self.set_content_type(self.content_type, boundary: opt[:boundary]) + if chunked? + write_header sock, ver, path + encode_multipart_form_data(sock, params, opt) + else + require 'tempfile' + file = Tempfile.new('multipart') + file.binmode + encode_multipart_form_data(file, params, opt) + file.rewind + self.content_length = file.size + write_header sock, ver, path + IO.copy_stream(file, sock) + file.close(true) + end + end + + def encode_multipart_form_data(out, params, opt) + charset = opt[:charset] + boundary = opt[:boundary] + require 'securerandom' unless defined?(SecureRandom) + boundary ||= SecureRandom.urlsafe_base64(40) + chunked_p = chunked? + + buf = '' + params.each do |key, value, h={}| + key = quote_string(key, charset) + filename = + h.key?(:filename) ? h[:filename] : + value.respond_to?(:to_path) ? File.basename(value.to_path) : + nil + + buf << "--#{boundary}\r\n" + if filename + filename = quote_string(filename, charset) + type = h[:content_type] || 'application/octet-stream' + buf << "Content-Disposition: form-data; " \ + "name=\"#{key}\"; filename=\"#{filename}\"\r\n" \ + "Content-Type: #{type}\r\n\r\n" + if !out.respond_to?(:write) || !value.respond_to?(:read) + # if +out+ is not an IO or +value+ is not an IO + buf << (value.respond_to?(:read) ? value.read : value) + elsif value.respond_to?(:size) && chunked_p + # if +out+ is an IO and +value+ is a File, use IO.copy_stream + flush_buffer(out, buf, chunked_p) + out << "%x\r\n" % value.size if chunked_p + IO.copy_stream(value, out) + out << "\r\n" if chunked_p + else + # +out+ is an IO, and +value+ is not a File but an IO + flush_buffer(out, buf, chunked_p) + 1 while flush_buffer(out, value.read(4096), chunked_p) + end + else + # non-file field: + # HTML5 says, "The parts of the generated multipart/form-data + # resource that correspond to non-file fields must not have a + # Content-Type header specified." + buf << "Content-Disposition: form-data; name=\"#{key}\"\r\n\r\n" + buf << (value.respond_to?(:read) ? value.read : value) + end + buf << "\r\n" + end + buf << "--#{boundary}--\r\n" + flush_buffer(out, buf, chunked_p) + out << "0\r\n\r\n" if chunked_p + end + + def quote_string(str, charset) + str = str.encode(charset, fallback:->(c){'&#%d;'%c.encode("UTF-8").ord}) if charset + str.gsub(/[\\"]/, '\\\\\&') + end + + def flush_buffer(out, buf, chunked_p) + return unless buf + out << "%x\r\n"%buf.bytesize if chunked_p + out << buf + out << "\r\n" if chunked_p + buf.clear + end + + def supply_default_content_type + return if content_type() + warn 'net/http: Content-Type did not set; using application/x-www-form-urlencoded', uplevel: 1 if $VERBOSE + set_content_type 'application/x-www-form-urlencoded' + end + + ## + # Waits up to the continue timeout for a response from the server provided + # we're speaking HTTP 1.1 and are expecting a 100-continue response. + + def wait_for_continue(sock, ver) + if ver >= '1.1' and @header['expect'] and + @header['expect'].include?('100-continue') + if sock.io.to_io.wait_readable(sock.continue_timeout) + res = Net::HTTPResponse.read_new(sock) + unless res.kind_of?(Net::HTTPContinue) + res.decode_content = @decode_content + throw :response, res + end + end + end + end + + def write_header(sock, ver, path) + reqline = "#{@method} #{path} HTTP/#{ver}" + if /[\r\n]/ =~ reqline + raise ArgumentError, "A Request-Line must not contain CR or LF" + end + buf = "" + buf << reqline << "\r\n" + each_capitalized do |k,v| + buf << "#{k}: #{v}\r\n" + end + buf << "\r\n" + sock.write buf + end + +end + diff --git a/lib/net/http/header.rb b/lib/net/http/header.rb new file mode 100644 index 0000000000..96d898c89f --- /dev/null +++ b/lib/net/http/header.rb @@ -0,0 +1,494 @@ +# frozen_string_literal: false +# The HTTPHeader module defines methods for reading and writing +# HTTP headers. +# +# It is used as a mixin by other classes, to provide hash-like +# access to HTTP header values. Unlike raw hash access, HTTPHeader +# provides access via case-insensitive keys. It also provides +# methods for accessing commonly-used HTTP header values in more +# convenient formats. +# +module Net::HTTPHeader + + def initialize_http_header(initheader) + @header = {} + return unless initheader + initheader.each do |key, value| + warn "net/http: duplicated HTTP header: #{key}", uplevel: 1 if key?(key) and $VERBOSE + if value.nil? + warn "net/http: nil HTTP header: #{key}", uplevel: 1 if $VERBOSE + else + value = value.strip # raise error for invalid byte sequences + if value.count("\r\n") > 0 + raise ArgumentError, 'header field value cannot include CR/LF' + end + @header[key.downcase] = [value] + end + end + end + + def size #:nodoc: obsolete + @header.size + end + + alias length size #:nodoc: obsolete + + # Returns the header field corresponding to the case-insensitive key. + # For example, a key of "Content-Type" might return "text/html" + def [](key) + a = @header[key.downcase] or return nil + a.join(', ') + end + + # Sets the header field corresponding to the case-insensitive key. + def []=(key, val) + unless val + @header.delete key.downcase + return val + end + set_field(key, val) + end + + # [Ruby 1.8.3] + # Adds a value to a named header field, instead of replacing its value. + # Second argument +val+ must be a String. + # See also #[]=, #[] and #get_fields. + # + # request.add_field 'X-My-Header', 'a' + # p request['X-My-Header'] #=> "a" + # p request.get_fields('X-My-Header') #=> ["a"] + # request.add_field 'X-My-Header', 'b' + # p request['X-My-Header'] #=> "a, b" + # p request.get_fields('X-My-Header') #=> ["a", "b"] + # request.add_field 'X-My-Header', 'c' + # p request['X-My-Header'] #=> "a, b, c" + # p request.get_fields('X-My-Header') #=> ["a", "b", "c"] + # + def add_field(key, val) + if @header.key?(key.downcase) + append_field_value(@header[key.downcase], val) + else + set_field(key, val) + end + end + + private def set_field(key, val) + case val + when Enumerable + ary = [] + append_field_value(ary, val) + @header[key.downcase] = ary + else + val = val.to_s # for compatibility use to_s instead of to_str + if val.b.count("\r\n") > 0 + raise ArgumentError, 'header field value cannot include CR/LF' + end + @header[key.downcase] = [val] + end + end + + private def append_field_value(ary, val) + case val + when Enumerable + val.each{|x| append_field_value(ary, x)} + else + val = val.to_s + if /[\r\n]/n.match?(val.b) + raise ArgumentError, 'header field value cannot include CR/LF' + end + ary.push val + end + end + + # [Ruby 1.8.3] + # Returns an array of header field strings corresponding to the + # case-insensitive +key+. This method allows you to get duplicated + # header fields without any processing. See also #[]. + # + # p response.get_fields('Set-Cookie') + # #=> ["session=al98axx; expires=Fri, 31-Dec-1999 23:58:23", + # "query=rubyscript; expires=Fri, 31-Dec-1999 23:58:23"] + # p response['Set-Cookie'] + # #=> "session=al98axx; expires=Fri, 31-Dec-1999 23:58:23, query=rubyscript; expires=Fri, 31-Dec-1999 23:58:23" + # + def get_fields(key) + return nil unless @header[key.downcase] + @header[key.downcase].dup + end + + # Returns the header field corresponding to the case-insensitive key. + # Returns the default value +args+, or the result of the block, or + # raises an IndexError if there's no header field named +key+ + # See Hash#fetch + def fetch(key, *args, &block) #:yield: +key+ + a = @header.fetch(key.downcase, *args, &block) + a.kind_of?(Array) ? a.join(', ') : a + end + + # Iterates through the header names and values, passing in the name + # and value to the code block supplied. + # + # Returns an enumerator if no block is given. + # + # Example: + # + # response.header.each_header {|key,value| puts "#{key} = #{value}" } + # + def each_header #:yield: +key+, +value+ + block_given? or return enum_for(__method__) { @header.size } + @header.each do |k,va| + yield k, va.join(', ') + end + end + + alias each each_header + + # Iterates through the header names in the header, passing + # each header name to the code block. + # + # Returns an enumerator if no block is given. + def each_name(&block) #:yield: +key+ + block_given? or return enum_for(__method__) { @header.size } + @header.each_key(&block) + end + + alias each_key each_name + + # Iterates through the header names in the header, passing + # capitalized header names to the code block. + # + # Note that header names are capitalized systematically; + # capitalization may not match that used by the remote HTTP + # server in its response. + # + # Returns an enumerator if no block is given. + def each_capitalized_name #:yield: +key+ + block_given? or return enum_for(__method__) { @header.size } + @header.each_key do |k| + yield capitalize(k) + end + end + + # Iterates through header values, passing each value to the + # code block. + # + # Returns an enumerator if no block is given. + def each_value #:yield: +value+ + block_given? or return enum_for(__method__) { @header.size } + @header.each_value do |va| + yield va.join(', ') + end + end + + # Removes a header field, specified by case-insensitive key. + def delete(key) + @header.delete(key.downcase) + end + + # true if +key+ header exists. + def key?(key) + @header.key?(key.downcase) + end + + # Returns a Hash consisting of header names and array of values. + # e.g. + # {"cache-control" => ["private"], + # "content-type" => ["text/html"], + # "date" => ["Wed, 22 Jun 2005 22:11:50 GMT"]} + def to_hash + @header.dup + end + + # As for #each_header, except the keys are provided in capitalized form. + # + # Note that header names are capitalized systematically; + # capitalization may not match that used by the remote HTTP + # server in its response. + # + # Returns an enumerator if no block is given. + def each_capitalized + block_given? or return enum_for(__method__) { @header.size } + @header.each do |k,v| + yield capitalize(k), v.join(', ') + end + end + + alias canonical_each each_capitalized + + def capitalize(name) + name.to_s.split(/-/).map {|s| s.capitalize }.join('-') + end + private :capitalize + + # Returns an Array of Range objects which represent the Range: + # HTTP header field, or +nil+ if there is no such header. + def range + return nil unless @header['range'] + + value = self['Range'] + # byte-range-set = *( "," OWS ) ( byte-range-spec / suffix-byte-range-spec ) + # *( OWS "," [ OWS ( byte-range-spec / suffix-byte-range-spec ) ] ) + # corrected collected ABNF + # http://tools.ietf.org/html/draft-ietf-httpbis-p5-range-19#section-5.4.1 + # http://tools.ietf.org/html/draft-ietf-httpbis-p5-range-19#appendix-C + # http://tools.ietf.org/html/draft-ietf-httpbis-p1-messaging-19#section-3.2.5 + unless /\Abytes=((?:,[ \t]*)*(?:\d+-\d*|-\d+)(?:[ \t]*,(?:[ \t]*\d+-\d*|-\d+)?)*)\z/ =~ value + raise Net::HTTPHeaderSyntaxError, "invalid syntax for byte-ranges-specifier: '#{value}'" + end + + byte_range_set = $1 + result = byte_range_set.split(/,/).map {|spec| + m = /(\d+)?\s*-\s*(\d+)?/i.match(spec) or + raise Net::HTTPHeaderSyntaxError, "invalid byte-range-spec: '#{spec}'" + d1 = m[1].to_i + d2 = m[2].to_i + if m[1] and m[2] + if d1 > d2 + raise Net::HTTPHeaderSyntaxError, "last-byte-pos MUST greater than or equal to first-byte-pos but '#{spec}'" + end + d1..d2 + elsif m[1] + d1..-1 + elsif m[2] + -d2..-1 + else + raise Net::HTTPHeaderSyntaxError, 'range is not specified' + end + } + # if result.empty? + # byte-range-set must include at least one byte-range-spec or suffix-byte-range-spec + # but above regexp already denies it. + if result.size == 1 && result[0].begin == 0 && result[0].end == -1 + raise Net::HTTPHeaderSyntaxError, 'only one suffix-byte-range-spec with zero suffix-length' + end + result + end + + # Sets the HTTP Range: header. + # Accepts either a Range object as a single argument, + # or a beginning index and a length from that index. + # Example: + # + # req.range = (0..1023) + # req.set_range 0, 1023 + # + def set_range(r, e = nil) + unless r + @header.delete 'range' + return r + end + r = (r...r+e) if e + case r + when Numeric + n = r.to_i + rangestr = (n > 0 ? "0-#{n-1}" : "-#{-n}") + when Range + first = r.first + last = r.end + last -= 1 if r.exclude_end? + if last == -1 + rangestr = (first > 0 ? "#{first}-" : "-#{-first}") + else + raise Net::HTTPHeaderSyntaxError, 'range.first is negative' if first < 0 + raise Net::HTTPHeaderSyntaxError, 'range.last is negative' if last < 0 + raise Net::HTTPHeaderSyntaxError, 'must be .first < .last' if first > last + rangestr = "#{first}-#{last}" + end + else + raise TypeError, 'Range/Integer is required' + end + @header['range'] = ["bytes=#{rangestr}"] + r + end + + alias range= set_range + + # Returns an Integer object which represents the HTTP Content-Length: + # header field, or +nil+ if that field was not provided. + def content_length + return nil unless key?('Content-Length') + len = self['Content-Length'].slice(/\d+/) or + raise Net::HTTPHeaderSyntaxError, 'wrong Content-Length format' + len.to_i + end + + def content_length=(len) + unless len + @header.delete 'content-length' + return nil + end + @header['content-length'] = [len.to_i.to_s] + end + + # Returns "true" if the "transfer-encoding" header is present and + # set to "chunked". This is an HTTP/1.1 feature, allowing the + # the content to be sent in "chunks" without at the outset + # stating the entire content length. + def chunked? + return false unless @header['transfer-encoding'] + field = self['Transfer-Encoding'] + (/(?:\A|[^\-\w])chunked(?![\-\w])/i =~ field) ? true : false + end + + # Returns a Range object which represents the value of the Content-Range: + # header field. + # For a partial entity body, this indicates where this fragment + # fits inside the full entity body, as range of byte offsets. + def content_range + return nil unless @header['content-range'] + m = %r<bytes\s+(\d+)-(\d+)/(\d+|\*)>i.match(self['Content-Range']) or + raise Net::HTTPHeaderSyntaxError, 'wrong Content-Range format' + m[1].to_i .. m[2].to_i + end + + # The length of the range represented in Content-Range: header. + def range_length + r = content_range() or return nil + r.end - r.begin + 1 + end + + # Returns a content type string such as "text/html". + # This method returns nil if Content-Type: header field does not exist. + def content_type + return nil unless main_type() + if sub_type() + then "#{main_type()}/#{sub_type()}" + else main_type() + end + end + + # Returns a content type string such as "text". + # This method returns nil if Content-Type: header field does not exist. + def main_type + return nil unless @header['content-type'] + self['Content-Type'].split(';').first.to_s.split('/')[0].to_s.strip + end + + # Returns a content type string such as "html". + # This method returns nil if Content-Type: header field does not exist + # or sub-type is not given (e.g. "Content-Type: text"). + def sub_type + return nil unless @header['content-type'] + _, sub = *self['Content-Type'].split(';').first.to_s.split('/') + return nil unless sub + sub.strip + end + + # Any parameters specified for the content type, returned as a Hash. + # For example, a header of Content-Type: text/html; charset=EUC-JP + # would result in type_params returning {'charset' => 'EUC-JP'} + def type_params + result = {} + list = self['Content-Type'].to_s.split(';') + list.shift + list.each do |param| + k, v = *param.split('=', 2) + result[k.strip] = v.strip + end + result + end + + # Sets the content type in an HTTP header. + # The +type+ should be a full HTTP content type, e.g. "text/html". + # The +params+ are an optional Hash of parameters to add after the + # content type, e.g. {'charset' => 'iso-8859-1'} + def set_content_type(type, params = {}) + @header['content-type'] = [type + params.map{|k,v|"; #{k}=#{v}"}.join('')] + end + + alias content_type= set_content_type + + # Set header fields and a body from HTML form data. + # +params+ should be an Array of Arrays or + # a Hash containing HTML form data. + # Optional argument +sep+ means data record separator. + # + # Values are URL encoded as necessary and the content-type is set to + # application/x-www-form-urlencoded + # + # Example: + # http.form_data = {"q" => "ruby", "lang" => "en"} + # http.form_data = {"q" => ["ruby", "perl"], "lang" => "en"} + # http.set_form_data({"q" => "ruby", "lang" => "en"}, ';') + # + def set_form_data(params, sep = '&') + query = URI.encode_www_form(params) + query.gsub!(/&/, sep) if sep != '&' + self.body = query + self.content_type = 'application/x-www-form-urlencoded' + end + + alias form_data= set_form_data + + # Set an HTML form data set. + # +params+ is the form data set; it is an Array of Arrays or a Hash + # +enctype is the type to encode the form data set. + # It is application/x-www-form-urlencoded or multipart/form-data. + # +formopt+ is an optional hash to specify the detail. + # + # boundary:: the boundary of the multipart message + # charset:: the charset of the message. All names and the values of + # non-file fields are encoded as the charset. + # + # Each item of params is an array and contains following items: + # +name+:: the name of the field + # +value+:: the value of the field, it should be a String or a File + # +opt+:: an optional hash to specify additional information + # + # Each item is a file field or a normal field. + # If +value+ is a File object or the +opt+ have a filename key, + # the item is treated as a file field. + # + # If Transfer-Encoding is set as chunked, this send the request in + # chunked encoding. Because chunked encoding is HTTP/1.1 feature, + # you must confirm the server to support HTTP/1.1 before sending it. + # + # Example: + # http.set_form([["q", "ruby"], ["lang", "en"]]) + # + # See also RFC 2388, RFC 2616, HTML 4.01, and HTML5 + # + def set_form(params, enctype='application/x-www-form-urlencoded', formopt={}) + @body_data = params + @body = nil + @body_stream = nil + @form_option = formopt + case enctype + when /\Aapplication\/x-www-form-urlencoded\z/i, + /\Amultipart\/form-data\z/i + self.content_type = enctype + else + raise ArgumentError, "invalid enctype: #{enctype}" + end + end + + # Set the Authorization: header for "Basic" authorization. + def basic_auth(account, password) + @header['authorization'] = [basic_encode(account, password)] + end + + # Set Proxy-Authorization: header for "Basic" authorization. + def proxy_basic_auth(account, password) + @header['proxy-authorization'] = [basic_encode(account, password)] + end + + def basic_encode(account, password) + 'Basic ' + ["#{account}:#{password}"].pack('m0') + end + private :basic_encode + + def connection_close? + token = /(?:\A|,)\s*close\s*(?:\z|,)/i + @header['connection']&.grep(token) {return true} + @header['proxy-connection']&.grep(token) {return true} + false + end + + def connection_keep_alive? + token = /(?:\A|,)\s*keep-alive\s*(?:\z|,)/i + @header['connection']&.grep(token) {return true} + @header['proxy-connection']&.grep(token) {return true} + false + end + +end diff --git a/lib/net/http/proxy_delta.rb b/lib/net/http/proxy_delta.rb new file mode 100644 index 0000000000..a2f770ebdb --- /dev/null +++ b/lib/net/http/proxy_delta.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: false +module Net::HTTP::ProxyDelta #:nodoc: internal use only + private + + def conn_address + proxy_address() + end + + def conn_port + proxy_port() + end + + def edit_path(path) + use_ssl? ? path : "http://#{addr_port()}#{path}" + end +end + diff --git a/lib/net/http/request.rb b/lib/net/http/request.rb new file mode 100644 index 0000000000..1e86f3e4b4 --- /dev/null +++ b/lib/net/http/request.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: false +# HTTP request class. +# This class wraps together the request header and the request path. +# You cannot use this class directly. Instead, you should use one of its +# subclasses: Net::HTTP::Get, Net::HTTP::Post, Net::HTTP::Head. +# +class Net::HTTPRequest < Net::HTTPGenericRequest + # Creates an HTTP request object for +path+. + # + # +initheader+ are the default headers to use. Net::HTTP adds + # Accept-Encoding to enable compression of the response body unless + # Accept-Encoding or Range are supplied in +initheader+. + + def initialize(path, initheader = nil) + super self.class::METHOD, + self.class::REQUEST_HAS_BODY, + self.class::RESPONSE_HAS_BODY, + path, initheader + end +end + diff --git a/lib/net/http/requests.rb b/lib/net/http/requests.rb new file mode 100644 index 0000000000..d4c80a3812 --- /dev/null +++ b/lib/net/http/requests.rb @@ -0,0 +1,123 @@ +# frozen_string_literal: false +# +# HTTP/1.1 methods --- RFC2616 +# + +# See Net::HTTPGenericRequest for attributes and methods. +# See Net::HTTP for usage examples. +class Net::HTTP::Get < Net::HTTPRequest + METHOD = 'GET' + REQUEST_HAS_BODY = false + RESPONSE_HAS_BODY = true +end + +# See Net::HTTPGenericRequest for attributes and methods. +# See Net::HTTP for usage examples. +class Net::HTTP::Head < Net::HTTPRequest + METHOD = 'HEAD' + REQUEST_HAS_BODY = false + RESPONSE_HAS_BODY = false +end + +# See Net::HTTPGenericRequest for attributes and methods. +# See Net::HTTP for usage examples. +class Net::HTTP::Post < Net::HTTPRequest + METHOD = 'POST' + REQUEST_HAS_BODY = true + RESPONSE_HAS_BODY = true +end + +# See Net::HTTPGenericRequest for attributes and methods. +# See Net::HTTP for usage examples. +class Net::HTTP::Put < Net::HTTPRequest + METHOD = 'PUT' + REQUEST_HAS_BODY = true + RESPONSE_HAS_BODY = true +end + +# See Net::HTTPGenericRequest for attributes and methods. +# See Net::HTTP for usage examples. +class Net::HTTP::Delete < Net::HTTPRequest + METHOD = 'DELETE' + REQUEST_HAS_BODY = false + RESPONSE_HAS_BODY = true +end + +# See Net::HTTPGenericRequest for attributes and methods. +class Net::HTTP::Options < Net::HTTPRequest + METHOD = 'OPTIONS' + REQUEST_HAS_BODY = false + RESPONSE_HAS_BODY = true +end + +# See Net::HTTPGenericRequest for attributes and methods. +class Net::HTTP::Trace < Net::HTTPRequest + METHOD = 'TRACE' + REQUEST_HAS_BODY = false + RESPONSE_HAS_BODY = true +end + +# +# PATCH method --- RFC5789 +# + +# See Net::HTTPGenericRequest for attributes and methods. +class Net::HTTP::Patch < Net::HTTPRequest + METHOD = 'PATCH' + REQUEST_HAS_BODY = true + RESPONSE_HAS_BODY = true +end + +# +# WebDAV methods --- RFC2518 +# + +# See Net::HTTPGenericRequest for attributes and methods. +class Net::HTTP::Propfind < Net::HTTPRequest + METHOD = 'PROPFIND' + REQUEST_HAS_BODY = true + RESPONSE_HAS_BODY = true +end + +# See Net::HTTPGenericRequest for attributes and methods. +class Net::HTTP::Proppatch < Net::HTTPRequest + METHOD = 'PROPPATCH' + REQUEST_HAS_BODY = true + RESPONSE_HAS_BODY = true +end + +# See Net::HTTPGenericRequest for attributes and methods. +class Net::HTTP::Mkcol < Net::HTTPRequest + METHOD = 'MKCOL' + REQUEST_HAS_BODY = true + RESPONSE_HAS_BODY = true +end + +# See Net::HTTPGenericRequest for attributes and methods. +class Net::HTTP::Copy < Net::HTTPRequest + METHOD = 'COPY' + REQUEST_HAS_BODY = false + RESPONSE_HAS_BODY = true +end + +# See Net::HTTPGenericRequest for attributes and methods. +class Net::HTTP::Move < Net::HTTPRequest + METHOD = 'MOVE' + REQUEST_HAS_BODY = false + RESPONSE_HAS_BODY = true +end + +# See Net::HTTPGenericRequest for attributes and methods. +class Net::HTTP::Lock < Net::HTTPRequest + METHOD = 'LOCK' + REQUEST_HAS_BODY = true + RESPONSE_HAS_BODY = true +end + +# See Net::HTTPGenericRequest for attributes and methods. +class Net::HTTP::Unlock < Net::HTTPRequest + METHOD = 'UNLOCK' + REQUEST_HAS_BODY = true + RESPONSE_HAS_BODY = true +end + diff --git a/lib/net/http/response.rb b/lib/net/http/response.rb new file mode 100644 index 0000000000..6a78272ac8 --- /dev/null +++ b/lib/net/http/response.rb @@ -0,0 +1,419 @@ +# frozen_string_literal: false +# HTTP response class. +# +# This class wraps together the response header and the response body (the +# entity requested). +# +# It mixes in the HTTPHeader module, which provides access to response +# header values both via hash-like methods and via individual readers. +# +# Note that each possible HTTP response code defines its own +# HTTPResponse subclass. These are listed below. +# +# All classes are defined under the Net module. Indentation indicates +# inheritance. For a list of the classes see Net::HTTP. +# +# +class Net::HTTPResponse + class << self + # true if the response has a body. + def body_permitted? + self::HAS_BODY + end + + def exception_type # :nodoc: internal use only + self::EXCEPTION_TYPE + end + + def read_new(sock) #:nodoc: internal use only + httpv, code, msg = read_status_line(sock) + res = response_class(code).new(httpv, code, msg) + each_response_header(sock) do |k,v| + res.add_field k, v + end + res + end + + private + + def read_status_line(sock) + str = sock.readline + m = /\AHTTP(?:\/(\d+\.\d+))?\s+(\d\d\d)(?:\s+(.*))?\z/in.match(str) or + raise Net::HTTPBadResponse, "wrong status line: #{str.dump}" + m.captures + end + + def response_class(code) + CODE_TO_OBJ[code] or + CODE_CLASS_TO_OBJ[code[0,1]] or + Net::HTTPUnknownResponse + end + + def each_response_header(sock) + key = value = nil + while true + line = sock.readuntil("\n", true).sub(/\s+\z/, '') + break if line.empty? + if line[0] == ?\s or line[0] == ?\t and value + value << ' ' unless value.empty? + value << line.strip + else + yield key, value if key + key, value = line.strip.split(/\s*:\s*/, 2) + raise Net::HTTPBadResponse, 'wrong header line format' if value.nil? + end + end + yield key, value if key + end + end + + # next is to fix bug in RDoc, where the private inside class << self + # spills out. + public + + include Net::HTTPHeader + + def initialize(httpv, code, msg) #:nodoc: internal use only + @http_version = httpv + @code = code + @message = msg + initialize_http_header nil + @body = nil + @read = false + @uri = nil + @decode_content = false + end + + # The HTTP version supported by the server. + attr_reader :http_version + + # The HTTP result code string. For example, '302'. You can also + # determine the response type by examining which response subclass + # the response object is an instance of. + attr_reader :code + + # The HTTP result message sent by the server. For example, 'Not Found'. + attr_reader :message + alias msg message # :nodoc: obsolete + + # The URI used to fetch this response. The response URI is only available + # if a URI was used to create the request. + attr_reader :uri + + # Set to true automatically when the request did not contain an + # Accept-Encoding header from the user. + attr_accessor :decode_content + + def inspect + "#<#{self.class} #{@code} #{@message} readbody=#{@read}>" + end + + # + # response <-> exception relationship + # + + def code_type #:nodoc: + self.class + end + + def error! #:nodoc: + message = @code + message += ' ' + @message.dump if @message + raise error_type().new(message, self) + end + + def error_type #:nodoc: + self.class::EXCEPTION_TYPE + end + + # Raises an HTTP error if the response is not 2xx (success). + def value + error! unless self.kind_of?(Net::HTTPSuccess) + end + + def uri= uri # :nodoc: + @uri = uri.dup if uri + end + + # + # header (for backward compatibility only; DO NOT USE) + # + + def response #:nodoc: + warn "Net::HTTPResponse#response is obsolete", uplevel: 1 if $VERBOSE + self + end + + def header #:nodoc: + warn "Net::HTTPResponse#header is obsolete", uplevel: 1 if $VERBOSE + self + end + + def read_header #:nodoc: + warn "Net::HTTPResponse#read_header is obsolete", uplevel: 1 if $VERBOSE + self + end + + # + # body + # + + def reading_body(sock, reqmethodallowbody) #:nodoc: internal use only + @socket = sock + @body_exist = reqmethodallowbody && self.class.body_permitted? + begin + yield + self.body # ensure to read body + ensure + @socket = nil + end + end + + # Gets the entity body returned by the remote HTTP server. + # + # If a block is given, the body is passed to the block, and + # the body is provided in fragments, as it is read in from the socket. + # + # Calling this method a second or subsequent time for the same + # HTTPResponse object will return the value already read. + # + # http.request_get('/index.html') {|res| + # puts res.read_body + # } + # + # http.request_get('/index.html') {|res| + # p res.read_body.object_id # 538149362 + # p res.read_body.object_id # 538149362 + # } + # + # # using iterator + # http.request_get('/index.html') {|res| + # res.read_body do |segment| + # print segment + # end + # } + # + def read_body(dest = nil, &block) + if @read + raise IOError, "#{self.class}\#read_body called twice" if dest or block + return @body + end + to = procdest(dest, block) + stream_check + if @body_exist + read_body_0 to + @body = to + else + @body = nil + end + @read = true + + @body + end + + # Returns the full entity body. + # + # Calling this method a second or subsequent time will return the + # string already read. + # + # http.request_get('/index.html') {|res| + # puts res.body + # } + # + # http.request_get('/index.html') {|res| + # p res.body.object_id # 538149362 + # p res.body.object_id # 538149362 + # } + # + def body + read_body() + end + + # Because it may be necessary to modify the body, Eg, decompression + # this method facilitates that. + def body=(value) + @body = value + end + + alias entity body #:nodoc: obsolete + + private + + ## + # Checks for a supported Content-Encoding header and yields an Inflate + # wrapper for this response's socket when zlib is present. If the + # Content-Encoding is not supported or zlib is missing, the plain socket is + # yielded. + # + # If a Content-Range header is present, a plain socket is yielded as the + # bytes in the range may not be a complete deflate block. + + def inflater # :nodoc: + return yield @socket unless Net::HTTP::HAVE_ZLIB + return yield @socket unless @decode_content + return yield @socket if self['content-range'] + + v = self['content-encoding'] + case v&.downcase + when 'deflate', 'gzip', 'x-gzip' then + self.delete 'content-encoding' + + inflate_body_io = Inflater.new(@socket) + + begin + yield inflate_body_io + ensure + orig_err = $! + begin + inflate_body_io.finish + rescue => err + raise orig_err || err + end + end + when 'none', 'identity' then + self.delete 'content-encoding' + + yield @socket + else + yield @socket + end + end + + def read_body_0(dest) + inflater do |inflate_body_io| + if chunked? + read_chunked dest, inflate_body_io + return + end + + @socket = inflate_body_io + + clen = content_length() + if clen + @socket.read clen, dest, true # ignore EOF + return + end + clen = range_length() + if clen + @socket.read clen, dest + return + end + @socket.read_all dest + end + end + + ## + # read_chunked reads from +@socket+ for chunk-size, chunk-extension, CRLF, + # etc. and +chunk_data_io+ for chunk-data which may be deflate or gzip + # encoded. + # + # See RFC 2616 section 3.6.1 for definitions + + def read_chunked(dest, chunk_data_io) # :nodoc: + total = 0 + while true + line = @socket.readline + hexlen = line.slice(/[0-9a-fA-F]+/) or + raise Net::HTTPBadResponse, "wrong chunk size line: #{line}" + len = hexlen.hex + break if len == 0 + begin + chunk_data_io.read len, dest + ensure + total += len + @socket.read 2 # \r\n + end + end + until @socket.readline.empty? + # none + end + end + + def stream_check + raise IOError, 'attempt to read body out of block' if @socket.closed? + end + + def procdest(dest, block) + raise ArgumentError, 'both arg and block given for HTTP method' if + dest and block + if block + Net::ReadAdapter.new(block) + else + dest || '' + end + end + + ## + # Inflater is a wrapper around Net::BufferedIO that transparently inflates + # zlib and gzip streams. + + class Inflater # :nodoc: + + ## + # Creates a new Inflater wrapping +socket+ + + def initialize socket + @socket = socket + # zlib with automatic gzip detection + @inflate = Zlib::Inflate.new(32 + Zlib::MAX_WBITS) + end + + ## + # Finishes the inflate stream. + + def finish + return if @inflate.total_in == 0 + @inflate.finish + end + + ## + # Returns a Net::ReadAdapter that inflates each read chunk into +dest+. + # + # This allows a large response body to be inflated without storing the + # entire body in memory. + + def inflate_adapter(dest) + if dest.respond_to?(:set_encoding) + dest.set_encoding(Encoding::ASCII_8BIT) + elsif dest.respond_to?(:force_encoding) + dest.force_encoding(Encoding::ASCII_8BIT) + end + block = proc do |compressed_chunk| + @inflate.inflate(compressed_chunk) do |chunk| + dest << chunk + end + end + + Net::ReadAdapter.new(block) + end + + ## + # Reads +clen+ bytes from the socket, inflates them, then writes them to + # +dest+. +ignore_eof+ is passed down to Net::BufferedIO#read + # + # Unlike Net::BufferedIO#read, this method returns more than +clen+ bytes. + # At this time there is no way for a user of Net::HTTPResponse to read a + # specific number of bytes from the HTTP response body, so this internal + # API does not return the same number of bytes as were requested. + # + # See https://bugs.ruby-lang.org/issues/6492 for further discussion. + + def read clen, dest, ignore_eof = false + temp_dest = inflate_adapter(dest) + + @socket.read clen, temp_dest, ignore_eof + end + + ## + # Reads the rest of the socket, inflates it, then writes it to +dest+. + + def read_all dest + temp_dest = inflate_adapter(dest) + + @socket.read_all temp_dest + end + + end + +end + diff --git a/lib/net/http/responses.rb b/lib/net/http/responses.rb new file mode 100644 index 0000000000..c4259e1a02 --- /dev/null +++ b/lib/net/http/responses.rb @@ -0,0 +1,299 @@ +# frozen_string_literal: false +# :stopdoc: +# https://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml +class Net::HTTPUnknownResponse < Net::HTTPResponse + HAS_BODY = true + EXCEPTION_TYPE = Net::HTTPError +end +class Net::HTTPInformation < Net::HTTPResponse # 1xx + HAS_BODY = false + EXCEPTION_TYPE = Net::HTTPError +end +class Net::HTTPSuccess < Net::HTTPResponse # 2xx + HAS_BODY = true + EXCEPTION_TYPE = Net::HTTPError +end +class Net::HTTPRedirection < Net::HTTPResponse # 3xx + HAS_BODY = true + EXCEPTION_TYPE = Net::HTTPRetriableError +end +class Net::HTTPClientError < Net::HTTPResponse # 4xx + HAS_BODY = true + EXCEPTION_TYPE = Net::HTTPServerException # for backward compatibility +end +class Net::HTTPServerError < Net::HTTPResponse # 5xx + HAS_BODY = true + EXCEPTION_TYPE = Net::HTTPFatalError # for backward compatibility +end + +class Net::HTTPContinue < Net::HTTPInformation # 100 + HAS_BODY = false +end +class Net::HTTPSwitchProtocol < Net::HTTPInformation # 101 + HAS_BODY = false +end +class Net::HTTPProcessing < Net::HTTPInformation # 102 + HAS_BODY = false +end + +class Net::HTTPOK < Net::HTTPSuccess # 200 + HAS_BODY = true +end +class Net::HTTPCreated < Net::HTTPSuccess # 201 + HAS_BODY = true +end +class Net::HTTPAccepted < Net::HTTPSuccess # 202 + HAS_BODY = true +end +class Net::HTTPNonAuthoritativeInformation < Net::HTTPSuccess # 203 + HAS_BODY = true +end +class Net::HTTPNoContent < Net::HTTPSuccess # 204 + HAS_BODY = false +end +class Net::HTTPResetContent < Net::HTTPSuccess # 205 + HAS_BODY = false +end +class Net::HTTPPartialContent < Net::HTTPSuccess # 206 + HAS_BODY = true +end +class Net::HTTPMultiStatus < Net::HTTPSuccess # 207 - RFC 4918 + HAS_BODY = true +end +class Net::HTTPAlreadyReported < Net::HTTPSuccess # 208 - RFC 5842 + HAS_BODY = true +end +class Net::HTTPIMUsed < Net::HTTPSuccess # 226 - RFC 3229 + HAS_BODY = true +end + +class Net::HTTPMultipleChoices < Net::HTTPRedirection # 300 + HAS_BODY = true +end +Net::HTTPMultipleChoice = Net::HTTPMultipleChoices +class Net::HTTPMovedPermanently < Net::HTTPRedirection # 301 + HAS_BODY = true +end +class Net::HTTPFound < Net::HTTPRedirection # 302 + HAS_BODY = true +end +Net::HTTPMovedTemporarily = Net::HTTPFound +class Net::HTTPSeeOther < Net::HTTPRedirection # 303 + HAS_BODY = true +end +class Net::HTTPNotModified < Net::HTTPRedirection # 304 + HAS_BODY = false +end +class Net::HTTPUseProxy < Net::HTTPRedirection # 305 + HAS_BODY = false +end +# 306 Switch Proxy - no longer unused +class Net::HTTPTemporaryRedirect < Net::HTTPRedirection # 307 + HAS_BODY = true +end +class Net::HTTPPermanentRedirect < Net::HTTPRedirection # 308 + HAS_BODY = true +end + +class Net::HTTPBadRequest < Net::HTTPClientError # 400 + HAS_BODY = true +end +class Net::HTTPUnauthorized < Net::HTTPClientError # 401 + HAS_BODY = true +end +class Net::HTTPPaymentRequired < Net::HTTPClientError # 402 + HAS_BODY = true +end +class Net::HTTPForbidden < Net::HTTPClientError # 403 + HAS_BODY = true +end +class Net::HTTPNotFound < Net::HTTPClientError # 404 + HAS_BODY = true +end +class Net::HTTPMethodNotAllowed < Net::HTTPClientError # 405 + HAS_BODY = true +end +class Net::HTTPNotAcceptable < Net::HTTPClientError # 406 + HAS_BODY = true +end +class Net::HTTPProxyAuthenticationRequired < Net::HTTPClientError # 407 + HAS_BODY = true +end +class Net::HTTPRequestTimeOut < Net::HTTPClientError # 408 + HAS_BODY = true +end +class Net::HTTPConflict < Net::HTTPClientError # 409 + HAS_BODY = true +end +class Net::HTTPGone < Net::HTTPClientError # 410 + HAS_BODY = true +end +class Net::HTTPLengthRequired < Net::HTTPClientError # 411 + HAS_BODY = true +end +class Net::HTTPPreconditionFailed < Net::HTTPClientError # 412 + HAS_BODY = true +end +class Net::HTTPRequestEntityTooLarge < Net::HTTPClientError # 413 + HAS_BODY = true +end +class Net::HTTPRequestURITooLong < Net::HTTPClientError # 414 + HAS_BODY = true +end +Net::HTTPRequestURITooLarge = Net::HTTPRequestURITooLong +class Net::HTTPUnsupportedMediaType < Net::HTTPClientError # 415 + HAS_BODY = true +end +class Net::HTTPRequestedRangeNotSatisfiable < Net::HTTPClientError # 416 + HAS_BODY = true +end +class Net::HTTPExpectationFailed < Net::HTTPClientError # 417 + HAS_BODY = true +end +# 418 I'm a teapot - RFC 2324; a joke RFC +# 420 Enhance Your Calm - Twitter +class Net::HTTPMisdirectedRequest < Net::HTTPClientError # 421 - RFC 7540 + HAS_BODY = true +end +class Net::HTTPUnprocessableEntity < Net::HTTPClientError # 422 - RFC 4918 + HAS_BODY = true +end +class Net::HTTPLocked < Net::HTTPClientError # 423 - RFC 4918 + HAS_BODY = true +end +class Net::HTTPFailedDependency < Net::HTTPClientError # 424 - RFC 4918 + HAS_BODY = true +end +# 425 Unordered Collection - existed only in draft +class Net::HTTPUpgradeRequired < Net::HTTPClientError # 426 - RFC 2817 + HAS_BODY = true +end +class Net::HTTPPreconditionRequired < Net::HTTPClientError # 428 - RFC 6585 + HAS_BODY = true +end +class Net::HTTPTooManyRequests < Net::HTTPClientError # 429 - RFC 6585 + HAS_BODY = true +end +class Net::HTTPRequestHeaderFieldsTooLarge < Net::HTTPClientError # 431 - RFC 6585 + HAS_BODY = true +end +class Net::HTTPUnavailableForLegalReasons < Net::HTTPClientError # 451 - RFC 7725 + HAS_BODY = true +end +# 444 No Response - Nginx +# 449 Retry With - Microsoft +# 450 Blocked by Windows Parental Controls - Microsoft +# 499 Client Closed Request - Nginx + +class Net::HTTPInternalServerError < Net::HTTPServerError # 500 + HAS_BODY = true +end +class Net::HTTPNotImplemented < Net::HTTPServerError # 501 + HAS_BODY = true +end +class Net::HTTPBadGateway < Net::HTTPServerError # 502 + HAS_BODY = true +end +class Net::HTTPServiceUnavailable < Net::HTTPServerError # 503 + HAS_BODY = true +end +class Net::HTTPGatewayTimeOut < Net::HTTPServerError # 504 + HAS_BODY = true +end +class Net::HTTPVersionNotSupported < Net::HTTPServerError # 505 + HAS_BODY = true +end +class Net::HTTPVariantAlsoNegotiates < Net::HTTPServerError # 506 + HAS_BODY = true +end +class Net::HTTPInsufficientStorage < Net::HTTPServerError # 507 - RFC 4918 + HAS_BODY = true +end +class Net::HTTPLoopDetected < Net::HTTPServerError # 508 - RFC 5842 + HAS_BODY = true +end +# 509 Bandwidth Limit Exceeded - Apache bw/limited extension +class Net::HTTPNotExtended < Net::HTTPServerError # 510 - RFC 2774 + HAS_BODY = true +end +class Net::HTTPNetworkAuthenticationRequired < Net::HTTPServerError # 511 - RFC 6585 + HAS_BODY = true +end + +class Net::HTTPResponse + CODE_CLASS_TO_OBJ = { + '1' => Net::HTTPInformation, + '2' => Net::HTTPSuccess, + '3' => Net::HTTPRedirection, + '4' => Net::HTTPClientError, + '5' => Net::HTTPServerError + } + CODE_TO_OBJ = { + '100' => Net::HTTPContinue, + '101' => Net::HTTPSwitchProtocol, + '102' => Net::HTTPProcessing, + + '200' => Net::HTTPOK, + '201' => Net::HTTPCreated, + '202' => Net::HTTPAccepted, + '203' => Net::HTTPNonAuthoritativeInformation, + '204' => Net::HTTPNoContent, + '205' => Net::HTTPResetContent, + '206' => Net::HTTPPartialContent, + '207' => Net::HTTPMultiStatus, + '208' => Net::HTTPAlreadyReported, + '226' => Net::HTTPIMUsed, + + '300' => Net::HTTPMultipleChoices, + '301' => Net::HTTPMovedPermanently, + '302' => Net::HTTPFound, + '303' => Net::HTTPSeeOther, + '304' => Net::HTTPNotModified, + '305' => Net::HTTPUseProxy, + '307' => Net::HTTPTemporaryRedirect, + '308' => Net::HTTPPermanentRedirect, + + '400' => Net::HTTPBadRequest, + '401' => Net::HTTPUnauthorized, + '402' => Net::HTTPPaymentRequired, + '403' => Net::HTTPForbidden, + '404' => Net::HTTPNotFound, + '405' => Net::HTTPMethodNotAllowed, + '406' => Net::HTTPNotAcceptable, + '407' => Net::HTTPProxyAuthenticationRequired, + '408' => Net::HTTPRequestTimeOut, + '409' => Net::HTTPConflict, + '410' => Net::HTTPGone, + '411' => Net::HTTPLengthRequired, + '412' => Net::HTTPPreconditionFailed, + '413' => Net::HTTPRequestEntityTooLarge, + '414' => Net::HTTPRequestURITooLong, + '415' => Net::HTTPUnsupportedMediaType, + '416' => Net::HTTPRequestedRangeNotSatisfiable, + '417' => Net::HTTPExpectationFailed, + '421' => Net::HTTPMisdirectedRequest, + '422' => Net::HTTPUnprocessableEntity, + '423' => Net::HTTPLocked, + '424' => Net::HTTPFailedDependency, + '426' => Net::HTTPUpgradeRequired, + '428' => Net::HTTPPreconditionRequired, + '429' => Net::HTTPTooManyRequests, + '431' => Net::HTTPRequestHeaderFieldsTooLarge, + '451' => Net::HTTPUnavailableForLegalReasons, + + '500' => Net::HTTPInternalServerError, + '501' => Net::HTTPNotImplemented, + '502' => Net::HTTPBadGateway, + '503' => Net::HTTPServiceUnavailable, + '504' => Net::HTTPGatewayTimeOut, + '505' => Net::HTTPVersionNotSupported, + '506' => Net::HTTPVariantAlsoNegotiates, + '507' => Net::HTTPInsufficientStorage, + '508' => Net::HTTPLoopDetected, + '510' => Net::HTTPNotExtended, + '511' => Net::HTTPNetworkAuthenticationRequired, + } +end + +# :startdoc: + diff --git a/lib/net/http/status.rb b/lib/net/http/status.rb new file mode 100644 index 0000000000..c7a4c0cee3 --- /dev/null +++ b/lib/net/http/status.rb @@ -0,0 +1,83 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +require 'net/http' + +if $0 == __FILE__ + require 'open-uri' + IO.foreach(__FILE__) do |line| + puts line + break if line.start_with?('end') + end + puts + puts "Net::HTTP::STATUS_CODES = {" + url = "https://www.iana.org/assignments/http-status-codes/http-status-codes-1.csv" + URI(url).read.each_line do |line| + code, mes, = line.split(',') + next if ['(Unused)', 'Unassigned', 'Description'].include?(mes) + puts " #{code} => '#{mes}'," + end + puts "}" +end + +Net::HTTP::STATUS_CODES = { + 100 => 'Continue', + 101 => 'Switching Protocols', + 102 => 'Processing', + 200 => 'OK', + 201 => 'Created', + 202 => 'Accepted', + 203 => 'Non-Authoritative Information', + 204 => 'No Content', + 205 => 'Reset Content', + 206 => 'Partial Content', + 207 => 'Multi-Status', + 208 => 'Already Reported', + 226 => 'IM Used', + 300 => 'Multiple Choices', + 301 => 'Moved Permanently', + 302 => 'Found', + 303 => 'See Other', + 304 => 'Not Modified', + 305 => 'Use Proxy', + 307 => 'Temporary Redirect', + 308 => 'Permanent Redirect', + 400 => 'Bad Request', + 401 => 'Unauthorized', + 402 => 'Payment Required', + 403 => 'Forbidden', + 404 => 'Not Found', + 405 => 'Method Not Allowed', + 406 => 'Not Acceptable', + 407 => 'Proxy Authentication Required', + 408 => 'Request Timeout', + 409 => 'Conflict', + 410 => 'Gone', + 411 => 'Length Required', + 412 => 'Precondition Failed', + 413 => 'Payload Too Large', + 414 => 'URI Too Long', + 415 => 'Unsupported Media Type', + 416 => 'Range Not Satisfiable', + 417 => 'Expectation Failed', + 421 => 'Misdirected Request', + 422 => 'Unprocessable Entity', + 423 => 'Locked', + 424 => 'Failed Dependency', + 426 => 'Upgrade Required', + 428 => 'Precondition Required', + 429 => 'Too Many Requests', + 431 => 'Request Header Fields Too Large', + 451 => 'Unavailable For Legal Reasons', + 500 => 'Internal Server Error', + 501 => 'Not Implemented', + 502 => 'Bad Gateway', + 503 => 'Service Unavailable', + 504 => 'Gateway Timeout', + 505 => 'HTTP Version Not Supported', + 506 => 'Variant Also Negotiates', + 507 => 'Insufficient Storage', + 508 => 'Loop Detected', + 510 => 'Not Extended', + 511 => 'Network Authentication Required', +} diff --git a/lib/net/https.rb b/lib/net/https.rb index 1111e94590..58cb6ddf19 100644 --- a/lib/net/https.rb +++ b/lib/net/https.rb @@ -1,6 +1,12 @@ +# frozen_string_literal: false =begin -= $RCSfile: https.rb,v $ -- SSL/TLS enhancement for Net::HTTP. += net/https -- SSL/TLS enhancement for Net::HTTP. + + This file has been merged with net/http. There is no longer any need to + require 'net/https' to use HTTPS. + + See Net::HTTP for details on how to make HTTPS connections. == Info 'OpenSSL for Ruby 2' project @@ -8,166 +14,10 @@ All rights reserved. == Licence - This program is licenced under the same licence as Ruby. + This program is licensed under the same licence as Ruby. (See the file 'LICENCE'.) -== Requirements - This program requires Net 1.2.0 or higher version. - You can get it from RAA or Ruby's CVS repository. - -== Version - $Id: https.rb,v 1.3.4.2 2006/02/05 09:56:34 aamine Exp $ - - 2001-11-06: Contiributed to Ruby/OpenSSL project. - 2004-03-06: Some code is merged in to net/http. - -== Example - -Here is a simple HTTP client: - - require 'net/http' - require 'uri' - - uri = URI.parse(ARGV[0] || 'http://localhost/') - http = Net::HTTP.new(uri.host, uri.port) - http.start { - http.request_get(uri.path) {|res| - print res.body - } - } - -It can be replaced by the following code: - - require 'net/https' - require 'uri' - - uri = URI.parse(ARGV[0] || 'https://localhost/') - http = Net::HTTP.new(uri.host, uri.port) - http.use_ssl = true if uri.scheme == "https" # enable SSL/TLS - http.start { - http.request_get(uri.path) {|res| - print res.body - } - } - -== class Net::HTTP - -=== Instance Methods - -: use_ssl? - returns true if use SSL/TLS with HTTP. - -: use_ssl=((|true_or_false|)) - sets use_ssl. - -: peer_cert - return the X.509 certificates the server presented. - -: key, key=((|key|)) - Sets an OpenSSL::PKey::RSA or OpenSSL::PKey::DSA object. - (This method is appeared in Michal Rokos's OpenSSL extention.) - -: cert, cert=((|cert|)) - Sets an OpenSSL::X509::Certificate object as client certificate - (This method is appeared in Michal Rokos's OpenSSL extention). - -: ca_file, ca_file=((|path|)) - Sets path of a CA certification file in PEM format. - The file can contrain several CA certificats. - -: ca_path, ca_path=((|path|)) - Sets path of a CA certification directory containing certifications - in PEM format. - -: verify_mode, verify_mode=((|mode|)) - Sets the flags for server the certification verification at - begining of SSL/TLS session. - OpenSSL::SSL::VERIFY_NONE or OpenSSL::SSL::VERIFY_PEER is acceptable. - -: verify_callback, verify_callback=((|proc|)) - Sets the verify callback for the server certification verification. - -: verify_depth, verify_depth=((|num|)) - Sets the maximum depth for the certificate chain verification. - -: cert_store, cert_store=((|store|)) - Sets the X509::Store to verify peer certificate. - -: ssl_timeout, ssl_timeout=((|sec|)) - Sets the SSL timeout seconds. - =end require 'net/http' require 'openssl' - -module Net - - class HTTP - remove_method :use_ssl? - def use_ssl? - @use_ssl - end - - # For backward compatibility. - alias use_ssl use_ssl? - - # Turn on/off SSL. - # This flag must be set before starting session. - # If you change use_ssl value after session started, - # a Net::HTTP object raises IOError. - def use_ssl=(flag) - flag = (flag ? true : false) - raise IOError, "use_ssl value changed, but session already started" \ - if started? and @use_ssl != flag - if flag and not @ssl_context - @ssl_context = OpenSSL::SSL::SSLContext.new - end - @use_ssl = flag - end - - def self.ssl_context_accessor(name) - module_eval(<<-End, __FILE__, __LINE__ + 1) - def #{name} - return nil unless @ssl_context - @ssl_context.#{name} - end - - def #{name}=(val) - @ssl_context ||= OpenSSL::SSL::SSLContext.new - @ssl_context.#{name} = val - end - End - end - - ssl_context_accessor :key - ssl_context_accessor :cert - ssl_context_accessor :ca_file - ssl_context_accessor :ca_path - ssl_context_accessor :verify_mode - ssl_context_accessor :verify_callback - ssl_context_accessor :verify_depth - ssl_context_accessor :cert_store - - def ssl_timeout - return nil unless @ssl_context - @ssl_context.timeout - end - - def ssl_timeout=(sec) - raise ArgumentError, 'Net::HTTP#ssl_timeout= called but use_ssl=false' \ - unless use_ssl? - @ssl_context ||= OpenSSL::SSL::SSLContext.new - @ssl_context.timeout = sec - end - - # For backward compatibility - alias timeout= ssl_timeout= - - def peer_cert - return nil if not use_ssl? or not @socket - @socket.io.peer_cert - end - end - -end diff --git a/lib/net/imap.rb b/lib/net/imap.rb index 57e78ec135..da7d0d555c 100644 --- a/lib/net/imap.rb +++ b/lib/net/imap.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true # # = net/imap.rb # @@ -9,13 +10,15 @@ # Documentation: Shugo Maeda, with RDoc conversion and overview by William # Webber. # -# See Net::IMAP for documentation. +# See Net::IMAP for documentation. # require "socket" require "monitor" require "digest/md5" +require "strscan" +require 'net/protocol' begin require "openssl" rescue LoadError @@ -36,7 +39,7 @@ module Net # arranged in an hierarchical namespace, and each of which # contains zero or more messages. How this is implemented on # the server is implementation-dependent; on a UNIX server, it - # will frequently be implemented as a files in mailbox format + # will frequently be implemented as files in mailbox format # within a hierarchy of directories. # # To work on the messages within a mailbox, the client must @@ -44,25 +47,25 @@ module Net # read-only access) #examine(). Once the client has successfully # selected a mailbox, they enter _selected_ state, and that # mailbox becomes the _current_ mailbox, on which mail-item - # related commands implicitly operate. + # related commands implicitly operate. # # Messages have two sorts of identifiers: message sequence - # numbers, and UIDs. + # numbers and UIDs. # - # Message sequence numbers number messages within a mail box - # from 1 up to the number of items in the mail box. If new + # Message sequence numbers number messages within a mailbox + # from 1 up to the number of items in the mailbox. If a new # message arrives during a session, it receives a sequence - # number equal to the new size of the mail box. If messages + # number equal to the new size of the mailbox. If messages # are expunged from the mailbox, remaining messages have their # sequence numbers "shuffled down" to fill the gaps. # # UIDs, on the other hand, are permanently guaranteed not to - # identify another message within the same mailbox, even if + # identify another message within the same mailbox, even if # the existing message is deleted. UIDs are required to # be assigned in ascending (but not necessarily sequential) # order within a mailbox; this means that if a non-IMAP client # rearranges the order of mailitems within a mailbox, the - # UIDs have to be reassigned. An IMAP client cannot thus + # UIDs have to be reassigned. An IMAP client thus cannot # rearrange message orders. # # == Examples of Usage @@ -90,11 +93,11 @@ module Net # imap.store(message_id, "+FLAGS", [:Deleted]) # end # imap.expunge - # + # # == Thread Safety # # Net::IMAP supports concurrent threads. For example, - # + # # imap = Net::IMAP.new("imap.foo.net", "imap2") # imap.authenticate("cram-md5", "bar", "password") # imap.select("inbox") @@ -102,7 +105,7 @@ module Net # search_result = imap.search(["BODY", "hello"]) # fetch_result = fetch_thread.value # imap.disconnect - # + # # This script invokes the FETCH command and the SEARCH command concurrently. # # == Errors @@ -112,9 +115,9 @@ module Net # # NO:: the attempted command could not be successfully completed. For # instance, the username/password used for logging in are incorrect; - # the selected mailbox does not exists; etc. + # the selected mailbox does not exist; etc. # - # BAD:: the request from the client does not follow the server's + # BAD:: the request from the client does not follow the server's # understanding of the IMAP protocol. This includes attempting # commands from the wrong client state; for instance, attempting # to perform a SEARCH command without having SELECTed a current @@ -124,7 +127,7 @@ module Net # BYE:: the server is saying goodbye. This can be part of a normal # logout sequence, and can be used as part of a login sequence # to indicate that the server is (for some reason) unwilling - # to accept our connection. As a response to any other command, + # to accept your connection. As a response to any other command, # it indicates either that the server is shutting down, or that # the server is timing out the client connection due to inactivity. # @@ -146,8 +149,8 @@ module Net # # Finally, a Net::IMAP::DataFormatError is thrown if low-level data # is found to be in an incorrect format (for instance, when converting - # between UTF-8 and UTF-16), and Net::IMAP::ResponseParseError is - # thrown if a server response is non-parseable. + # between UTF-8 and UTF-16), and Net::IMAP::ResponseParseError is + # thrown if a server response is non-parseable. # # # == References @@ -197,9 +200,9 @@ module Net # Goldsmith, D. and Davis, M., "UTF-7: A Mail-Safe Transformation Format of # Unicode", RFC 2152, May 1997. # - class IMAP + class IMAP < Protocol include MonitorMixin - if defined?(OpenSSL) + if defined?(OpenSSL::SSL) include OpenSSL include SSL end @@ -219,17 +222,22 @@ module Net # Returns all response handlers. attr_reader :response_handlers + # 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. + attr_reader :open_timeout + # The thread to receive exceptions. attr_accessor :client_thread - # Flag indicating a message has been seen + # Flag indicating a message has been seen. SEEN = :Seen - # Flag indicating a message has been answered + # Flag indicating a message has been answered. ANSWERED = :Answered # Flag indicating a message has been flagged for special or urgent - # attention + # attention. FLAGGED = :Flagged # Flag indicating a message has been marked for deletion. This @@ -239,7 +247,7 @@ module Net # Flag indicating a message is only a draft or work-in-progress version. DRAFT = :Draft - # Flag indicating that the message is "recent", meaning that this + # Flag indicating that the message is "recent," meaning that this # session is the first session in which the client has been notified # of this message. RECENT = :Recent @@ -269,12 +277,24 @@ module Net return @@debug = val end + # Returns the max number of flags interned to symbols. + def self.max_flag_count + return @@max_flag_count + end + + # Sets the max number of flags interned to symbols. + def self.max_flag_count=(count) + @@max_flag_count = count + end + # Adds an authenticator for Net::IMAP#authenticate. +auth_type+ # is the type of authentication this authenticator supports # (for instance, "LOGIN"). The +authenticator+ is an object # which defines a process() method to handle authentication with - # the server. See Net::IMAP::LoginAuthenticator and - # Net::IMAP::CramMD5Authenticator for examples. + # the server. See Net::IMAP::LoginAuthenticator, + # Net::IMAP::CramMD5Authenticator, and Net::IMAP::DigestMD5Authenticator + # for examples. + # # # If +auth_type+ refers to an existing authenticator, it will be # replaced by the new one. @@ -282,15 +302,43 @@ module Net @@authenticators[auth_type] = authenticator end + # The default port for IMAP connections, port 143 + def self.default_port + return PORT + end + + # The default port for IMAPS connections, port 993 + def self.default_tls_port + return SSL_PORT + end + + class << self + alias default_imap_port default_port + alias default_imaps_port default_tls_port + alias default_ssl_port default_tls_port + end + # Disconnects from the server. def disconnect - if SSL::SSLSocket === @sock - @sock.io.shutdown - else - @sock.shutdown + return if disconnected? + begin + begin + # try to call SSL::SSLSocket#io. + @sock.io.shutdown + rescue NoMethodError + # @sock is not an SSL::SSLSocket. + @sock.shutdown + end + rescue Errno::ENOTCONN + # ignore `Errno::ENOTCONN: Socket is not connected' on some platforms. + rescue Exception => e + @receiver_thread.raise(e) end @receiver_thread.join - @sock.close + synchronize do + @sock.close + end + raise e if e end # Returns true if disconnected from the server. @@ -305,7 +353,7 @@ module Net # # Note that the Net::IMAP class does not modify its # behaviour according to the capabilities of the server; - # it is up to the user of the class to ensure that + # it is up to the user of the class to ensure that # a certain capability is supported by a server before # using it. def capability @@ -326,19 +374,34 @@ module Net send_command("LOGOUT") end + # Sends a STARTTLS command to start TLS session. + def starttls(options = {}, verify = true) + send_command("STARTTLS") do |resp| + if resp.kind_of?(TaggedResponse) && resp.name == "OK" + begin + # for backward compatibility + certs = options.to_str + options = create_ssl_params(certs, verify) + rescue NoMethodError + end + start_tls_session(options) + end + end + end + # Sends an AUTHENTICATE command to authenticate the client. # The +auth_type+ parameter is a string that represents # the authentication mechanism to be used. Currently Net::IMAP - # supports authentication mechanisms: + # supports the authentication mechanisms: # - # LOGIN:: login using cleartext user and password. + # LOGIN:: login using cleartext user and password. # CRAM-MD5:: login with cleartext user and encrypted password # (see [RFC-2195] for a full description). This # mechanism requires that the server have the user's # password stored in clear-text password. # - # For both these mechanisms, there should be two +args+: username - # and (cleartext) password. A server may not support one or other + # For both of these mechanisms, there should be two +args+: username + # and (cleartext) password. A server may not support one or the other # of these mechanisms; check #capability() for a capability of # the form "AUTH=LOGIN" or "AUTH=CRAM-MD5". # @@ -361,7 +424,7 @@ module Net send_command("AUTHENTICATE", auth_type) do |resp| if resp.instance_of?(ContinuationRequest) data = authenticator.process(resp.data.text.unpack("m")[0]) - s = [data].pack("m").gsub(/\n/, "") + s = [data].pack("m0") send_string_data(s) put_string(CRLF) end @@ -379,7 +442,7 @@ module Net end # Sends a SELECT command to select a +mailbox+ so that messages - # in the +mailbox+ can be accessed. + # in the +mailbox+ can be accessed. # # After you have selected a mailbox, you may retrieve the # number of items in that mailbox from @responses["EXISTS"][-1], @@ -430,7 +493,7 @@ module Net # Sends a RENAME command to change the name of the +mailbox+ to # +newname+. # - # A Net::IMAP::NoResponseError is raised if a mailbox with the + # A Net::IMAP::NoResponseError is raised if a mailbox with the # name +mailbox+ cannot be renamed to +newname+ for whatever # reason; for instance, because +mailbox+ does not exist, or # because there is already a mailbox with the name +newname+. @@ -443,7 +506,7 @@ module Net # by #lsub(). # # A Net::IMAP::NoResponseError is raised if +mailbox+ cannot be - # subscribed to, for instance because it does not exist. + # subscribed to; for instance, because it does not exist. def subscribe(mailbox) send_command("SUBSCRIBE", mailbox) end @@ -452,7 +515,7 @@ module Net # from the server's set of "active" or "subscribed" mailboxes. # # A Net::IMAP::NoResponseError is raised if +mailbox+ cannot be - # unsubscribed from, for instance because the client is not currently + # unsubscribed from; for instance, because the client is not currently # subscribed to it. def unsubscribe(mailbox) send_command("UNSUBSCRIBE", mailbox) @@ -477,8 +540,8 @@ module Net # imap.create("foo/bar") # imap.create("foo/baz") # p imap.list("", "foo/%") - # #=> [#<Net::IMAP::MailboxList attr=[:Noselect], delim="/", name="foo/">, \\ - # #<Net::IMAP::MailboxList attr=[:Noinferiors, :Marked], delim="/", name="foo/bar">, \\ + # #=> [#<Net::IMAP::MailboxList attr=[:Noselect], delim="/", name="foo/">, \\ + # #<Net::IMAP::MailboxList attr=[:Noinferiors, :Marked], delim="/", name="foo/bar">, \\ # #<Net::IMAP::MailboxList attr=[:Noinferiors], delim="/", name="foo/baz">] def list(refname, mailbox) synchronize do @@ -487,9 +550,41 @@ module Net end end - # Sends the GETQUOTAROOT command along with specified +mailbox+. + # Sends a XLIST command, and returns a subset of names from + # the complete set of all names available to the client. + # +refname+ provides a context (for instance, a base directory + # in a directory-based mailbox hierarchy). +mailbox+ specifies + # a mailbox or (via wildcards) mailboxes under that context. + # Two wildcards may be used in +mailbox+: '*', which matches + # all characters *including* the hierarchy delimiter (for instance, + # '/' on a UNIX-hosted directory-based mailbox hierarchy); and '%', + # which matches all characters *except* the hierarchy delimiter. + # + # If +refname+ is empty, +mailbox+ is used directly to determine + # which mailboxes to match. If +mailbox+ is empty, the root + # name of +refname+ and the hierarchy delimiter are returned. + # + # The XLIST command is like the LIST command except that the flags + # returned refer to the function of the folder/mailbox, e.g. :Sent + # + # The return value is an array of +Net::IMAP::MailboxList+. For example: + # + # imap.create("foo/bar") + # imap.create("foo/baz") + # p imap.xlist("", "foo/%") + # #=> [#<Net::IMAP::MailboxList attr=[:Noselect], delim="/", name="foo/">, \\ + # #<Net::IMAP::MailboxList attr=[:Noinferiors, :Marked], delim="/", name="foo/bar">, \\ + # #<Net::IMAP::MailboxList attr=[:Noinferiors], delim="/", name="foo/baz">] + def xlist(refname, mailbox) + synchronize do + send_command("XLIST", refname, mailbox) + return @responses.delete("XLIST") + end + end + + # Sends the GETQUOTAROOT command along with the specified +mailbox+. # This command is generally available to both admin and user. - # If mailbox exists, returns an array containing objects of + # If this mailbox exists, it returns an array containing objects of type # Net::IMAP::MailboxQuotaRoot and Net::IMAP::MailboxQuota. def getquotaroot(mailbox) synchronize do @@ -504,7 +599,7 @@ module Net # Sends the GETQUOTA command along with specified +mailbox+. # If this mailbox exists, then an array containing a # Net::IMAP::MailboxQuota object is returned. This - # command generally is only available to server admin. + # command is generally only available to server admin. def getquota(mailbox) synchronize do send_command("GETQUOTA", mailbox) @@ -513,8 +608,8 @@ module Net end # Sends a SETQUOTA command 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 server admin + # +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. The IMAP quota commands are described in # [RFC-2087]. def setquota(mailbox, quota) @@ -531,14 +626,14 @@ module Net # then that user will be stripped of any rights to that mailbox. # The IMAP ACL commands are described in [RFC-2086]. def setacl(mailbox, user, rights) - if rights.nil? + if rights.nil? send_command("SETACL", mailbox, user, "") else send_command("SETACL", mailbox, user, rights) end end - # Send the GETACL command along with specified +mailbox+. + # Send the GETACL command along with a specified +mailbox+. # If this mailbox exists, an array containing objects of # Net::IMAP::MailboxACLItem will be returned. def getacl(mailbox) @@ -550,7 +645,7 @@ module Net # Sends a LSUB command, and returns a subset of names from the set # of names that the user has declared as being "active" or - # "subscribed". +refname+ and +mailbox+ are interpreted as + # "subscribed." +refname+ and +mailbox+ are interpreted as # for #list(). # The return value is an array of +Net::IMAP::MailboxList+. def lsub(refname, mailbox) @@ -561,8 +656,8 @@ module Net end # Sends a STATUS command, and returns the status of the indicated - # +mailbox+. +attr+ is a list of one or more attributes that - # we are request the status of. Supported attributes include: + # +mailbox+. +attr+ is a list of one or more attributes whose + # statuses are to be requested. Supported attributes include: # # MESSAGES:: the number of messages in the mailbox. # RECENT:: the number of recent messages in the mailbox. @@ -573,8 +668,8 @@ module Net # p imap.status("inbox", ["MESSAGES", "RECENT"]) # #=> {"RECENT"=>0, "MESSAGES"=>44} # - # A Net::IMAP::NoResponseError is raised if status values - # for +mailbox+ cannot be returned, for instance because it + # A Net::IMAP::NoResponseError is raised if status values + # for +mailbox+ cannot be returned; for instance, because it # does not exist. def status(mailbox, attr) synchronize do @@ -584,9 +679,9 @@ module Net end # Sends a APPEND command to append the +message+ to the end of - # the +mailbox+. The optional +flags+ argument is an array of - # flags to initially passing to the new message. The optional - # +date_time+ argument specifies the creation time to assign to the + # the +mailbox+. The optional +flags+ argument is an array of + # flags initially passed to the new message. The optional + # +date_time+ argument specifies the creation time to assign to the # new message; it defaults to the current time. # For example: # @@ -594,7 +689,7 @@ module Net # Subject: hello # From: shugo@ruby-lang.org # To: shugo@ruby-lang.org - # + # # hello world # EOF # @@ -613,7 +708,7 @@ module Net # Sends a CHECK command to request a checkpoint of the currently # selected mailbox. This performs implementation-specific - # housekeeping, for instance, reconciling the mailbox's + # housekeeping; for instance, reconciling the mailbox's # in-memory and on-disk state. def check send_command("CHECK") @@ -637,8 +732,8 @@ module Net # Sends a SEARCH command to search the mailbox for messages that # match the given searching criteria, and returns message sequence - # numbers. +keys+ can either be a string holding the entire - # search string, or a single-dimension array of search keywords and + # numbers. +keys+ can either be a string holding the entire + # search string, or a single-dimension array of search keywords and # arguments. The following are some common search criteria; # see [IMAP] section 6.4.4 for a full list. # @@ -662,7 +757,7 @@ module Net # # OR <search-key> <search-key>:: "or" two search keys together. # - # ON <date>:: messages with an internal date exactly equal to <date>, + # ON <date>:: messages with an internal date exactly equal to <date>, # which has a format similar to 8-Aug-2002. # # SINCE <date>:: messages with an internal date on or after <date>. @@ -670,7 +765,7 @@ module Net # SUBJECT <string>:: messages with <string> in their subject. # # TO <string>:: messages with <string> in their TO field. - # + # # For example: # # p imap.search(["SUBJECT", "hello", "NOT", "NEW"]) @@ -679,22 +774,34 @@ module Net return search_internal("SEARCH", keys, charset) end - # As for #search(), but returns unique identifiers. + # Similar to #search(), but returns unique identifiers. def uid_search(keys, charset = nil) return search_internal("UID SEARCH", keys, charset) end # Sends a FETCH command to retrieve data associated with a message - # in the mailbox. The +set+ parameter is a number or an array of - # numbers or a Range object. The number is a message sequence - # number. +attr+ is a list of attributes to fetch; see the - # documentation for Net::IMAP::FetchData for a list of valid - # attributes. - # The return value is an array of Net::IMAP::FetchData. For example: + # in the mailbox. + # + # The +set+ parameter is a number or a range between two numbers, + # or an array of those. The number is a message sequence number, + # where -1 represents a '*' for use in range notation like 100..-1 + # being interpreted as '100:*'. Beware that the +exclude_end?+ + # property of a Range object is ignored, and the contents of a + # range are independent of the order of the range endpoints as per + # the protocol specification, so 1...5, 5..1 and 5...1 are all + # equivalent to 1..5. + # + # +attr+ is a list of attributes to fetch; see the documentation + # for Net::IMAP::FetchData for a list of valid attributes. + # + # The return value is an array of Net::IMAP::FetchData or nil + # (instead of an empty array) if there is no matching message. + # + # For example: # # p imap.fetch(6..8, "UID") - # #=> [#<Net::IMAP::FetchData seqno=6, attr={"UID"=>98}>, \\ - # #<Net::IMAP::FetchData seqno=7, attr={"UID"=>99}>, \\ + # #=> [#<Net::IMAP::FetchData seqno=6, attr={"UID"=>98}>, \\ + # #<Net::IMAP::FetchData seqno=7, attr={"UID"=>99}>, \\ # #<Net::IMAP::FetchData seqno=8, attr={"UID"=>100}>] # p imap.fetch(6, "BODY[HEADER.FIELDS (SUBJECT)]") # #=> [#<Net::IMAP::FetchData seqno=6, attr={"BODY[HEADER.FIELDS (SUBJECT)]"=>"Subject: test\r\n\r\n"}>] @@ -707,51 +814,65 @@ module Net # #=> "12-Oct-2000 22:40:59 +0900" # p data.attr["UID"] # #=> 98 - def fetch(set, attr) - return fetch_internal("FETCH", set, attr) + def fetch(set, attr, mod = nil) + return fetch_internal("FETCH", set, attr, mod) end - # As for #fetch(), but +set+ contains unique identifiers. - def uid_fetch(set, attr) - return fetch_internal("UID FETCH", set, attr) + # Similar to #fetch(), but +set+ contains unique identifiers. + def uid_fetch(set, attr, mod = nil) + return fetch_internal("UID FETCH", set, attr, mod) end # Sends a STORE command to alter data associated with messages - # in the mailbox, in particular their flags. The +set+ parameter - # is a number or an array of numbers or a Range object. Each number - # is a message sequence number. +attr+ is the name of a data item - # to store: 'FLAGS' means to replace the message's flag list - # with the provided one; '+FLAGS' means to add the provided flags; - # and '-FLAGS' means to remove them. +flags+ is a list of flags. + # in the mailbox, in particular their flags. The +set+ parameter + # is a number, an array of numbers, or a Range object. Each number + # is a message sequence number. +attr+ is the name of a data item + # to store: 'FLAGS' will replace the message's flag list + # with the provided one, '+FLAGS' will add the provided flags, + # and '-FLAGS' will remove them. +flags+ is a list of flags. # # The return value is an array of Net::IMAP::FetchData. For example: # # p imap.store(6..8, "+FLAGS", [:Deleted]) - # #=> [#<Net::IMAP::FetchData seqno=6, attr={"FLAGS"=>[:Seen, :Deleted]}>, \\ - # #<Net::IMAP::FetchData seqno=7, attr={"FLAGS"=>[:Seen, :Deleted]}>, \\ + # #=> [#<Net::IMAP::FetchData seqno=6, attr={"FLAGS"=>[:Seen, :Deleted]}>, \\ + # #<Net::IMAP::FetchData seqno=7, attr={"FLAGS"=>[:Seen, :Deleted]}>, \\ # #<Net::IMAP::FetchData seqno=8, attr={"FLAGS"=>[:Seen, :Deleted]}>] def store(set, attr, flags) return store_internal("STORE", set, attr, flags) end - # As for #store(), but +set+ contains unique identifiers. + # Similar to #store(), but +set+ contains unique identifiers. def uid_store(set, attr, flags) return store_internal("UID STORE", set, attr, flags) end # Sends a COPY command to copy the specified message(s) to the end # of the specified destination +mailbox+. The +set+ parameter is - # a number or an array of numbers or a Range object. The number is + # a number, an array of numbers, or a Range object. The number is # a message sequence number. def copy(set, mailbox) copy_internal("COPY", set, mailbox) end - # As for #copy(), but +set+ contains unique identifiers. + # Similar to #copy(), but +set+ contains unique identifiers. def uid_copy(set, mailbox) copy_internal("UID COPY", set, mailbox) end + # Sends a MOVE command to move the specified message(s) to the end + # of the specified destination +mailbox+. The +set+ parameter is + # a number, an array of numbers, or a Range object. The number is + # a message sequence number. + # The IMAP MOVE extension is described in [RFC-6851]. + def move(set, mailbox) + copy_internal("MOVE", set, mailbox) + end + + # Similar to #move(), but +set+ contains unique identifiers. + def uid_move(set, mailbox) + copy_internal("UID MOVE", set, mailbox) + end + # Sends a SORT command to sort messages in the mailbox. # Returns an array of message sequence numbers. For example: # @@ -765,16 +886,16 @@ module Net return sort_internal("SORT", sort_keys, search_keys, charset) end - # As for #sort(), but returns an array of unique identifiers. + # Similar to #sort(), but returns an array of unique identifiers. def uid_sort(sort_keys, search_keys, charset) return sort_internal("UID SORT", sort_keys, search_keys, charset) end - # Adds a response handler. For example, to detect when - # the server sends us a new EXISTS response (which normally - # indicates new messages being added to the mail box), - # you could add the following handler after selecting the - # mailbox. + # Adds a response handler. For example, to detect when + # the server sends a new EXISTS response (which normally + # indicates new messages being added to the mailbox), + # add the following handler after selecting the + # mailbox: # # imap.add_response_handler { |resp| # if resp.kind_of?(Net::IMAP::UntaggedResponse) and resp.name == "EXISTS" @@ -791,7 +912,7 @@ module Net @response_handlers.delete(handler) end - # As for #search(), but returns message sequence numbers in threaded + # Similar to #search(), but returns message sequence numbers in threaded # format, as a Net::IMAP::ThreadMember tree. The supported algorithms # are: # @@ -808,12 +929,65 @@ module Net return thread_internal("THREAD", algorithm, search_keys, charset) end - # As for #thread(), but returns unique identifiers instead of + # Similar to #thread(), but returns unique identifiers instead of # message sequence numbers. def uid_thread(algorithm, search_keys, charset) return thread_internal("UID THREAD", algorithm, search_keys, charset) end + # Sends an IDLE command that waits for notifications of new or expunged + # messages. Yields responses from the server during the IDLE. + # + # Use #idle_done() to leave IDLE. + # + # If +timeout+ is given, this method returns after +timeout+ seconds passed. + # +timeout+ can be used for keep-alive. For example, the following code + # checks the connection for each 60 seconds. + # + # loop do + # imap.idle(60) do |res| + # ... + # end + # end + def idle(timeout = nil, &response_handler) + raise LocalJumpError, "no block given" unless response_handler + + response = nil + + synchronize do + tag = Thread.current[:net_imap_tag] = generate_tag + put_string("#{tag} IDLE#{CRLF}") + + begin + add_response_handler(response_handler) + @idle_done_cond = new_cond + @idle_done_cond.wait(timeout) + @idle_done_cond = nil + if @receiver_thread_terminating + raise @exception || Net::IMAP::Error.new("connection closed") + end + ensure + unless @receiver_thread_terminating + remove_response_handler(response_handler) + put_string("DONE#{CRLF}") + response = get_tagged_response(tag, "IDLE") + end + end + end + + return response + end + + # Leaves IDLE. + def idle_done + synchronize do + if @idle_done_cond.nil? + raise Net::IMAP::Error, "not during IDLE" + end + @idle_done_cond.signal + end + end + # Decode a string from modified UTF-7 format to UTF-8. # # UTF-7 is a 7-bit encoding of Unicode [UTF7]. IMAP uses a @@ -821,127 +995,180 @@ module Net # containing non-ASCII characters; see [IMAP] section 5.1.3. # # Net::IMAP does _not_ automatically encode and decode - # mailbox names to and from utf7. + # mailbox names to and from UTF-7. def self.decode_utf7(s) - return s.gsub(/&(.*?)-/n) { - if $1.empty? - "&" + return s.gsub(/&([^-]+)?-/n) { + if $1 + ($1.tr(",", "/") + "===").unpack("m")[0].encode(Encoding::UTF_8, Encoding::UTF_16BE) else - base64 = $1.tr(",", "/") - x = base64.length % 4 - if x > 0 - base64.concat("=" * (4 - x)) - end - u16tou8(base64.unpack("m")[0]) + "&" end } end # Encode a string from UTF-8 format to modified UTF-7. def self.encode_utf7(s) - return s.gsub(/(&)|([^\x20-\x25\x27-\x7e]+)/n) { |x| + return s.gsub(/(&)|[^\x20-\x7e]+/) { if $1 "&-" else - base64 = [u8tou16(x)].pack("m") - "&" + base64.delete("=\n").tr("/", ",") + "-" + base64 = [$&.encode(Encoding::UTF_16BE)].pack("m0") + "&" + base64.delete("=").tr("/", ",") + "-" end - } + }.force_encoding("ASCII-8BIT") + end + + # Formats +time+ as an IMAP-style date. + def self.format_date(time) + return time.strftime('%d-%b-%Y') + end + + # Formats +time+ as an IMAP-style date-time. + def self.format_datetime(time) + return time.strftime('%d-%b-%Y %H:%M %z') end private CRLF = "\r\n" # :nodoc: PORT = 143 # :nodoc: + SSL_PORT = 993 # :nodoc: @@debug = false @@authenticators = {} + @@max_flag_count = 10000 + # :call-seq: + # Net::IMAP.new(host, options = {}) + # # Creates a new Net::IMAP object and connects it to the specified - # +port+ (143 by default) on the named +host+. If +usessl+ is true, - # then an attempt will - # be made to use SSL (now TLS) to connect to the server. For this - # to work OpenSSL [OSSL] and the Ruby OpenSSL [RSSL] - # extensions need to be installed. The +certs+ parameter indicates - # the path or file containing the CA cert of the server, and the - # +verify+ parameter is for the OpenSSL verification callback. + # +host+. + # + # +options+ is an option hash, each key of which is a symbol. + # + # The available options are: + # + # port:: Port number (default value is 143 for imap, or 993 for imaps) + # ssl:: If options[:ssl] is true, then an attempt will be made + # to use SSL (now TLS) to connect to the server. For this to work + # OpenSSL [OSSL] and the Ruby OpenSSL [RSSL] extensions need to + # be installed. + # If options[:ssl] is a hash, it's passed to + # OpenSSL::SSL::SSLContext#set_params as parameters. + # open_timeout:: Seconds to wait until a connection is opened # # The most common errors are: # - # Errno::ECONNREFUSED:: connection refused by +host+ or an intervening + # Errno::ECONNREFUSED:: Connection refused by +host+ or an intervening # firewall. - # Errno::ETIMEDOUT:: connection timed out (possibly due to packets + # Errno::ETIMEDOUT:: Connection timed out (possibly due to packets # being dropped by an intervening firewall). - # Errno::ENETUNREACH:: there is no route to that network. - # SocketError:: hostname not known or other socket error. - # Net::IMAP::ByeResponseError:: we connected to the host, but they - # immediately said goodbye to us. - def initialize(host, port = PORT, usessl = false, certs = nil, verify = false) + # Errno::ENETUNREACH:: There is no route to that network. + # SocketError:: Hostname not known or other socket error. + # Net::IMAP::ByeResponseError:: The connected to the host was successful, but + # it immediately said goodbye. + def initialize(host, port_or_options = {}, + usessl = false, certs = nil, verify = true) super() @host = host - @port = port + begin + options = port_or_options.to_hash + rescue NoMethodError + # for backward compatibility + options = {} + options[:port] = port_or_options + if usessl + options[:ssl] = create_ssl_params(certs, verify) + end + end + @port = options[:port] || (options[:ssl] ? SSL_PORT : PORT) @tag_prefix = "RUBY" @tagno = 0 + @open_timeout = options[:open_timeout] || 30 @parser = ResponseParser.new - @sock = TCPSocket.open(host, port) - if usessl - unless defined?(OpenSSL) - raise "SSL extension not installed" - end - @usessl = true - - # verify the server. - context = SSLContext::new() - context.ca_file = certs if certs && FileTest::file?(certs) - context.ca_path = certs if certs && FileTest::directory?(certs) - context.verify_mode = VERIFY_PEER if verify - if defined?(VerifyCallbackProc) - context.verify_callback = VerifyCallbackProc - end - @sock = SSLSocket.new(@sock, context) - @sock.connect # start ssl session. - @sock.post_connection_check(@host) if verify - else - @usessl = false - end - @responses = Hash.new([].freeze) - @tagged_responses = {} - @response_handlers = [] - @tagged_response_arrival = new_cond - @continuation_request_arrival = new_cond - @logout_command_tag = nil - @debug_output_bol = true - - @greeting = get_response - if @greeting.name == "BYE" + @sock = tcp_socket(@host, @port) + begin + if options[:ssl] + start_tls_session(options[:ssl]) + @usessl = true + else + @usessl = false + end + @responses = Hash.new([].freeze) + @tagged_responses = {} + @response_handlers = [] + @tagged_response_arrival = new_cond + @continued_command_tag = nil + @continuation_request_arrival = new_cond + @continuation_request_exception = nil + @idle_done_cond = nil + @logout_command_tag = nil + @debug_output_bol = true + @exception = nil + + @greeting = get_response + if @greeting.nil? + raise Error, "connection closed" + end + if @greeting.name == "BYE" + raise ByeResponseError, @greeting + end + + @client_thread = Thread.current + @receiver_thread = Thread.start { + begin + receive_responses + rescue Exception + end + } + @receiver_thread_terminating = false + rescue Exception @sock.close - raise ByeResponseError, @greeting.raw_data + raise end + end - @client_thread = Thread.current - @receiver_thread = Thread.start { - receive_responses - } + def tcp_socket(host, port) + Socket.tcp(host, port, :connect_timeout => @open_timeout) + rescue Errno::ETIMEDOUT + raise Net::OpenTimeout, "Timeout to open TCP connection to " + + "#{host}:#{port} (exceeds #{@open_timeout} seconds)" end def receive_responses - while true + connection_closed = false + until connection_closed + synchronize do + @exception = nil + end begin resp = get_response - rescue Exception - @sock.close - @client_thread.raise($!) + rescue Exception => e + synchronize do + @sock.close + @exception = e + end + break + end + unless resp + synchronize do + @exception = EOFError.new("end of file reached") + end break end - break unless resp begin synchronize do case resp when TaggedResponse @tagged_responses[resp.tag] = resp @tagged_response_arrival.broadcast - if resp.tag == @logout_command_tag + case resp.tag + when @logout_command_tag return + when @continued_command_tag + @continuation_request_exception = + RESPONSE_ERRORS[resp.name].new(resp) + @continuation_request_arrival.signal end when UntaggedResponse record_response(resp.name, resp.data) @@ -951,7 +1178,8 @@ module Net end if resp.name == "BYE" && @logout_command_tag.nil? @sock.close - raise ByeResponseError, resp.raw_data + @exception = ByeResponseError.new(resp) + connection_closed = true end when ContinuationRequest @continuation_request_arrival.signal @@ -960,29 +1188,42 @@ module Net handler.call(resp) end end - rescue Exception - @client_thread.raise($!) + rescue Exception => e + @exception = e + synchronize do + @tagged_response_arrival.broadcast + @continuation_request_arrival.broadcast + end + end + end + synchronize do + @receiver_thread_terminating = true + @tagged_response_arrival.broadcast + @continuation_request_arrival.broadcast + if @idle_done_cond + @idle_done_cond.signal end end end def get_tagged_response(tag, cmd) until @tagged_responses.key?(tag) + raise @exception if @exception @tagged_response_arrival.wait end resp = @tagged_responses.delete(tag) case resp.name when /\A(?:NO)\z/ni - raise NoResponseError, resp.data.text + raise NoResponseError, resp when /\A(?:BAD)\z/ni - raise BadResponseError, resp.data.text + raise BadResponseError, resp else return resp end end def get_response - buff = "" + buff = String.new while true s = @sock.gets(CRLF) break unless s @@ -1010,11 +1251,14 @@ module Net def send_command(cmd, *args, &block) synchronize do + args.each do |i| + validate_data(i) + end tag = generate_tag put_string(tag + " " + cmd) args.each do |i| put_string(" ") - send_data(i) + send_data(i, tag) end put_string(CRLF) if cmd == "LOGOUT" @@ -1037,7 +1281,7 @@ module Net @tagno += 1 return format("%s%04d", @tag_prefix, @tagno) end - + def put_string(str) @sock.print(str) if @@debug @@ -1053,32 +1297,53 @@ module Net end end - def send_data(data) + def validate_data(data) + case data + when nil + when String + when Integer + NumValidator.ensure_number(data) + when Array + if data[0] == 'CHANGEDSINCE' + NumValidator.ensure_mod_sequence_value(data[1]) + else + data.each do |i| + validate_data(i) + end + end + when Time + when Symbol + else + data.validate + end + end + + def send_data(data, tag = nil) case data when nil put_string("NIL") when String - send_string_data(data) + send_string_data(data, tag) when Integer send_number_data(data) when Array - send_list_data(data) + send_list_data(data, tag) when Time send_time_data(data) when Symbol send_symbol_data(data) else - data.send_data(self) + data.send_data(self, tag) end end - def send_string_data(str) + def send_string_data(str, tag = nil) case str when "" put_string('""') when /[\x80-\xff\r\n]/n # literal - send_literal(str) + send_literal(str, tag) when /[(){ \x00-\x1f\x7f%*"\\]/n # quoted string send_quoted_string(str) @@ -1086,25 +1351,33 @@ module Net put_string(str) end end - + def send_quoted_string(str) put_string('"' + str.gsub(/["\\]/n, "\\\\\\&") + '"') end - def send_literal(str) - put_string("{" + str.length.to_s + "}" + CRLF) - @continuation_request_arrival.wait - put_string(str) + def send_literal(str, tag = nil) + synchronize do + put_string("{" + str.bytesize.to_s + "}" + CRLF) + @continued_command_tag = tag + @continuation_request_exception = nil + begin + @continuation_request_arrival.wait + e = @continuation_request_exception || @exception + raise e if e + put_string(str) + ensure + @continued_command_tag = nil + @continuation_request_exception = nil + end + end end def send_number_data(num) - if num < 0 || num >= 4294967296 - raise DataFormatError, num.to_s - end put_string(num.to_s) end - def send_list_data(list) + def send_list_data(list, tag = nil) put_string("(") first = true list.each do |i| @@ -1113,7 +1386,7 @@ module Net else put_string(" ") end - send_data(i) + send_data(i, tag) end put_string(")") end @@ -1148,13 +1421,23 @@ module Net end end - def fetch_internal(cmd, set, attr) - if attr.instance_of?(String) + def fetch_internal(cmd, set, attr, mod = nil) + case attr + when String then attr = RawData.new(attr) + when Array then + attr = attr.map { |arg| + arg.is_a?(String) ? RawData.new(arg) : arg + } end + synchronize do @responses.delete("FETCH") - send_command(cmd, MessageSet.new(set), attr) + if mod + send_command(cmd, MessageSet.new(set), attr, mod) + else + send_command(cmd, MessageSet.new(set), attr) + end return @responses.delete("FETCH") end end @@ -1209,130 +1492,57 @@ module Net end end - def self.u16tou8(s) - len = s.length - if len < 2 - return "" - end - buf = "" - i = 0 - while i < len - c = s[i] << 8 | s[i + 1] - i += 2 - if c == 0xfeff - next - elsif c < 0x0080 - buf.concat(c) - elsif c < 0x0800 - b2 = c & 0x003f - b1 = c >> 6 - buf.concat(b1 | 0xc0) - buf.concat(b2 | 0x80) - elsif c >= 0xdc00 && c < 0xe000 - raise DataFormatError, "invalid surrogate detected" - elsif c >= 0xd800 && c < 0xdc00 - if i + 2 > len - raise DataFormatError, "invalid surrogate detected" - end - low = s[i] << 8 | s[i + 1] - i += 2 - if low < 0xdc00 || low > 0xdfff - raise DataFormatError, "invalid surrogate detected" - end - c = (((c & 0x03ff)) << 10 | (low & 0x03ff)) + 0x10000 - b4 = c & 0x003f - b3 = (c >> 6) & 0x003f - b2 = (c >> 12) & 0x003f - b1 = c >> 18; - buf.concat(b1 | 0xf0) - buf.concat(b2 | 0x80) - buf.concat(b3 | 0x80) - buf.concat(b4 | 0x80) - else # 0x0800-0xffff - b3 = c & 0x003f - b2 = (c >> 6) & 0x003f - b1 = c >> 12 - buf.concat(b1 | 0xe0) - buf.concat(b2 | 0x80) - buf.concat(b3 | 0x80) - end - end - return buf - end - private_class_method :u16tou8 - - def self.u8tou16(s) - len = s.length - buf = "" - i = 0 - while i < len - c = s[i] - if (c & 0x80) == 0 - buf.concat(0x00) - buf.concat(c) - i += 1 - elsif (c & 0xe0) == 0xc0 && - len >= 2 && - (s[i + 1] & 0xc0) == 0x80 - if c == 0xc0 || c == 0xc1 - raise DataFormatError, format("non-shortest UTF-8 sequence (%02x)", c) - end - u = ((c & 0x1f) << 6) | (s[i + 1] & 0x3f) - buf.concat(u >> 8) - buf.concat(u & 0x00ff) - i += 2 - elsif (c & 0xf0) == 0xe0 && - i + 2 < len && - (s[i + 1] & 0xc0) == 0x80 && - (s[i + 2] & 0xc0) == 0x80 - if c == 0xe0 && s[i + 1] < 0xa0 - raise DataFormatError, format("non-shortest UTF-8 sequence (%02x)", c) - end - u = ((c & 0x0f) << 12) | ((s[i + 1] & 0x3f) << 6) | (s[i + 2] & 0x3f) - # surrogate chars - if u >= 0xd800 && u <= 0xdfff - raise DataFormatError, format("none-UTF-16 char detected (%04x)", u) - end - buf.concat(u >> 8) - buf.concat(u & 0x00ff) - i += 3 - elsif (c & 0xf8) == 0xf0 && - i + 3 < len && - (s[i + 1] & 0xc0) == 0x80 && - (s[i + 2] & 0xc0) == 0x80 && - (s[i + 3] & 0xc0) == 0x80 - if c == 0xf0 && s[i + 1] < 0x90 - raise DataFormatError, format("non-shortest UTF-8 sequence (%02x)", c) - end - u = ((c & 0x07) << 18) | ((s[i + 1] & 0x3f) << 12) | - ((s[i + 2] & 0x3f) << 6) | (s[i + 3] & 0x3f) - if u < 0x10000 - buf.concat(u >> 8) - buf.concat(u & 0x00ff) - elsif u < 0x110000 - high = ((u - 0x10000) >> 10) | 0xd800 - low = (u & 0x03ff) | 0xdc00 - buf.concat(high >> 8) - buf.concat(high & 0x00ff) - buf.concat(low >> 8) - buf.concat(low & 0x00ff) - else - raise DataFormatError, format("none-UTF-16 char detected (%04x)", u) - end - i += 4 - else - raise DataFormatError, format("illegal UTF-8 sequence (%02x)", c) + def create_ssl_params(certs = nil, verify = true) + params = {} + if certs + if File.file?(certs) + params[:ca_file] = certs + elsif File.directory?(certs) + params[:ca_path] = certs end end - return buf + if verify + params[:verify_mode] = VERIFY_PEER + else + params[:verify_mode] = VERIFY_NONE + end + return params + end + + def start_tls_session(params = {}) + unless defined?(OpenSSL::SSL) + raise "SSL extension not installed" + end + if @sock.kind_of?(OpenSSL::SSL::SSLSocket) + raise RuntimeError, "already using SSL" + end + begin + params = params.to_hash + rescue NoMethodError + params = {} + end + context = SSLContext.new + context.set_params(params) + if defined?(VerifyCallbackProc) + context.verify_callback = VerifyCallbackProc + end + @sock = SSLSocket.new(@sock, context) + @sock.sync_close = true + @sock.hostname = @host if @sock.respond_to? :hostname= + ssl_socket_connect(@sock, @open_timeout) + if context.verify_mode != VERIFY_NONE + @sock.post_connection_check(@host) + end end - private_class_method :u8tou16 class RawData # :nodoc: - def send_data(imap) + def send_data(imap, tag) imap.send(:put_string, @data) end + def validate + end + private def initialize(data) @@ -1341,10 +1551,13 @@ module Net end class Atom # :nodoc: - def send_data(imap) + def send_data(imap, tag) imap.send(:put_string, @data) end + def validate + end + private def initialize(data) @@ -1353,10 +1566,13 @@ module Net end class QuotedString # :nodoc: - def send_data(imap) + def send_data(imap, tag) imap.send(:send_quoted_string, @data) end + def validate + end + private def initialize(data) @@ -1365,8 +1581,11 @@ module Net end class Literal # :nodoc: - def send_data(imap) - imap.send(:send_literal, @data) + def send_data(imap, tag) + imap.send(:send_literal, @data, tag) + end + + def validate end private @@ -1377,10 +1596,14 @@ module Net end class MessageSet # :nodoc: - def send_data(imap) + def send_data(imap, tag) imap.send(:put_string, format_internal(@data)) end + def validate + validate_internal(@data) + end + private def initialize(data) @@ -1392,7 +1615,6 @@ module Net when "*" return data when Integer - ensure_nz_number(data) if data == -1 return "*" else @@ -1406,124 +1628,188 @@ module Net when ThreadMember return data.seqno.to_s + ":" + data.children.collect {|i| format_internal(i).join(",")} + end + end + + def validate_internal(data) + case data + when "*" + when Integer + NumValidator.ensure_nz_number(data) + when Range + when Array + data.each do |i| + validate_internal(i) + end + when ThreadMember + data.children.each do |i| + validate_internal(i) + end else raise DataFormatError, data.inspect end end + end + + # Common validators of number and nz_number types + module NumValidator # :nodoc + class << self + # Check is passed argument valid 'number' in RFC 3501 terminology + def valid_number?(num) + # [RFC 3501] + # number = 1*DIGIT + # ; Unsigned 32-bit integer + # ; (0 <= n < 4,294,967,296) + num >= 0 && num < 4294967296 + end + + # Check is passed argument valid 'nz_number' in RFC 3501 terminology + def valid_nz_number?(num) + # [RFC 3501] + # nz-number = digit-nz *DIGIT + # ; Non-zero unsigned 32-bit integer + # ; (0 < n < 4,294,967,296) + num != 0 && valid_number?(num) + end - def ensure_nz_number(num) - if num < -1 || num == 0 || num >= 4294967296 - msg = "nz_number must be non-zero unsigned 32-bit integer: " + - num.inspect + # Check is passed argument valid 'mod_sequence_value' in RFC 4551 terminology + def valid_mod_sequence_value?(num) + # mod-sequence-value = 1*DIGIT + # ; Positive unsigned 64-bit integer + # ; (mod-sequence) + # ; (1 <= n < 18,446,744,073,709,551,615) + num >= 1 && num < 18446744073709551615 + end + + # Ensure argument is 'number' or raise DataFormatError + def ensure_number(num) + return if valid_number?(num) + + msg = "number must be unsigned 32-bit integer: #{num}" + raise DataFormatError, msg + end + + # Ensure argument is 'nz_number' or raise DataFormatError + def ensure_nz_number(num) + return if valid_nz_number?(num) + + msg = "nz_number must be non-zero unsigned 32-bit integer: #{num}" + raise DataFormatError, msg + end + + # Ensure argument is 'mod_sequence_value' or raise DataFormatError + def ensure_mod_sequence_value(num) + return if valid_mod_sequence_value?(num) + + msg = "mod_sequence_value must be unsigned 64-bit integer: #{num}" raise DataFormatError, msg end end end # Net::IMAP::ContinuationRequest represents command continuation requests. - # + # # The command continuation request response is indicated by a "+" token # instead of a tag. This form of response indicates that the server is # ready to accept the continuation of a command from the client. The # remainder of this response is a line of text. - # + # # continue_req ::= "+" SPACE (resp_text / base64) - # + # # ==== Fields: - # + # # data:: Returns the data (Net::IMAP::ResponseText). - # + # # raw_data:: Returns the raw data string. ContinuationRequest = Struct.new(:data, :raw_data) # Net::IMAP::UntaggedResponse represents untagged responses. - # + # # Data transmitted by the server to the client and status responses # that do not indicate command completion are prefixed with the token # "*", and are called untagged responses. - # + # # response_data ::= "*" SPACE (resp_cond_state / resp_cond_bye / # mailbox_data / message_data / capability_data) - # + # # ==== Fields: - # - # name:: Returns the name such as "FLAGS", "LIST", "FETCH".... - # + # + # name:: Returns the name, such as "FLAGS", "LIST", or "FETCH". + # # data:: Returns the data such as an array of flag symbols, - # a ((<Net::IMAP::MailboxList>)) object.... - # + # a ((<Net::IMAP::MailboxList>)) object. + # # raw_data:: Returns the raw data string. UntaggedResponse = Struct.new(:name, :data, :raw_data) - + # Net::IMAP::TaggedResponse represents tagged responses. - # + # # The server completion result response indicates the success or # failure of the operation. It is tagged with the same tag as the # client command which began the operation. - # + # # response_tagged ::= tag SPACE resp_cond_state CRLF - # + # # tag ::= 1*<any ATOM_CHAR except "+"> - # + # # resp_cond_state ::= ("OK" / "NO" / "BAD") SPACE resp_text - # + # # ==== Fields: - # + # # tag:: Returns the tag. - # - # name:: Returns the name. the name is one of "OK", "NO", "BAD". - # + # + # name:: Returns the name, one of "OK", "NO", or "BAD". + # # data:: Returns the data. See ((<Net::IMAP::ResponseText>)). - # + # # raw_data:: Returns the raw data string. # TaggedResponse = Struct.new(:tag, :name, :data, :raw_data) - + # Net::IMAP::ResponseText represents texts of responses. # The text may be prefixed by the response code. - # + # # resp_text ::= ["[" resp_text_code "]" SPACE] (text_mime2 / text) # ;; text SHOULD NOT begin with "[" or "=" - # + # # ==== Fields: - # + # # code:: Returns the response code. See ((<Net::IMAP::ResponseCode>)). - # + # # text:: Returns the text. - # + # ResponseText = Struct.new(:code, :text) - # # Net::IMAP::ResponseCode represents response codes. - # + # # resp_text_code ::= "ALERT" / "PARSE" / # "PERMANENTFLAGS" SPACE "(" #(flag / "\*") ")" / # "READ-ONLY" / "READ-WRITE" / "TRYCREATE" / # "UIDVALIDITY" SPACE nz_number / # "UNSEEN" SPACE nz_number / # atom [SPACE 1*<any TEXT_CHAR except "]">] - # + # # ==== Fields: - # - # name:: Returns the name such as "ALERT", "PERMANENTFLAGS", "UIDVALIDITY".... - # - # data:: Returns the data if it exists. + # + # name:: Returns the name, such as "ALERT", "PERMANENTFLAGS", or "UIDVALIDITY". + # + # data:: Returns the data, if it exists. # ResponseCode = Struct.new(:name, :data) # Net::IMAP::MailboxList represents contents of the LIST response. - # + # # mailbox_list ::= "(" #("\Marked" / "\Noinferiors" / # "\Noselect" / "\Unmarked" / flag_extension) ")" # SPACE (<"> QUOTED_CHAR <"> / nil) SPACE mailbox - # + # # ==== Fields: - # + # # attr:: Returns the name attributes. Each name attribute is a symbol # capitalized by String#capitalize, such as :Noselect (not :NoSelect). - # - # delim:: Returns the hierarchy delimiter - # + # + # delim:: Returns the hierarchy delimiter. + # # name:: Returns the mailbox name. # MailboxList = Struct.new(:attr, :delim, :name) @@ -1532,78 +1818,78 @@ module Net # This object can also be a response to GETQUOTAROOT. In the syntax # specification below, the delimiter used with the "#" construct is a # single space (SPACE). - # + # # quota_list ::= "(" #quota_resource ")" - # + # # quota_resource ::= atom SPACE number SPACE number - # + # # quota_response ::= "QUOTA" SPACE astring SPACE quota_list - # + # # ==== Fields: - # + # # mailbox:: The mailbox with the associated quota. - # - # usage:: Current storage usage of mailbox. - # - # quota:: Quota limit imposed on mailbox. + # + # usage:: Current storage usage of the mailbox. + # + # quota:: Quota limit imposed on the mailbox. # MailboxQuota = Struct.new(:mailbox, :usage, :quota) # Net::IMAP::MailboxQuotaRoot represents part of the GETQUOTAROOT # response. (GETQUOTAROOT can also return Net::IMAP::MailboxQuota.) - # + # # quotaroot_response ::= "QUOTAROOT" SPACE astring *(SPACE astring) - # + # # ==== Fields: - # + # # mailbox:: The mailbox with the associated quota. - # - # quotaroots:: Zero or more quotaroots that effect the quota on the + # + # quotaroots:: Zero or more quotaroots that affect the quota on the # specified mailbox. # MailboxQuotaRoot = Struct.new(:mailbox, :quotaroots) - # Net::IMAP::MailboxACLItem represents response from GETACL. - # + # Net::IMAP::MailboxACLItem represents the response from GETACL. + # # acl_data ::= "ACL" SPACE mailbox *(SPACE identifier SPACE rights) - # + # # identifier ::= astring - # + # # rights ::= astring - # + # # ==== Fields: - # + # # user:: Login name that has certain rights to the mailbox # that was specified with the getacl command. - # + # # rights:: The access rights the indicated user has to the # mailbox. # - MailboxACLItem = Struct.new(:user, :rights) + MailboxACLItem = Struct.new(:user, :rights, :mailbox) - # Net::IMAP::StatusData represents contents of the STATUS response. - # + # Net::IMAP::StatusData represents the contents of the STATUS response. + # # ==== Fields: - # + # # mailbox:: Returns the mailbox name. - # + # # attr:: Returns a hash. Each key is one of "MESSAGES", "RECENT", "UIDNEXT", # "UIDVALIDITY", "UNSEEN". Each value is a number. - # + # StatusData = Struct.new(:mailbox, :attr) - # Net::IMAP::FetchData represents contents of the FETCH response. - # + # Net::IMAP::FetchData represents the contents of the FETCH response. + # # ==== Fields: - # + # # seqno:: Returns the message sequence number. # (Note: not the unique identifier, even for the UID command response.) - # + # # attr:: Returns a hash. Each key is a data item name, and each value is # its value. - # + # # The current data items are: - # + # # [BODY] # A form of BODYSTRUCTURE without extension data. # [BODY[<section>]<<origin_octet>>] @@ -1616,7 +1902,7 @@ module Net # A Net::IMAP::Envelope object that describes the envelope # structure of a message. # [FLAGS] - # A array of flag symbols that are set for this message. flag symbols + # A array of flag symbols that are set for this message. Flag symbols # are capitalized by String#capitalize. # [INTERNALDATE] # A string representing the internal date of the message. @@ -1630,110 +1916,110 @@ module Net # Equivalent to BODY[TEXT]. # [UID] # A number expressing the unique identifier of the message. - # + # FetchData = Struct.new(:seqno, :attr) # Net::IMAP::Envelope represents envelope structures of messages. - # + # # ==== Fields: - # + # # date:: Returns a string that represents the date. - # + # # subject:: Returns a string that represents the subject. - # + # # from:: Returns an array of Net::IMAP::Address that represents the from. - # + # # sender:: Returns an array of Net::IMAP::Address that represents the sender. - # + # # reply_to:: Returns an array of Net::IMAP::Address that represents the reply-to. - # + # # to:: Returns an array of Net::IMAP::Address that represents the to. - # + # # cc:: Returns an array of Net::IMAP::Address that represents the cc. - # + # # bcc:: Returns an array of Net::IMAP::Address that represents the bcc. - # + # # in_reply_to:: Returns a string that represents the in-reply-to. - # + # # message_id:: Returns a string that represents the message-id. - # + # Envelope = Struct.new(:date, :subject, :from, :sender, :reply_to, :to, :cc, :bcc, :in_reply_to, :message_id) - # + # # Net::IMAP::Address represents electronic mail addresses. - # + # # ==== Fields: - # + # # name:: Returns the phrase from [RFC-822] mailbox. - # + # # route:: Returns the route from [RFC-822] route-addr. - # + # # mailbox:: nil indicates end of [RFC-822] group. # If non-nil and host is nil, returns [RFC-822] group name. - # Otherwise, returns [RFC-822] local-part - # + # Otherwise, returns [RFC-822] local-part. + # # host:: nil indicates [RFC-822] group syntax. # Otherwise, returns [RFC-822] domain name. # Address = Struct.new(:name, :route, :mailbox, :host) - # + # # Net::IMAP::ContentDisposition represents Content-Disposition fields. - # + # # ==== Fields: - # + # # dsp_type:: Returns the disposition type. - # + # # param:: Returns a hash that represents parameters of the Content-Disposition # field. - # + # ContentDisposition = Struct.new(:dsp_type, :param) - # Net::IMAP::ThreadMember represents a thread-node returned - # by Net::IMAP#thread + # Net::IMAP::ThreadMember represents a thread-node returned + # by Net::IMAP#thread. # # ==== Fields: # # seqno:: The sequence number of this message. # - # children:: an array of Net::IMAP::ThreadMember objects for mail - # items that are children of this in the thread. + # children:: An array of Net::IMAP::ThreadMember objects for mail + # items that are children of this in the thread. # ThreadMember = Struct.new(:seqno, :children) # Net::IMAP::BodyTypeBasic represents basic body structures of messages. - # + # # ==== Fields: - # + # # media_type:: Returns the content media type name as defined in [MIME-IMB]. - # + # # subtype:: Returns the content subtype name as defined in [MIME-IMB]. - # + # # param:: Returns a hash that represents parameters as defined in [MIME-IMB]. - # + # # content_id:: Returns a string giving the content id as defined in [MIME-IMB]. - # + # # description:: Returns a string giving the content description as defined in # [MIME-IMB]. - # + # # encoding:: Returns a string giving the content transfer encoding as defined in # [MIME-IMB]. - # + # # size:: Returns a number giving the size of the body in octets. - # + # # md5:: Returns a string giving the body MD5 value as defined in [MD5]. - # + # # disposition:: Returns a Net::IMAP::ContentDisposition object giving # the content disposition. - # + # # language:: Returns a string or an array of strings giving the body # language value as defined in [LANGUAGE-TAGS]. - # + # # extension:: Returns extension data. - # + # # multipart?:: Returns false. - # + # class BodyTypeBasic < Struct.new(:media_type, :subtype, :param, :content_id, :description, :encoding, :size, @@ -1744,23 +2030,22 @@ module Net end # Obsolete: use +subtype+ instead. Calling this will - # generate a warning message to +stderr+, then return + # generate a warning message to +stderr+, then return # the value of +subtype+. def media_subtype - $stderr.printf("warning: media_subtype is obsolete.\n") - $stderr.printf(" use subtype instead.\n") + warn("media_subtype is obsolete, use subtype instead.\n", uplevel: 1) return subtype end end # Net::IMAP::BodyTypeText represents TEXT body structures of messages. - # + # # ==== Fields: - # + # # lines:: Returns the size of the body in text lines. - # + # # And Net::IMAP::BodyTypeText has all fields of Net::IMAP::BodyTypeBasic. - # + # class BodyTypeText < Struct.new(:media_type, :subtype, :param, :content_id, :description, :encoding, :size, @@ -1772,23 +2057,22 @@ module Net end # Obsolete: use +subtype+ instead. Calling this will - # generate a warning message to +stderr+, then return + # generate a warning message to +stderr+, then return # the value of +subtype+. def media_subtype - $stderr.printf("warning: media_subtype is obsolete.\n") - $stderr.printf(" use subtype instead.\n") + warn("media_subtype is obsolete, use subtype instead.\n", uplevel: 1) return subtype end end # Net::IMAP::BodyTypeMessage represents MESSAGE/RFC822 body structures of messages. - # + # # ==== Fields: - # + # # envelope:: Returns a Net::IMAP::Envelope giving the envelope structure. - # + # # body:: Returns an object giving the body structure. - # + # # And Net::IMAP::BodyTypeMessage has all methods of Net::IMAP::BodyTypeText. # class BodyTypeMessage < Struct.new(:media_type, :subtype, @@ -1802,38 +2086,57 @@ module Net end # Obsolete: use +subtype+ instead. Calling this will - # generate a warning message to +stderr+, then return + # generate a warning message to +stderr+, then return # the value of +subtype+. def media_subtype - $stderr.printf("warning: media_subtype is obsolete.\n") - $stderr.printf(" use subtype instead.\n") + warn("media_subtype is obsolete, use subtype instead.\n", uplevel: 1) return subtype end end - # Net::IMAP::BodyTypeMultipart represents multipart body structures + # Net::IMAP::BodyTypeAttachment represents attachment body structures # of messages. - # + # # ==== Fields: - # + # + # media_type:: Returns the content media type name. + # + # subtype:: Returns +nil+. + # + # param:: Returns a hash that represents parameters. + # + # multipart?:: Returns false. + # + class BodyTypeAttachment < Struct.new(:media_type, :subtype, + :param) + def multipart? + return false + end + end + + # Net::IMAP::BodyTypeMultipart represents multipart body structures + # of messages. + # + # ==== Fields: + # # media_type:: Returns the content media type name as defined in [MIME-IMB]. - # + # # subtype:: Returns the content subtype name as defined in [MIME-IMB]. - # + # # parts:: Returns multiple parts. - # + # # param:: Returns a hash that represents parameters as defined in [MIME-IMB]. - # + # # disposition:: Returns a Net::IMAP::ContentDisposition object giving # the content disposition. - # + # # language:: Returns a string or an array of strings giving the body # language value as defined in [LANGUAGE-TAGS]. - # + # # extension:: Returns extension data. - # + # # multipart?:: Returns true. - # + # class BodyTypeMultipart < Struct.new(:media_type, :subtype, :parts, :param, :disposition, :language, @@ -1843,16 +2146,31 @@ module Net end # Obsolete: use +subtype+ instead. Calling this will - # generate a warning message to +stderr+, then return + # generate a warning message to +stderr+, then return # the value of +subtype+. def media_subtype - $stderr.printf("warning: media_subtype is obsolete.\n") - $stderr.printf(" use subtype instead.\n") + warn("media_subtype is obsolete, use subtype instead.\n", uplevel: 1) return subtype end end + class BodyTypeExtension < Struct.new(:media_type, :subtype, + :params, :content_id, + :description, :encoding, :size) + def multipart? + return false + end + end + class ResponseParser # :nodoc: + def initialize + @str = nil + @pos = nil + @lex_state = nil + @token = nil + @flag_symbols = {} + end + def parse(str) @str = str @pos = 0 @@ -1888,7 +2206,7 @@ module Net T_TEXT = :TEXT BEG_REGEXP = /\G(?:\ -(?# 1: SPACE )( )|\ +(?# 1: SPACE )( +)|\ (?# 2: NIL )(NIL)(?=[\x80-\xff(){ \x00-\x1f\x7f%*"\\\[\]+])|\ (?# 3: NUMBER )(\d+)(?=[\x80-\xff(){ \x00-\x1f\x7f%*"\\\[\]+])|\ (?# 4: ATOM )([^\x80-\xff(){ \x00-\x1f\x7f%*"\\\[\]+]+)|\ @@ -1936,6 +2254,10 @@ module Net else result = response_tagged end + while lookahead.symbol == T_SPACE + # Ignore trailing space for Microsoft Exchange Server + shift_token + end match(T_CRLF) match(T_EOF) return result @@ -1943,8 +2265,13 @@ module Net def continue_req match(T_PLUS) - match(T_SPACE) - return ContinuationRequest.new(resp_text, @str) + token = lookahead + if token.symbol == T_SPACE + shift_token + return ContinuationRequest.new(resp_text, @str) + else + return ContinuationRequest.new(ResponseText.new(nil, ""), @str) + end end def response_untagged @@ -1959,7 +2286,7 @@ module Net return response_cond when /\A(?:FLAGS)\z/ni return flags_response - when /\A(?:LIST|LSUB)\z/ni + when /\A(?:LIST|LSUB|XLIST)\z/ni return list_response when /\A(?:QUOTA)\z/ni return getquota_response @@ -2010,12 +2337,12 @@ module Net when "FETCH" shift_token match(T_SPACE) - data = FetchData.new(n, msg_att) + data = FetchData.new(n, msg_att(n)) return UntaggedResponse.new(name, data, @str) end end - def msg_att + def msg_att(n) match(T_LPAR) attr = {} while true @@ -2026,7 +2353,7 @@ module Net break when T_SPACE shift_token - token = lookahead + next end case token.value when /\A(?:ENVELOPE)\z/ni @@ -2043,8 +2370,10 @@ module Net name, val = body_data when /\A(?:UID)\z/ni name, val = uid_data + when /\A(?:MODSEQ)\z/ni + name, val = modseq_data else - parse_error("unknown attribute `%s'", token.value) + parse_error("unknown attribute `%s' for {%d}", token.value, n) end attr[name] = val end @@ -2111,6 +2440,11 @@ module Net def rfc822_text token = match(T_ATOM) name = token.value.upcase + token = lookahead + if token.symbol == T_LBRA + shift_token + match(T_RBRA) + end match(T_SPACE) return name, nstring end @@ -2168,6 +2502,10 @@ module Net return body_type_text when /\A(?:MESSAGE)\z/ni return body_type_msg + when /\A(?:ATTACHMENT)\z/ni + return body_type_attachment + when /\A(?:MIXED)\z/ni + return body_type_mixed else return body_type_basic end @@ -2206,6 +2544,29 @@ module Net mtype, msubtype = media_type match(T_SPACE) param, content_id, desc, enc, size = body_fields + + token = lookahead + if token.symbol == T_RPAR + # If this is not message/rfc822, we shouldn't apply the RFC822 + # spec to it. We should handle anything other than + # message/rfc822 using multipart extension data [rfc3501] (i.e. + # the data itself won't be returned, we would have to retrieve it + # with BODYSTRUCTURE instead of with BODY + + # Also, sometimes a message/rfc822 is included as a large + # attachment instead of having all of the other details + # (e.g. attaching a .eml file to an email) + if msubtype == "RFC822" + return BodyTypeMessage.new(mtype, msubtype, param, content_id, + desc, enc, size, nil, nil, nil, nil, + nil, nil, nil) + else + return BodyTypeExtension.new(mtype, msubtype, + param, content_id, + desc, enc, size) + end + end + match(T_SPACE) env = envelope match(T_SPACE) @@ -2220,6 +2581,20 @@ module Net md5, disposition, language, extension) end + def body_type_attachment + mtype = case_insensitive_string + match(T_SPACE) + param = body_fld_param + return BodyTypeAttachment.new(mtype, nil, param) + end + + def body_type_mixed + mtype = "MULTIPART" + msubtype = case_insensitive_string + param, disposition, language, extension = body_ext_mpart + return BodyTypeBasic.new(mtype, msubtype, param, nil, nil, nil, nil, nil, disposition, language, extension) + end + def body_type_mpart parts = [] while true @@ -2240,6 +2615,10 @@ module Net def media_type mtype = case_insensitive_string + token = lookahead + if token.symbol != T_SPACE + return mtype, nil + end match(T_SPACE) msubtype = case_insensitive_string return mtype, msubtype @@ -2335,7 +2714,13 @@ module Net return param end disposition = body_fld_dsp - match(T_SPACE) + + token = lookahead + if token.symbol == T_SPACE + shift_token + else + return param, disposition + end language = body_fld_lang token = lookahead @@ -2419,7 +2804,7 @@ module Net end def section - str = "" + str = String.new token = match(T_LBRA) str.concat(token.value) token = match(T_ATOM, T_NUMBER, T_RBRA) @@ -2459,7 +2844,7 @@ module Net return '""' when /[\x80-\xff\r\n]/n # literal - return "{" + str.length.to_s + "}" + CRLF + str + return "{" + str.bytesize.to_s + "}" + CRLF + str when /[(){ \x00-\x1f\x7f%*"\\]/n # quoted string return '"' + str.gsub(/["\\]/n, "\\\\\\&") + '"' @@ -2476,6 +2861,16 @@ module Net return name, number end + def modseq_data + token = match(T_ATOM) + name = token.value.upcase + match(T_SPACE) + match(T_LPAR) + modseq = number + match(T_RPAR) + return name, modseq + end + def text_response token = match(T_ATOM) name = token.value.upcase @@ -2584,8 +2979,7 @@ module Net user = astring match(T_SPACE) rights = astring - ##XXX data.push([user, rights]) - data.push(MailboxACLItem.new(user, rights)) + data.push(MailboxACLItem.new(user, rights, mailbox)) end end return UntaggedResponse.new(name, data, @str) @@ -2605,8 +2999,16 @@ module Net break when T_SPACE shift_token + when T_NUMBER + data.push(number) + when T_LPAR + # TODO: include the MODSEQ value in a response + shift_token + match(T_ATOM) + match(T_SPACE) + match(T_NUMBER) + match(T_RPAR) end - data.push(number) end else data = [] @@ -2644,35 +3046,35 @@ module Net def thread_branch(token) rootmember = nil lastmember = nil - + while true shift_token # ignore first T_LPAR token = lookahead - + case token.symbol when T_NUMBER # new member newmember = ThreadMember.new(number, []) if rootmember.nil? rootmember = newmember - else + else lastmember.children << newmember - end + end lastmember = newmember - when T_SPACE - # do nothing + when T_SPACE + # do nothing when T_LPAR if rootmember.nil? # dummy member lastmember = rootmember = ThreadMember.new(nil, []) - end - + end + lastmember.children << thread_branch(token) when T_RPAR - break - end + break + end end - + return rootmember end @@ -2715,6 +3117,7 @@ module Net break when T_SPACE shift_token + next end data.push(atom.upcase) end @@ -2740,7 +3143,7 @@ module Net token = match(T_ATOM) name = token.value.upcase case name - when /\A(?:ALERT|PARSE|READ-ONLY|READ-WRITE|TRYCREATE)\z/n + when /\A(?:ALERT|PARSE|READ-ONLY|READ-WRITE|TRYCREATE|NOMODSEQ)\z/n result = ResponseCode.new(name, nil) when /\A(?:PERMANENTFLAGS)\z/n match(T_SPACE) @@ -2749,11 +3152,16 @@ module Net match(T_SPACE) result = ResponseCode.new(name, number) else - match(T_SPACE) - @lex_state = EXPR_CTEXT - token = match(T_TEXT) - @lex_state = EXPR_BEG - result = ResponseCode.new(name, token.value) + token = lookahead + if token.symbol == T_SPACE + shift_token + @lex_state = EXPR_CTEXT + token = match(T_TEXT) + @lex_state = EXPR_BEG + result = ResponseCode.new(name, token.value) + else + result = ResponseCode.new(name, nil) + end end match(T_RBRA) @lex_state = EXPR_RTEXT @@ -2817,39 +3225,6 @@ module Net return Address.new(name, route, mailbox, host) end -# def flag_list -# result = [] -# match(T_LPAR) -# while true -# token = lookahead -# case token.symbol -# when T_RPAR -# shift_token -# break -# when T_SPACE -# shift_token -# end -# result.push(flag) -# end -# return result -# end - -# def flag -# token = lookahead -# if token.symbol == T_BSLASH -# shift_token -# token = lookahead -# if token.symbol == T_STAR -# shift_token -# return token.value.intern -# else -# return atom.intern -# end -# else -# return atom -# end -# end - FLAG_REGEXP = /\ (?# FLAG )\\([^\x80-\xff(){ \x00-\x1f\x7f%"\\]+)|\ (?# ATOM )([^\x80-\xff(){ \x00-\x1f\x7f%*"\\]+)/n @@ -2858,7 +3233,16 @@ module Net if @str.index(/\(([^)]*)\)/ni, @pos) @pos = $~.end(0) return $1.scan(FLAG_REGEXP).collect { |flag, atom| - atom || flag.capitalize.intern + if atom + atom + else + symbol = flag.capitalize.untaint.intern + @flag_symbols[symbol] = true + if @flag_symbols.length > IMAP.max_flag_count + raise FlagCountError, "number of flag symbols exceeded" + end + symbol + end } else parse_error("invalid flag list") @@ -2911,7 +3295,7 @@ module Net end def atom - result = "" + result = String.new while true token = lookahead if atom_token?(token) @@ -3092,7 +3476,7 @@ module Net parse_error("unknown token - %s", $&.dump) end else - parse_error("illegal @lex_state - %s", @lex_state.inspect) + parse_error("invalid @lex_state - %s", @lex_state.inspect) end end @@ -3136,6 +3520,22 @@ module Net end add_authenticator "LOGIN", LoginAuthenticator + # Authenticator for the "PLAIN" authentication type. See + # #authenticate(). + class PlainAuthenticator + def process(data) + return "\0#{@user}\0#{@password}" + end + + private + + def initialize(user, password) + @user = user + @password = password + end + end + add_authenticator "PLAIN", PlainAuthenticator + # Authenticator for the "CRAM-MD5" authentication type. See # #authenticate(). class CramMD5Authenticator @@ -3159,8 +3559,8 @@ module Net k_ipad = key + "\0" * (64 - key.length) k_opad = key + "\0" * (64 - key.length) for i in 0..63 - k_ipad[i] ^= 0x36 - k_opad[i] ^= 0x5c + k_ipad[i] = (k_ipad[i].ord ^ 0x36).chr + k_opad[i] = (k_opad[i].ord ^ 0x5c).chr end digest = Digest::MD5.digest(k_ipad + text) @@ -3170,6 +3570,106 @@ module Net end add_authenticator "CRAM-MD5", CramMD5Authenticator + # Authenticator for the "DIGEST-MD5" authentication type. See + # #authenticate(). + class DigestMD5Authenticator + def process(challenge) + case @stage + when STAGE_ONE + @stage = STAGE_TWO + sparams = {} + c = StringScanner.new(challenge) + while c.scan(/(?:\s*,)?\s*(\w+)=("(?:[^\\"]+|\\.)*"|[^,]+)\s*/) + k, v = c[1], c[2] + if v =~ /^"(.*)"$/ + v = $1 + if v =~ /,/ + v = v.split(',') + end + end + sparams[k] = v + end + + raise DataFormatError, "Bad Challenge: '#{challenge}'" unless c.rest.size == 0 + raise Error, "Server does not support auth (qop = #{sparams['qop'].join(',')})" unless sparams['qop'].include?("auth") + + response = { + :nonce => sparams['nonce'], + :username => @user, + :realm => sparams['realm'], + :cnonce => Digest::MD5.hexdigest("%.15f:%.15f:%d" % [Time.now.to_f, rand, Process.pid.to_s]), + :'digest-uri' => 'imap/' + sparams['realm'], + :qop => 'auth', + :maxbuf => 65535, + :nc => "%08d" % nc(sparams['nonce']), + :charset => sparams['charset'], + } + + response[:authzid] = @authname unless @authname.nil? + + # now, the real thing + a0 = Digest::MD5.digest( [ response.values_at(:username, :realm), @password ].join(':') ) + + a1 = [ a0, response.values_at(:nonce,:cnonce) ].join(':') + a1 << ':' + response[:authzid] unless response[:authzid].nil? + + a2 = "AUTHENTICATE:" + response[:'digest-uri'] + a2 << ":00000000000000000000000000000000" if response[:qop] and response[:qop] =~ /^auth-(?:conf|int)$/ + + response[:response] = Digest::MD5.hexdigest( + [ + Digest::MD5.hexdigest(a1), + response.values_at(:nonce, :nc, :cnonce, :qop), + Digest::MD5.hexdigest(a2) + ].join(':') + ) + + return response.keys.map {|key| qdval(key.to_s, response[key]) }.join(',') + when STAGE_TWO + @stage = nil + # if at the second stage, return an empty string + if challenge =~ /rspauth=/ + return '' + else + raise ResponseParseError, challenge + end + else + raise ResponseParseError, challenge + end + end + + def initialize(user, password, authname = nil) + @user, @password, @authname = user, password, authname + @nc, @stage = {}, STAGE_ONE + end + + private + + STAGE_ONE = :stage_one + STAGE_TWO = :stage_two + + def nc(nonce) + if @nc.has_key? nonce + @nc[nonce] = @nc[nonce] + 1 + else + @nc[nonce] = 1 + end + return @nc[nonce] + end + + # some responses need quoting + def qdval(k, v) + return if k.nil? or v.nil? + if %w"username authzid realm nonce cnonce digest-uri qop".include? k + v.gsub!(/([\\"])/, "\\\1") + return '%s="%s"' % [k, v] + else + return '%s=%s' % [k, v] + end + end + end + add_authenticator "DIGEST-MD5", DigestMD5Authenticator + # Superclass of IMAP errors. class Error < StandardError end @@ -3185,6 +3685,16 @@ module Net # Superclass of all errors used to encapsulate "fail" responses # from the server. class ResponseError < Error + + # The response that caused this error + attr_accessor :response + + def initialize(response) + @response = response + + super @response.data.text + end + end # Error raised upon a "NO" response from the server, indicating @@ -3198,161 +3708,18 @@ module Net class BadResponseError < ResponseError end - # Error raised upon a "BYE" response from the server, indicating + # Error raised upon a "BYE" response from the server, indicating # that the client is not being allowed to login, or has been timed # out due to inactivity. class ByeResponseError < ResponseError end - end -end -if __FILE__ == $0 - # :enddoc: - require "getoptlong" - - $stdout.sync = true - $port = nil - $user = ENV["USER"] || ENV["LOGNAME"] - $auth = "login" - $ssl = false - - def usage - $stderr.print <<EOF -usage: #{$0} [options] <host> - - --help print this message - --port=PORT specifies port - --user=USER specifies user - --auth=AUTH specifies auth type - --ssl use ssl -EOF - end - - def get_password - print "password: " - system("stty", "-echo") - begin - return gets.chop - ensure - system("stty", "echo") - print "\n" - end - end + RESPONSE_ERRORS = Hash.new(ResponseError) + RESPONSE_ERRORS["NO"] = NoResponseError + RESPONSE_ERRORS["BAD"] = BadResponseError - def get_command - printf("%s@%s> ", $user, $host) - if line = gets - return line.strip.split(/\s+/) - else - return nil - end - end - - parser = GetoptLong.new - parser.set_options(['--debug', GetoptLong::NO_ARGUMENT], - ['--help', GetoptLong::NO_ARGUMENT], - ['--port', GetoptLong::REQUIRED_ARGUMENT], - ['--user', GetoptLong::REQUIRED_ARGUMENT], - ['--auth', GetoptLong::REQUIRED_ARGUMENT], - ['--ssl', GetoptLong::NO_ARGUMENT]) - begin - parser.each_option do |name, arg| - case name - when "--port" - $port = arg - when "--user" - $user = arg - when "--auth" - $auth = arg - when "--ssl" - $ssl = true - when "--debug" - Net::IMAP.debug = true - when "--help" - usage - exit(1) - end - end - rescue - usage - exit(1) - end - - $host = ARGV.shift - unless $host - usage - exit(1) - end - $port ||= $ssl ? 993 : 143 - - imap = Net::IMAP.new($host, $port, $ssl) - begin - password = get_password - imap.authenticate($auth, $user, password) - while true - cmd, *args = get_command - break unless cmd - begin - case cmd - when "list" - for mbox in imap.list("", args[0] || "*") - if mbox.attr.include?(Net::IMAP::NOSELECT) - prefix = "!" - elsif mbox.attr.include?(Net::IMAP::MARKED) - prefix = "*" - else - prefix = " " - end - print prefix, mbox.name, "\n" - end - when "select" - imap.select(args[0] || "inbox") - print "ok\n" - when "close" - imap.close - print "ok\n" - when "summary" - unless messages = imap.responses["EXISTS"][-1] - puts "not selected" - next - end - if messages > 0 - for data in imap.fetch(1..-1, ["ENVELOPE"]) - print data.seqno, ": ", data.attr["ENVELOPE"].subject, "\n" - end - else - puts "no message" - end - when "fetch" - if args[0] - data = imap.fetch(args[0].to_i, ["RFC822.HEADER", "RFC822.TEXT"])[0] - puts data.attr["RFC822.HEADER"] - puts data.attr["RFC822.TEXT"] - else - puts "missing argument" - end - when "logout", "exit", "quit" - break - when "help", "?" - print <<EOF -list [pattern] list mailboxes -select [mailbox] select mailbox -close close mailbox -summary display summary -fetch [msgno] display message -logout logout -help, ? display help message -EOF - else - print "unknown command: ", cmd, "\n" - end - rescue Net::IMAP::Error - puts $! - end + # Error raised when too many flags are interned to symbols. + class FlagCountError < Error end - ensure - imap.logout - imap.disconnect end end - diff --git a/lib/net/pop.rb b/lib/net/pop.rb index 39d446f9e6..92a4fe7303 100644 --- a/lib/net/pop.rb +++ b/lib/net/pop.rb @@ -1,27 +1,34 @@ +# frozen_string_literal: true # = net/pop.rb # -# Copyright (c) 1999-2003 Yukihiro Matsumoto. +# Copyright (c) 1999-2007 Yukihiro Matsumoto. +# +# Copyright (c) 1999-2007 Minero Aoki. # -# Copyright (c) 1999-2003 Minero Aoki. -# # Written & maintained by Minero Aoki <aamine@loveruby.net>. # # Documented by William Webber and Minero Aoki. -# +# # This program is free software. You can re-distribute and/or # modify this program under the same terms as Ruby itself, -# Ruby Distribute License or GNU General Public License. -# -# NOTE: You can find Japanese version of this document in -# the doc/net directory of the standard ruby interpreter package. -# -# $Id: pop.rb,v 1.62.2.4 2005/09/13 07:27:18 aamine Exp $ +# Ruby Distribute License. +# +# NOTE: You can find Japanese version of this document at: +# http://docs.ruby-lang.org/ja/latest/library/net=2fpop.html +# +# $Id$ # # See Net::POP3 for documentation. # require 'net/protocol' require 'digest/md5' +require 'timeout' + +begin + require "openssl" +rescue LoadError +end module Net @@ -36,28 +43,26 @@ module Net class POPBadResponse < POPError; end # - # = Net::POP3 - # # == What is This Library? - # - # This library provides functionality for retrieving + # + # This library provides functionality for retrieving # email via POP3, the Post Office Protocol version 3. For details # of POP3, see [RFC1939] (http://www.ietf.org/rfc/rfc1939.txt). - # + # # == Examples - # - # === Retrieving Messages - # - # This example retrieves messages from the server and deletes them + # + # === Retrieving Messages + # + # This example retrieves messages from the server and deletes them # on the server. # # Messages are written to files named 'inbox/1', 'inbox/2', .... # Replace 'pop.example.com' with your POP3 server address, and # 'YourAccount' and 'YourPassword' with the appropriate account # details. - # + # # require 'net/pop' - # + # # pop = Net::POP3.new('pop.example.com') # pop.start('YourAccount', 'YourPassword') # (1) # if pop.mails.empty? @@ -74,19 +79,19 @@ module Net # puts "#{pop.mails.size} mails popped." # end # pop.finish # (3) - # + # # 1. Call Net::POP3#start and start POP session. # 2. Access messages by using POP3#each_mail and/or POP3#mails. # 3. Close POP session by calling POP3#finish or use the block form of #start. - # + # # === Shortened Code - # + # # The example above is very verbose. You can shorten the code by using # some utility methods. First, the block form of Net::POP3.start can # be used instead of POP3.new, POP3#start and POP3#finish. - # + # # require 'net/pop' - # + # # Net::POP3.start('pop.example.com', 110, # 'YourAccount', 'YourPassword') do |pop| # if pop.mails.empty? @@ -103,11 +108,11 @@ module Net # puts "#{pop.mails.size} mails popped." # end # end - # + # # POP3#delete_all is an alternative for #each_mail and #delete. - # + # # require 'net/pop' - # + # # Net::POP3.start('pop.example.com', 110, # 'YourAccount', 'YourPassword') do |pop| # if pop.mails.empty? @@ -122,11 +127,11 @@ module Net # end # end # end - # + # # And here is an even shorter example. - # + # # require 'net/pop' - # + # # i = 0 # Net::POP3.delete_all('pop.example.com', 110, # 'YourAccount', 'YourPassword') do |m| @@ -135,14 +140,14 @@ module Net # end # i += 1 # end - # + # # === Memory Space Issues - # + # # All the examples above get each message as one big string. # This example avoids this. - # + # # require 'net/pop' - # + # # i = 1 # Net::POP3.delete_all('pop.example.com', 110, # 'YourAccount', 'YourPassword') do |m| @@ -153,54 +158,65 @@ module Net # i += 1 # end # end - # + # # === Using APOP - # + # # The net/pop library supports APOP authentication. # To use APOP, use the Net::APOP class instead of the Net::POP3 class. # You can use the utility method, Net::POP3.APOP(). For example: - # + # # require 'net/pop' - # + # # # Use APOP authentication if $isapop == true - # pop = Net::POP3.APOP($is_apop).new('apop.example.com', 110) - # pop.start(YourAccount', 'YourPassword') do |pop| + # pop = Net::POP3.APOP($isapop).new('apop.example.com', 110) + # pop.start('YourAccount', 'YourPassword') do |pop| # # Rest of the code is the same. # end - # + # # === Fetch Only Selected Mail Using 'UIDL' POP Command - # + # # If your POP server provides UIDL functionality, # you can grab only selected mails from the POP server. # e.g. - # + # # def need_pop?( id ) # # determine if we need pop this mail... # end - # + # # Net::POP3.start('pop.example.com', 110, # 'Your account', 'Your password') do |pop| # pop.mails.select { |m| need_pop?(m.unique_id) }.each do |m| # do_something(m.pop) # end # end - # + # # The POPMail#unique_id() method returns the unique-id of the message as a # String. Normally the unique-id is a hash of the message. - # + # class POP3 < Protocol - Revision = %q$Revision: 1.62.2.4 $.split[1] + # svn revision of this library + Revision = %q$Revision$.split[1] # # Class Parameters # - # The default port for POP3 connections, port 110 + # returns the port for POP3 def POP3.default_port + default_pop3_port() + end + + # The default port for POP3 connections, port 110 + def POP3.default_pop3_port 110 end + # The default port for POP3S connections, port 995 + def POP3.default_pop3s_port + 995 + end + def POP3.socket_type #:nodoc: obsolete Net::InternetMessageIO end @@ -220,7 +236,7 @@ module Net # .... # end # - def POP3.APOP( isapop ) + def POP3.APOP(isapop) isapop ? APOP : POP3 end @@ -244,9 +260,9 @@ module Net # m.delete if $DELETE # end # - def POP3.foreach( address, port = nil, - account = nil, password = nil, - isapop = false, &block ) # :yields: message + def POP3.foreach(address, port = nil, + account = nil, password = nil, + isapop = false, &block) # :yields: message start(address, port, account, password, isapop) {|pop| pop.each_mail(&block) } @@ -265,9 +281,9 @@ module Net # file.write m.pop # end # - def POP3.delete_all( address, port = nil, - account = nil, password = nil, - isapop = false, &block ) + def POP3.delete_all(address, port = nil, + account = nil, password = nil, + isapop = false, &block) start(address, port, account, password, isapop) {|pop| pop.delete_all(&block) } @@ -287,16 +303,16 @@ module Net # Net::POP3.auth_only('pop.example.com', 110, # 'YourAccount', 'YourPassword', true) # - def POP3.auth_only( address, port = nil, - account = nil, password = nil, - isapop = false ) + def POP3.auth_only(address, port = nil, + account = nil, password = nil, + isapop = false) new(address, port, isapop).auth_only account, password end # Starts a pop3 session, attempts authentication, and quits. # This method must not be called while POP3 session is opened. # This method raises POPAuthenticationError if authentication fails. - def auth_only( account, password ) + def auth_only(account, password) raise IOError, 'opening previously opened POP session' if started? start(account, password) { ; @@ -304,10 +320,70 @@ module Net end # + # SSL + # + + @ssl_params = nil + + # :call-seq: + # Net::POP.enable_ssl(params = {}) + # + # Enable SSL for all new instances. + # +params+ is passed to OpenSSL::SSLContext#set_params. + def POP3.enable_ssl(*args) + @ssl_params = create_ssl_params(*args) + end + + # Constructs proper parameters from arguments + def POP3.create_ssl_params(verify_or_params = {}, certs = nil) + begin + params = verify_or_params.to_hash + rescue NoMethodError + params = {} + params[:verify_mode] = verify_or_params + if certs + if File.file?(certs) + params[:ca_file] = certs + elsif File.directory?(certs) + params[:ca_path] = certs + end + end + end + return params + end + + # Disable SSL for all new instances. + def POP3.disable_ssl + @ssl_params = nil + end + + # returns the SSL Parameters + # + # see also POP3.enable_ssl + def POP3.ssl_params + return @ssl_params + end + + # returns +true+ if POP3.ssl_params is set + def POP3.use_ssl? + return !@ssl_params.nil? + end + + # returns whether verify_mode is enable from POP3.ssl_params + def POP3.verify + return @ssl_params[:verify_mode] + end + + # returns the :ca_file or :ca_path from POP3.ssl_params + def POP3.certs + return @ssl_params[:ca_file] || @ssl_params[:ca_path] + end + + # # Session management # - # Creates a new POP3 object and open the connection. Equivalent to + # Creates a new POP3 object and open the connection. Equivalent to # # Net::POP3.new(address, port, isapop).start(account, password) # @@ -323,9 +399,9 @@ module Net # end # end # - def POP3.start( address, port = nil, - account = nil, password = nil, - isapop = false, &block ) # :yield: pop + def POP3.start(address, port = nil, + account = nil, password = nil, + isapop = false, &block) # :yield: pop new(address, port, isapop).start(account, password, &block) end @@ -333,15 +409,16 @@ module Net # # +address+ is the hostname or ip address of your POP3 server. # - # The optional +port+ is the port to connect to; it defaults to 110. + # The optional +port+ is the port to connect to. # # The optional +isapop+ specifies whether this connection is going # to use APOP authentication; it defaults to +false+. # # This method does *not* open the TCP connection. - def initialize( addr, port = nil, isapop = false ) + def initialize(addr, port = nil, isapop = false) @address = addr - @port = port || self.class.default_port + @ssl_params = POP3.ssl_params + @port = port @apop = isapop @command = nil @@ -361,9 +438,36 @@ module Net @apop end + # does this instance use SSL? + def use_ssl? + return !@ssl_params.nil? + end + + # :call-seq: + # Net::POP#enable_ssl(params = {}) + # + # Enables SSL for this instance. Must be called before the connection is + # established to have any effect. + # +params[:port]+ is port to establish the SSL connection on; Defaults to 995. + # +params+ (except :port) is passed to OpenSSL::SSLContext#set_params. + def enable_ssl(verify_or_params = {}, certs = nil, port = nil) + begin + @ssl_params = verify_or_params.to_hash.dup + @port = @ssl_params.delete(:port) || @port + rescue NoMethodError + @ssl_params = POP3.create_ssl_params(verify_or_params, certs) + @port = port || @port + end + end + + # Disable SSL for all new instances. + def disable_ssl + @ssl_params = nil + end + # Provide human-readable stringification of class state. def inspect - "#<#{self.class} #{@address}:#{@port} open=#{@started}>" + +"#<#{self.class} #{@address}:#{@port} open=#{@started}>" end # *WARNING*: This method causes a serious security hole. @@ -379,7 +483,7 @@ module Net # .... # end # - def set_debug_output( arg ) + def set_debug_output(arg) @debug_output = arg end @@ -387,20 +491,22 @@ module Net attr_reader :address # The port number to connect to. - attr_reader :port + def port + return @port || (use_ssl? ? POP3.default_pop3s_port : POP3.default_pop3_port) + end # Seconds to wait until a connection is opened. # If the POP3 object cannot open a connection within this time, - # it raises a TimeoutError exception. + # it raises a Net::OpenTimeout exception. The default value is 30 seconds. attr_accessor :open_timeout # Seconds to wait until reading one block (by one read(1) call). # If the POP3 object cannot complete a read() within this time, - # it raises a TimeoutError exception. + # it raises a Net::ReadTimeout exception. The default value is 60 seconds. attr_reader :read_timeout # Set the read timeout. - def read_timeout=( sec ) + def read_timeout=(sec) @command.socket.read_timeout = sec if @command @read_timeout = sec end @@ -418,9 +524,8 @@ module Net # closes the session after block call finishes. # # This method raises a POPAuthenticationError if authentication fails. - def start( account, password ) # :yield: pop + def start(account, password) # :yield: pop raise IOError, 'POP session already started' if @started - if block_given? begin do_start account, password @@ -434,9 +539,26 @@ module Net end end - def do_start( account, password ) - @socket = self.class.socket_type.old_open(@address, @port, - @open_timeout, @read_timeout, @debug_output) + # internal method for Net::POP3.start + def do_start(account, password) # :nodoc: + s = Timeout.timeout(@open_timeout, Net::OpenTimeout) do + TCPSocket.open(@address, port) + end + if use_ssl? + raise 'openssl library not installed' unless defined?(OpenSSL) + context = OpenSSL::SSL::SSLContext.new + context.set_params(@ssl_params) + s = OpenSSL::SSL::SSLSocket.new(s, context) + s.sync_close = true + ssl_socket_connect(s, @open_timeout) + if context.verify_mode != OpenSSL::SSL::VERIFY_NONE + s.post_connection_check(@address) + end + end + @socket = InternetMessageIO.new(s, + read_timeout: @read_timeout, + debug_output: @debug_output) + logging "POP session started: #{@address}:#{@port} (#{@apop ? 'APOP' : 'POP'})" on_connect @command = POP3Command.new(@socket) if apop? @@ -446,11 +568,17 @@ module Net end @started = true ensure - do_finish if not @started + # Authentication failed, clean up connection. + unless @started + s.close if s + @socket = nil + @command = nil + end end private :do_start - def on_connect + # Does nothing + def on_connect # :nodoc: end private :on_connect @@ -460,7 +588,12 @@ module Net do_finish end - def do_finish + # nil's out the: + # - mails + # - number counter for mails + # - number counter for bytes + # - quits the current command, if any + def do_finish # :nodoc: @mails = nil @n_mails = nil @n_bytes = nil @@ -468,12 +601,15 @@ module Net ensure @started = false @command = nil - @socket.close if @socket and not @socket.closed? + @socket.close if @socket @socket = nil end private :do_finish - def command + # Returns the current command. + # + # Raises IOError if there is no active socket + def command # :nodoc: raise IOError, 'POP session not opened yet' \ if not @socket or @socket.closed? @command @@ -520,13 +656,13 @@ module Net # Yields each message to the passed-in block in turn. # Equivalent to: - # + # # pop3.mails.each do |popmail| # .... # end # # This method raises a POPError if an error occurs. - def each_mail( &block ) # :yield: message + def each_mail(&block) # :yield: message mails().each(&block) end @@ -568,17 +704,21 @@ module Net end def set_all_uids #:nodoc: internal use only (called from POPMail#uidl) - command().uidl.each do |num, uid| - @mails.find {|m| m.number == num }.uid = uid - end + uidl = command().uidl + @mails.each {|m| m.uid = uidl[m.number] } + end + + # debugging output for +msg+ + def logging(msg) + @debug_output << msg + "\n" if @debug_output end end # class POP3 # class aliases - POP = POP3 - POPSession = POP3 - POP3Session = POP3 + POP = POP3 # :nodoc: + POPSession = POP3 # :nodoc: + POP3Session = POP3 # :nodoc: # # This class is equivalent to POP3, except that it uses APOP authentication. @@ -600,7 +740,7 @@ module Net # class POPMail - def initialize( num, len, pop, cmd ) #:nodoc: + def initialize(num, len, pop, cmd) #:nodoc: @number = num @length = len @pop = pop @@ -618,24 +758,24 @@ module Net # Provide human-readable stringification of class state. def inspect - "#<#{self.class} #{@number}#{@deleted ? ' deleted' : ''}>" + +"#<#{self.class} #{@number}#{@deleted ? ' deleted' : ''}>" end # # This method fetches the message. If called with a block, the # message is yielded to the block one chunk at a time. If called - # without a block, the message is returned as a String. The optional + # without a block, the message is returned as a String. The optional # +dest+ argument will be prepended to the returned String; this # argument is essentially obsolete. # # === Example without block # # POP3.start('pop.example.com', 110, - # 'YourAccount, 'YourPassword') do |pop| + # 'YourAccount', 'YourPassword') do |pop| # n = 1 # pop.mails.each do |popmail| # File.open("inbox/#{n}", 'w') do |f| - # f.write popmail.pop + # f.write popmail.pop # end # popmail.delete # n += 1 @@ -645,7 +785,7 @@ module Net # === Example with block # # POP3.start('pop.example.com', 110, - # 'YourAccount, 'YourPassword') do |pop| + # 'YourAccount', 'YourPassword') do |pop| # n = 1 # pop.mails.each do |popmail| # File.open("inbox/#{n}", 'w') do |f| @@ -659,7 +799,7 @@ module Net # # This method raises a POPError if an error occurs. # - def pop( dest = '', &block ) # :yield: message_chunk + def pop( dest = +'', &block ) # :yield: message_chunk if block_given? @command.retr(@number, &block) nil @@ -674,24 +814,24 @@ module Net alias all pop #:nodoc: obsolete alias mail pop #:nodoc: obsolete - # Fetches the message header and +lines+ lines of body. + # Fetches the message header and +lines+ lines of body. # # The optional +dest+ argument is obsolete. # # This method raises a POPError if an error occurs. - def top( lines, dest = '' ) + def top(lines, dest = +'') @command.top(@number, lines) do |chunk| dest << chunk end dest end - # Fetches the message header. + # Fetches the message header. # # The optional +dest+ argument is obsolete. # # This method raises a POPError if an error occurs. - def header( dest = '' ) + def header(dest = +'') top(0, dest) end @@ -704,7 +844,7 @@ module Net # === Example # # POP3.start('pop.example.com', 110, - # 'YourAccount, 'YourPassword') do |pop| + # 'YourAccount', 'YourPassword') do |pop| # n = 1 # pop.mails.each do |popmail| # File.open("inbox/#{n}", 'w') do |f| @@ -739,7 +879,7 @@ module Net alias uidl unique_id - def uid=( uid ) #:nodoc: internal use only (used from POP3#set_all_uids) + def uid=(uid) #:nodoc: internal use only @uid = uid end @@ -748,25 +888,27 @@ module Net class POP3Command #:nodoc: internal use only - def initialize( sock ) + def initialize(sock) @socket = sock - @error_occured = false + @error_occurred = false res = check_response(critical { recv_response() }) - @apop_stamp = res.slice(/<.+>/) + @apop_stamp = res.slice(/<[!-~]+@[!-~]+>/) end + attr_reader :socket + def inspect - "#<#{self.class} socket=#{@socket}>" + +"#<#{self.class} socket=#{@socket}>" end - def auth( account, password ) + def auth(account, password) check_response_auth(critical { check_response_auth(get_response('USER %s', account)) get_response('PASS %s', password) }) end - def apop( account, password ) + def apop(account, password) raise POPAuthenticationError, 'not APOP server; cannot login' \ unless @apop_stamp check_response_auth(critical { @@ -797,28 +939,28 @@ module Net end def rset - check_response(critical { get_response 'RSET' }) + check_response(critical { get_response('RSET') }) end - def top( num, lines = 0, &block ) + def top(num, lines = 0, &block) critical { getok('TOP %d %d', num, lines) @socket.each_message_chunk(&block) } end - def retr( num, &block ) + def retr(num, &block) critical { getok('RETR %d', num) @socket.each_message_chunk(&block) } end - - def dele( num ) + + def dele(num) check_response(critical { get_response('DELE %d', num) }) end - def uidl( num = nil ) + def uidl(num = nil) if num res = check_response(critical { get_response('UIDL %d', num) }) return res.split(/ /)[1] @@ -841,12 +983,12 @@ module Net private - def getok( fmt, *fargs ) + def getok(fmt, *fargs) @socket.writeline sprintf(fmt, *fargs) check_response(recv_response()) end - def get_response( fmt, *fargs ) + def get_response(fmt, *fargs) @socket.writeline sprintf(fmt, *fargs) recv_response() end @@ -855,22 +997,22 @@ module Net @socket.readline end - def check_response( res ) - raise POPError, res unless /\A\+OK/i === res + def check_response(res) + raise POPError, res unless /\A\+OK/i =~ res res end - def check_response_auth( res ) - raise POPAuthenticationError, res unless /\A\+OK/i === res + def check_response_auth(res) + raise POPAuthenticationError, res unless /\A\+OK/i =~ res res end def critical - return '+OK dummy ok response' if @error_occured + return '+OK dummy ok response' if @error_occurred begin return yield() rescue Exception - @error_occured = true + @error_occurred = true raise end end @@ -878,4 +1020,3 @@ module Net end # class POP3Command end # module Net - diff --git a/lib/net/protocol.rb b/lib/net/protocol.rb index 0fee78c63a..0e887d5aa9 100644 --- a/lib/net/protocol.rb +++ b/lib/net/protocol.rb @@ -1,9 +1,10 @@ +# frozen_string_literal: true # # = net/protocol.rb # #-- -# Copyright (c) 1999-2005 Yukihiro Matsumoto -# Copyright (c) 1999-2005 Minero Aoki +# Copyright (c) 1999-2004 Yukihiro Matsumoto +# Copyright (c) 1999-2004 Minero Aoki # # written and maintained by Minero Aoki <aamine@loveruby.net> # @@ -11,7 +12,7 @@ # modify this program under the same terms as Ruby itself, # Ruby Distribute License or GNU General Public License. # -# $Id: protocol.rb,v 1.73.2.3 2005/09/13 07:27:18 aamine Exp $ +# $Id$ #++ # # WARNING: This file is going to remove. @@ -20,6 +21,7 @@ require 'socket' require 'timeout' +require 'io/wait' module Net # :nodoc: @@ -32,6 +34,24 @@ module Net # :nodoc: end End end + + def ssl_socket_connect(s, timeout) + if timeout + while true + raise Net::OpenTimeout if timeout <= 0 + start = Process.clock_gettime Process::CLOCK_MONOTONIC + # to_io is required because SSLSocket doesn't have wait_readable yet + case s.connect_nonblock(exception: false) + when :wait_readable; s.to_io.wait_readable(timeout) + when :wait_writable; s.to_io.wait_writable(timeout) + else; break + end + timeout -= Process.clock_gettime(Process::CLOCK_MONOTONIC) - start + end + else + s.connect + end + end end @@ -45,23 +65,41 @@ module Net # :nodoc: class ProtoRetriableError < ProtocolError; end ProtocRetryError = ProtoRetriableError + ## + # OpenTimeout, a subclass of Timeout::Error, is raised if a connection cannot + # be created within the open_timeout. + + class OpenTimeout < Timeout::Error; end + + ## + # ReadTimeout, a subclass of Timeout::Error, is raised if a chunk of the + # response cannot be read within the read_timeout. + + class ReadTimeout < Timeout::Error; end + class BufferedIO #:nodoc: internal use only - def initialize(io) + def initialize(io, read_timeout: 60, continue_timeout: nil, debug_output: nil) @io = io - @read_timeout = 60 - @debug_output = nil - @rbuf = '' + @read_timeout = read_timeout + @continue_timeout = continue_timeout + @debug_output = debug_output + @rbuf = ''.dup end attr_reader :io attr_accessor :read_timeout + attr_accessor :continue_timeout attr_accessor :debug_output def inspect "#<#{self.class} io=#{@io}>" end + def eof? + @io.eof? + end + def closed? @io.closed? end @@ -76,17 +114,19 @@ module Net # :nodoc: public - def read(len, dest = '', ignore_eof = false) + def read(len, dest = ''.dup, ignore_eof = false) LOG "reading #{len} bytes..." read_bytes = 0 begin while read_bytes + @rbuf.size < len - dest << (s = rbuf_consume(@rbuf.size)) + s = rbuf_consume(@rbuf.size) read_bytes += s.size + dest << s rbuf_fill end - dest << (s = rbuf_consume(len - read_bytes)) + s = rbuf_consume(len - read_bytes) read_bytes += s.size + dest << s rescue EOFError raise unless ignore_eof end @@ -94,13 +134,14 @@ module Net # :nodoc: dest end - def read_all(dest = '') + def read_all(dest = ''.dup) LOG 'reading all...' read_bytes = 0 begin while true - dest << (s = rbuf_consume(@rbuf.size)) + s = rbuf_consume(@rbuf.size) read_bytes += s.size + dest << s rbuf_fill end rescue EOFError @@ -121,17 +162,32 @@ module Net # :nodoc: return rbuf_consume(@rbuf.size) end end - + def readline readuntil("\n").chop end private + BUFSIZE = 1024 * 16 + def rbuf_fill - timeout(@read_timeout) { - @rbuf << @io.sysread(1024) - } + case rv = @io.read_nonblock(BUFSIZE, exception: false) + when String + @rbuf << rv + rv.clear + return + when :wait_readable + @io.to_io.wait_readable(@read_timeout) or raise Net::ReadTimeout + # continue looping + when :wait_writable + # OpenSSL::Buffering#read_nonblock may fail with IO::WaitWritable. + # http://www.openssl.org/support/faq.html#PROG10 + @io.to_io.wait_writable(@read_timeout) or raise Net::ReadTimeout + # continue looping + when nil + raise EOFError, 'end of file reached' + end while true end def rbuf_consume(len) @@ -152,6 +208,8 @@ module Net # :nodoc: } end + alias << write + def writeline(str) writing { write0 str + "\r\n" @@ -200,17 +258,7 @@ module Net # :nodoc: class InternetMessageIO < BufferedIO #:nodoc: internal use only - def InternetMessageIO.old_open(addr, port, - open_timeout = nil, read_timeout = nil, debug_output = nil) - debug_output << "opening connection to #{addr}...\n" if debug_output - s = timeout(open_timeout) { TCPsocket.new(addr, port) } - io = new(s) - io.read_timeout = read_timeout - io.debug_output = debug_output - io - end - - def initialize(io) + def initialize(*) super @wbuf = nil end @@ -230,7 +278,7 @@ module Net # :nodoc: LOG_on() LOG "read message (#{read_bytes} bytes)" end - + # *library private* (cannot handle 'break') def each_list_item while (str = readuntil("\r\n")) != ".\r\n" @@ -241,7 +289,7 @@ module Net # :nodoc: def write_message_0(src) prev = @written_bytes each_crlf_line(src) do |line| - write0 line.sub(/\A\./, '..') + write0 dot_stuff(line) end @written_bytes - prev end @@ -282,11 +330,15 @@ module Net # :nodoc: private + def dot_stuff(s) + s.sub(/\A\./, '..') + end + def using_each_crlf_line - @wbuf = '' + @wbuf = ''.dup yield if not @wbuf.empty? # unterminated last line - write0 @wbuf.chomp + "\r\n" + write0 dot_stuff(@wbuf.chomp) + "\r\n" elsif @written_bytes == 0 # empty src write0 "\r\n" end @@ -296,7 +348,7 @@ module Net # :nodoc: def each_crlf_line(src) buffer_filling(@wbuf, src) do - while line = @wbuf.slice!(/\A.*(?:\n|\r\n|\r(?!\z))/n) + while line = @wbuf.slice!(/\A[^\r\n]*(?:\n|\r(?:\n|(?!\z)))/) yield line.chomp("\n") + "\r\n" end end @@ -315,8 +367,8 @@ module Net # :nodoc: yield end else # generic reader - src.each do |s| - buf << s + src.each do |str| + buf << str yield if buf.size > 1024 end yield unless buf.empty? diff --git a/lib/net/smtp.rb b/lib/net/smtp.rb index 89929b1c6e..1777a7fa7e 100644 --- a/lib/net/smtp.rb +++ b/lib/net/smtp.rb @@ -1,34 +1,36 @@ +# frozen_string_literal: true # = net/smtp.rb -# -# Copyright (c) 1999-2003 Yukihiro Matsumoto. # -# Copyright (c) 1999-2003 Minero Aoki. -# +# Copyright (c) 1999-2007 Yukihiro Matsumoto. +# +# Copyright (c) 1999-2007 Minero Aoki. +# # Written & maintained by Minero Aoki <aamine@loveruby.net>. # # Documented by William Webber and Minero Aoki. -# +# # This program is free software. You can re-distribute and/or -# modify this program under the same terms as Ruby itself, -# Ruby Distribute License or GNU General Public License. -# -# NOTE: You can find Japanese version of this document in -# the doc/net directory of the standard ruby interpreter package. -# -# $Id: smtp.rb,v 1.69.2.3 2005/09/13 07:27:18 aamine Exp $ +# modify this program under the same terms as Ruby itself. +# +# $Id$ +# +# See Net::SMTP for documentation. # -# See Net::SMTP for documentation. -# require 'net/protocol' require 'digest/md5' +require 'timeout' +begin + require 'openssl' +rescue LoadError +end module Net # Module mixed in to all SMTP error classes module SMTPError - # This *class* is module for some reason. - # In ruby 1.9.x, this module becomes a class. + # This *class* is a module for backward compatibility. + # In later release, this module becomes a class. end # Represents an SMTP authentication error. @@ -56,120 +58,142 @@ module Net include SMTPError end - # - # = Net::SMTP + # Command is not supported on server. + class SMTPUnsupportedCommand < ProtocolError + include SMTPError + end + # # == What is This Library? - # + # # This library provides functionality to send internet # mail via SMTP, the Simple Mail Transfer Protocol. For details of # SMTP itself, see [RFC2821] (http://www.ietf.org/rfc/rfc2821.txt). - # + # # == What is This Library NOT? - # + # # This library does NOT provide functions to compose internet mails. # You must create them by yourself. If you want better mail support, - # try RubyMail or TMail. You can get both libraries from RAA. - # (http://www.ruby-lang.org/en/raa.html) - # + # try RubyMail or TMail or search for alternatives in + # {RubyGems.org}[https://rubygems.org/] or {The Ruby + # Toolbox}[https://www.ruby-toolbox.com/]. + # # FYI: the official documentation on internet mail is: [RFC2822] (http://www.ietf.org/rfc/rfc2822.txt). - # + # # == Examples - # + # # === Sending Messages - # + # # You must open a connection to an SMTP server before sending messages. - # The first argument is the address of your SMTP server, and the second - # argument is the port number. Using SMTP.start with a block is the simplest - # way to do this. This way, the SMTP connection is closed automatically + # The first argument is the address of your SMTP server, and the second + # argument is the port number. Using SMTP.start with a block is the simplest + # way to do this. This way, the SMTP connection is closed automatically # after the block is executed. - # + # # require 'net/smtp' # Net::SMTP.start('your.smtp.server', 25) do |smtp| # # Use the SMTP object smtp only in this block. # end - # + # # Replace 'your.smtp.server' with your SMTP server. Normally # your system manager or internet provider supplies a server # for you. - # + # # Then you can send messages. - # + # # msgstr = <<END_OF_MESSAGE # From: Your Name <your@mail.address> # To: Destination Address <someone@example.com> # Subject: test message # Date: Sat, 23 Jun 2001 16:26:43 +0900 # Message-Id: <unique.message.id.string@example.com> - # + # # This is a test message. # END_OF_MESSAGE - # + # # require 'net/smtp' # Net::SMTP.start('your.smtp.server', 25) do |smtp| # smtp.send_message msgstr, # 'your@mail.address', - # 'his_addess@example.com' + # 'his_address@example.com' # end - # + # # === Closing the Session - # - # You MUST close the SMTP session after sending messages, by calling + # + # You MUST close the SMTP session after sending messages, by calling # the #finish method: - # + # # # using SMTP#finish # smtp = Net::SMTP.start('your.smtp.server', 25) # smtp.send_message msgstr, 'from@address', 'to@address' # smtp.finish - # + # # You can also use the block form of SMTP.start/SMTP#start. This closes # the SMTP session automatically: - # + # # # using block form of SMTP.start # Net::SMTP.start('your.smtp.server', 25) do |smtp| # smtp.send_message msgstr, 'from@address', 'to@address' # end - # + # # I strongly recommend this scheme. This form is simpler and more robust. - # + # # === HELO domain - # + # # In almost all situations, you must provide a third argument # to SMTP.start/SMTP#start. This is the domain name which you are on # (the host to send mail from). It is called the "HELO domain". # The SMTP server will judge whether it should send or reject # the SMTP session by inspecting the HELO domain. - # + # # Net::SMTP.start('your.smtp.server', 25, # 'mail.from.domain') { |smtp| ... } - # + # # === SMTP Authentication - # + # # The Net::SMTP class supports three authentication schemes; # PLAIN, LOGIN and CRAM MD5. (SMTP Authentication: [RFC2554]) - # To use SMTP authentication, pass extra arguments to + # To use SMTP authentication, pass extra arguments to # SMTP.start/SMTP#start. - # + # # # PLAIN # Net::SMTP.start('your.smtp.server', 25, 'mail.from.domain', # 'Your Account', 'Your Password', :plain) # # LOGIN # Net::SMTP.start('your.smtp.server', 25, 'mail.from.domain', # 'Your Account', 'Your Password', :login) - # + # # # CRAM MD5 # Net::SMTP.start('your.smtp.server', 25, 'mail.from.domain', # 'Your Account', 'Your Password', :cram_md5) # - class SMTP + class SMTP < Protocol - Revision = %q$Revision: 1.69.2.3 $.split[1] + Revision = %q$Revision$.split[1] - # The default SMTP port, port 25. + # The default SMTP port number, 25. def SMTP.default_port 25 end + # The default mail submission port number, 587. + def SMTP.default_submission_port + 587 + end + + # The default SMTPS port number, 465. + def SMTP.default_tls_port + 465 + end + + class << self + alias default_ssl_port default_tls_port + end + + def SMTP.default_ssl_context + OpenSSL::SSL::SSLContext.new + end + # # Creates a new Net::SMTP object. # @@ -181,16 +205,20 @@ module Net # SMTP.start instead of SMTP.new if you want to do everything # at once. Otherwise, follow SMTP.new with SMTP#start. # - def initialize( address, port = nil ) + def initialize(address, port = nil) @address = address @port = (port || SMTP.default_port) @esmtp = true + @capabilities = nil @socket = nil @started = false @open_timeout = 30 @read_timeout = 60 - @error_occured = false + @error_occurred = false @debug_output = nil + @tls = false + @starttls = false + @ssl_context = nil end # Provide human-readable stringification of class state. @@ -198,23 +226,132 @@ module Net "#<#{self.class} #{@address}:#{@port} started=#{@started}>" end - # +true+ if the SMTP object uses ESMTP (which it does by default). - def esmtp? - @esmtp - end - # - # Set whether to use ESMTP or not. This should be done before + # Set whether to use ESMTP or not. This should be done before # calling #start. Note that if #start is called in ESMTP mode, # and the connection fails due to a ProtocolError, the SMTP # object will automatically switch to plain SMTP mode and # retry (but not vice versa). # - def esmtp=( bool ) - @esmtp = bool + attr_accessor :esmtp + + # +true+ if the SMTP object uses ESMTP (which it does by default). + alias :esmtp? :esmtp + + # true if server advertises STARTTLS. + # You cannot get valid value before opening SMTP session. + def capable_starttls? + capable?('STARTTLS') + end + + def capable?(key) + return nil unless @capabilities + @capabilities[key] ? true : false + end + private :capable? + + # true if server advertises AUTH PLAIN. + # You cannot get valid value before opening SMTP session. + def capable_plain_auth? + auth_capable?('PLAIN') + end + + # true if server advertises AUTH LOGIN. + # You cannot get valid value before opening SMTP session. + def capable_login_auth? + auth_capable?('LOGIN') + end + + # true if server advertises AUTH CRAM-MD5. + # You cannot get valid value before opening SMTP session. + def capable_cram_md5_auth? + auth_capable?('CRAM-MD5') + end + + def auth_capable?(type) + return nil unless @capabilities + return false unless @capabilities['AUTH'] + @capabilities['AUTH'].include?(type) + end + private :auth_capable? + + # Returns supported authentication methods on this server. + # You cannot get valid value before opening SMTP session. + def capable_auth_types + return [] unless @capabilities + return [] unless @capabilities['AUTH'] + @capabilities['AUTH'] + end + + # true if this object uses SMTP/TLS (SMTPS). + def tls? + @tls + end + + alias ssl? tls? + + # Enables SMTP/TLS (SMTPS: SMTP over direct TLS connection) for + # this object. Must be called before the connection is established + # to have any effect. +context+ is a OpenSSL::SSL::SSLContext object. + def enable_tls(context = SMTP.default_ssl_context) + raise 'openssl library not installed' unless defined?(OpenSSL) + raise ArgumentError, "SMTPS and STARTTLS is exclusive" if @starttls + @tls = true + @ssl_context = context + end + + alias enable_ssl enable_tls + + # Disables SMTP/TLS for this object. Must be called before the + # connection is established to have any effect. + def disable_tls + @tls = false + @ssl_context = nil + end + + alias disable_ssl disable_tls + + # Returns truth value if this object uses STARTTLS. + # If this object always uses STARTTLS, returns :always. + # If this object uses STARTTLS when the server support TLS, returns :auto. + def starttls? + @starttls + end + + # true if this object uses STARTTLS. + def starttls_always? + @starttls == :always end - alias esmtp esmtp? + # true if this object uses STARTTLS when server advertises STARTTLS. + def starttls_auto? + @starttls == :auto + end + + # Enables SMTP/TLS (STARTTLS) for this object. + # +context+ is a OpenSSL::SSL::SSLContext object. + def enable_starttls(context = SMTP.default_ssl_context) + raise 'openssl library not installed' unless defined?(OpenSSL) + raise ArgumentError, "SMTPS and STARTTLS is exclusive" if @tls + @starttls = :always + @ssl_context = context + end + + # Enables SMTP/TLS (STARTTLS) for this object if server accepts. + # +context+ is a OpenSSL::SSL::SSLContext object. + def enable_starttls_auto(context = SMTP.default_ssl_context) + raise 'openssl library not installed' unless defined?(OpenSSL) + raise ArgumentError, "SMTPS and STARTTLS is exclusive" if @tls + @starttls = :auto + @ssl_context = context + end + + # Disables SMTP/TLS (STARTTLS) for this object. Must be called + # before the connection is established to have any effect. + def disable_starttls + @starttls = false + @ssl_context = nil + end # The address of the SMTP server to connect to. attr_reader :address @@ -224,17 +361,17 @@ module Net # Seconds to wait while attempting to open a connection. # If the connection cannot be opened within this time, a - # TimeoutError is raised. + # Net::OpenTimeout is raised. The default value is 30 seconds. attr_accessor :open_timeout # Seconds to wait while reading one block (by one read(2) call). # If the read(2) call does not complete within this time, a - # TimeoutError is raised. + # Net::ReadTimeout is raised. The default value is 60 seconds. attr_reader :read_timeout # Set the number of seconds to wait until timing-out a read(2) # call. - def read_timeout=( sec ) + def read_timeout=(sec) @socket.read_timeout = sec if @socket @read_timeout = sec end @@ -253,10 +390,12 @@ module Net # .... # end # - def set_debug_output( arg ) + def debug_output=(arg) @debug_output = arg end + alias set_debug_output debug_output= + # # SMTP session control # @@ -265,7 +404,7 @@ module Net # Creates a new Net::SMTP object and connects to the server. # # This method is equivalent to: - # + # # Net::SMTP.new(address, port).start(helo_domain, account, password, authtype) # # === Example @@ -289,7 +428,7 @@ module Net # +port+ is the port to connect to; it defaults to port 25. # # +helo+ is the _HELO_ _domain_ provided by the client to the - # server (see overview comments); it defaults to 'localhost.localdomain'. + # server (see overview comments); it defaults to 'localhost'. # # The remaining arguments are used for SMTP authentication, if required # or desired. +user+ is the account name; +secret+ is your password @@ -306,13 +445,13 @@ module Net # * Net::SMTPSyntaxError # * Net::SMTPFatalError # * Net::SMTPUnknownError + # * Net::OpenTimeout + # * Net::ReadTimeout # * IOError - # * TimeoutError # - def SMTP.start( address, port = nil, - helo = 'localhost.localdomain', - user = nil, secret = nil, authtype = nil, - &block) # :yield: smtp + def SMTP.start(address, port = nil, helo = 'localhost', + user = nil, secret = nil, authtype = nil, + &block) # :yield: smtp new(address, port).start(helo, user, secret, authtype, &block) end @@ -329,33 +468,33 @@ module Net # +helo+ is the _HELO_ _domain_ that you'll dispatch mails from; see # the discussion in the overview notes. # - # If both of +user+ and +secret+ are given, SMTP authentication - # will be attempted using the AUTH command. +authtype+ specifies + # If both of +user+ and +secret+ are given, SMTP authentication + # will be attempted using the AUTH command. +authtype+ specifies # the type of authentication to attempt; it must be one of # :login, :plain, and :cram_md5. See the notes on SMTP Authentication - # in the overview. + # in the overview. # # === Block Usage # # When this methods is called with a block, the newly-started SMTP # object is yielded to the block, and automatically closed after - # the block call finishes. Otherwise, it is the caller's + # the block call finishes. Otherwise, it is the caller's # responsibility to close the session when finished. # # === Example # # This is very similar to the class method SMTP.start. # - # require 'net/smtp' + # require 'net/smtp' # smtp = Net::SMTP.new('smtp.mail.server', 25) # smtp.start(helo_domain, account, password, authtype) do |smtp| # smtp.send_message msgstr, 'from@example.com', ['dest@example.com'] - # end + # end # # The primary use of this method (as opposed to SMTP.start) # is probably to set debugging (#set_debug_output) or ESMTP # (#esmtp=), which must be done before the session is - # started. + # started. # # === Errors # @@ -368,72 +507,118 @@ module Net # * Net::SMTPSyntaxError # * Net::SMTPFatalError # * Net::SMTPUnknownError + # * Net::OpenTimeout + # * Net::ReadTimeout # * IOError - # * TimeoutError # - def start( helo = 'localhost.localdomain', - user = nil, secret = nil, authtype = nil ) # :yield: smtp + def start(helo = 'localhost', + user = nil, secret = nil, authtype = nil) # :yield: smtp if block_given? begin - do_start(helo, user, secret, authtype) + do_start helo, user, secret, authtype return yield(self) ensure do_finish end else - do_start(helo, user, secret, authtype) + do_start helo, user, secret, authtype return self end end - def do_start( helodomain, user, secret, authtype ) - raise IOError, 'SMTP session already started' if @started - check_auth_args user, secret, authtype if user or secret + # Finishes the SMTP session and closes TCP connection. + # Raises IOError if not started. + def finish + raise IOError, 'not yet started' unless started? + do_finish + end - @socket = InternetMessageIO.old_open(@address, @port, - @open_timeout, @read_timeout, - @debug_output) - check_response(critical { recv_response() }) - begin - if @esmtp - ehlo helodomain - else - helo helodomain - end - rescue ProtocolError - if @esmtp - @esmtp = false - @error_occured = false - retry + private + + def tcp_socket(address, port) + TCPSocket.open address, port + end + + def do_start(helo_domain, user, secret, authtype) + raise IOError, 'SMTP session already started' if @started + if user or secret + check_auth_method(authtype || DEFAULT_AUTH_TYPE) + check_auth_args user, secret + end + s = Timeout.timeout(@open_timeout, Net::OpenTimeout) do + tcp_socket(@address, @port) + end + logging "Connection opened: #{@address}:#{@port}" + @socket = new_internet_message_io(tls? ? tlsconnect(s) : s) + check_response critical { recv_response() } + do_helo helo_domain + if starttls_always? or (capable_starttls? and starttls_auto?) + unless capable_starttls? + raise SMTPUnsupportedCommand, + "STARTTLS is not supported on this server" end - raise + starttls + @socket = new_internet_message_io(tlsconnect(s)) + # helo response may be different after STARTTLS + do_helo helo_domain end - authenticate user, secret, authtype if user + authenticate user, secret, (authtype || DEFAULT_AUTH_TYPE) if user @started = true ensure - @socket.close if not @started and @socket and not @socket.closed? + unless @started + # authentication failed, cancel connection. + s.close if s + @socket = nil + end end - private :do_start - # Finishes the SMTP session and closes TCP connection. - # Raises IOError if not started. - def finish - raise IOError, 'not yet started' unless started? - do_finish + def ssl_socket(socket, context) + OpenSSL::SSL::SSLSocket.new socket, context + end + + def tlsconnect(s) + verified = false + s = ssl_socket(s, @ssl_context) + logging "TLS connection started" + s.sync_close = true + ssl_socket_connect(s, @open_timeout) + if @ssl_context.verify_mode != OpenSSL::SSL::VERIFY_NONE + s.post_connection_check(@address) + end + verified = true + s + ensure + s.close unless verified + end + + def new_internet_message_io(s) + InternetMessageIO.new(s, read_timeout: @read_timeout, + debug_output: @debug_output) + end + + def do_helo(helo_domain) + res = @esmtp ? ehlo(helo_domain) : helo(helo_domain) + @capabilities = res.capabilities + rescue SMTPError + if @esmtp + @esmtp = false + @error_occurred = false + retry + end + raise end def do_finish - quit if @socket and not @socket.closed? and not @error_occured + quit if @socket and not @socket.closed? and not @error_occurred ensure @started = false - @error_occured = false - @socket.close if @socket and not @socket.closed? + @error_occurred = false + @socket.close if @socket @socket = nil end - private :do_finish # - # message send + # Message Sending # public @@ -441,7 +626,7 @@ module Net # # Sends +msgstr+ as a message. Single CR ("\r") and LF ("\n") found # in the +msgstr+, are converted into the CR LF pair. You cannot send a - # binary message with this method. +msgstr+ should include both + # binary message with this method. +msgstr+ should include both # the message headers and body. # # +from_addr+ is a String representing the source mail address. @@ -465,13 +650,13 @@ module Net # * Net::SMTPSyntaxError # * Net::SMTPFatalError # * Net::SMTPUnknownError + # * Net::ReadTimeout # * IOError - # * TimeoutError # - def send_message( msgstr, from_addr, *to_addrs ) - send0(from_addr, to_addrs.flatten) { - @socket.write_message msgstr - } + def send_message(msgstr, from_addr, *to_addrs) + raise IOError, 'closed session' unless @socket + mailfrom from_addr + rcptto_list(to_addrs) {data msgstr} end alias send_mail send_message @@ -518,180 +703,376 @@ module Net # * Net::SMTPSyntaxError # * Net::SMTPFatalError # * Net::SMTPUnknownError + # * Net::ReadTimeout # * IOError - # * TimeoutError # - def open_message_stream( from_addr, *to_addrs, &block ) # :yield: stream - send0(from_addr, to_addrs.flatten) { - @socket.write_message_by_block(&block) - } + def open_message_stream(from_addr, *to_addrs, &block) # :yield: stream + raise IOError, 'closed session' unless @socket + mailfrom from_addr + rcptto_list(to_addrs) {data(&block)} end alias ready open_message_stream # obsolete - private + # + # Authentication + # - def send0( from_addr, to_addrs ) - raise IOError, 'closed session' unless @socket - raise ArgumentError, 'mail destination not given' if to_addrs.empty? - if $SAFE > 0 - raise SecurityError, 'tainted from_addr' if from_addr.tainted? - to_addrs.each do |to| - raise SecurityError, 'tainted to_addr' if to.tainted? - end - end + public - mailfrom from_addr - to_addrs.each do |to| - rcptto to - end + DEFAULT_AUTH_TYPE = :plain + + def authenticate(user, secret, authtype = DEFAULT_AUTH_TYPE) + check_auth_method authtype + check_auth_args user, secret + send auth_method(authtype), user, secret + end + + def auth_plain(user, secret) + check_auth_args user, secret res = critical { - check_response(get_response('DATA'), true) - yield - recv_response() + get_response('AUTH PLAIN ' + base64_encode("\0#{user}\0#{secret}")) } - check_response(res) + check_auth_response res + res end - # - # auth - # + def auth_login(user, secret) + check_auth_args user, secret + res = critical { + check_auth_continue get_response('AUTH LOGIN') + check_auth_continue get_response(base64_encode(user)) + get_response(base64_encode(secret)) + } + check_auth_response res + res + end + + def auth_cram_md5(user, secret) + check_auth_args user, secret + res = critical { + res0 = get_response('AUTH CRAM-MD5') + check_auth_continue res0 + crammed = cram_md5_response(secret, res0.cram_md5_challenge) + get_response(base64_encode("#{user} #{crammed}")) + } + check_auth_response res + res + end private - def check_auth_args( user, secret, authtype ) - raise ArgumentError, 'both user and secret are required'\ - unless user and secret - auth_method = "auth_#{authtype || 'cram_md5'}" - raise ArgumentError, "wrong auth type #{authtype}"\ - unless respond_to?(auth_method, true) + def check_auth_method(type) + unless respond_to?(auth_method(type), true) + raise ArgumentError, "wrong authentication type #{type}" + end end - def authenticate( user, secret, authtype ) - __send__("auth_#{authtype || 'cram_md5'}", user, secret) + def auth_method(type) + "auth_#{type.to_s.downcase}".intern end - def auth_plain( user, secret ) - res = critical { get_response('AUTH PLAIN %s', - base64_encode("\0#{user}\0#{secret}")) } - raise SMTPAuthenticationError, res unless /\A2../ === res + def check_auth_args(user, secret, authtype = DEFAULT_AUTH_TYPE) + unless user + raise ArgumentError, 'SMTP-AUTH requested but missing user name' + end + unless secret + raise ArgumentError, 'SMTP-AUTH requested but missing secret phrase' + end end - def auth_login( user, secret ) - res = critical { - check_response(get_response('AUTH LOGIN'), true) - check_response(get_response(base64_encode(user)), true) - get_response(base64_encode(secret)) - } - raise SMTPAuthenticationError, res unless /\A2../ === res - end - - def auth_cram_md5( user, secret ) - # CRAM-MD5: [RFC2195] - res = nil - critical { - res = check_response(get_response('AUTH CRAM-MD5'), true) - challenge = res.split(/ /)[1].unpack('m')[0] - secret = Digest::MD5.digest(secret) if secret.size > 64 - - isecret = secret + "\0" * (64 - secret.size) - osecret = isecret.dup - 0.upto(63) do |i| - isecret[i] ^= 0x36 - osecret[i] ^= 0x5c - end - tmp = Digest::MD5.digest(isecret + challenge) - tmp = Digest::MD5.hexdigest(osecret + tmp) + def base64_encode(str) + # expects "str" may not become too long + [str].pack('m0') + end - res = get_response(base64_encode(user + ' ' + tmp)) - } - raise SMTPAuthenticationError, res unless /\A2../ === res + IMASK = 0x36 + OMASK = 0x5c + + # CRAM-MD5: [RFC2195] + def cram_md5_response(secret, challenge) + tmp = Digest::MD5.digest(cram_secret(secret, IMASK) + challenge) + Digest::MD5.hexdigest(cram_secret(secret, OMASK) + tmp) end - def base64_encode( str ) - # expects "str" may not become too long - [str].pack('m').gsub(/\s+/, '') + CRAM_BUFSIZE = 64 + + def cram_secret(secret, mask) + secret = Digest::MD5.digest(secret) if secret.size > CRAM_BUFSIZE + buf = secret.ljust(CRAM_BUFSIZE, "\0") + 0.upto(buf.size - 1) do |i| + buf[i] = (buf[i].ord ^ mask).chr + end + buf end # # SMTP command dispatcher # - private + public + + # Aborts the current mail transaction - def helo( domain ) - getok('HELO %s', domain) + def rset + getok('RSET') end - def ehlo( domain ) - getok('EHLO %s', domain) + def starttls + getok('STARTTLS') end - def mailfrom( fromaddr ) - getok('MAIL FROM:<%s>', fromaddr) + def helo(domain) + getok("HELO #{domain}") end - def rcptto( to ) - getok('RCPT TO:<%s>', to) + def ehlo(domain) + getok("EHLO #{domain}") + end + + def mailfrom(from_addr) + if $SAFE > 0 + raise SecurityError, 'tainted from_addr' if from_addr.tainted? + end + getok("MAIL FROM:<#{from_addr}>") + end + + def rcptto_list(to_addrs) + raise ArgumentError, 'mail destination not given' if to_addrs.empty? + ok_users = [] + unknown_users = [] + to_addrs.flatten.each do |addr| + begin + rcptto addr + rescue SMTPAuthenticationError + unknown_users << addr.dump + else + ok_users << addr + end + end + raise ArgumentError, 'mail destination not given' if ok_users.empty? + ret = yield + unless unknown_users.empty? + raise SMTPAuthenticationError, "failed to deliver for #{unknown_users.join(', ')}" + end + ret + end + + def rcptto(to_addr) + if $SAFE > 0 + raise SecurityError, 'tainted to_addr' if to_addr.tainted? + end + getok("RCPT TO:<#{to_addr}>") + end + + # This method sends a message. + # If +msgstr+ is given, sends it as a message. + # If block is given, yield a message writer stream. + # You must write message before the block is closed. + # + # # Example 1 (by string) + # smtp.data(<<EndMessage) + # From: john@example.com + # To: betty@example.com + # Subject: I found a bug + # + # Check vm.c:58879. + # EndMessage + # + # # Example 2 (by block) + # smtp.data {|f| + # f.puts "From: john@example.com" + # f.puts "To: betty@example.com" + # f.puts "Subject: I found a bug" + # f.puts "" + # f.puts "Check vm.c:58879." + # } + # + def data(msgstr = nil, &block) #:yield: stream + if msgstr and block + raise ArgumentError, "message and block are exclusive" + end + unless msgstr or block + raise ArgumentError, "message or block is required" + end + res = critical { + check_continue get_response('DATA') + socket_sync_bak = @socket.io.sync + begin + @socket.io.sync = false + if msgstr + @socket.write_message msgstr + else + @socket.write_message_by_block(&block) + end + ensure + @socket.io.flush + @socket.io.sync = socket_sync_bak + end + recv_response() + } + check_response res + res end def quit getok('QUIT') end - # - # row level library - # - private - def getok( fmt, *args ) + def validate_line(line) + # A bare CR or LF is not allowed in RFC5321. + if /[\r\n]/ =~ line + raise ArgumentError, "A line must not contain CR or LF" + end + end + + def getok(reqline) + validate_line reqline res = critical { - @socket.writeline sprintf(fmt, *args) + @socket.writeline reqline recv_response() } - return check_response(res) + check_response res + res end - def get_response( fmt, *args ) - @socket.writeline sprintf(fmt, *args) + def get_response(reqline) + validate_line reqline + @socket.writeline reqline recv_response() end def recv_response - res = '' + buf = ''.dup while true line = @socket.readline - res << line << "\n" - break unless line[3] == ?- # "210-PIPELINING" + buf << line << "\n" + break unless line[3,1] == '-' # "210-PIPELINING" end - res - end - - def check_response( res, allow_continue = false ) - return res if /\A2/ === res - return res if allow_continue and /\A3/ === res - err = case res - when /\A4/ then SMTPServerBusy - when /\A50/ then SMTPSyntaxError - when /\A55/ then SMTPFatalError - else SMTPUnknownError - end - raise err, res + Response.parse(buf) end - def critical( &block ) - return '200 dummy reply code' if @error_occured + def critical + return Response.parse('200 dummy reply code') if @error_occurred begin return yield() rescue Exception - @error_occured = true + @error_occurred = true raise end end + def check_response(res) + unless res.success? + raise res.exception_class, res.message + end + end + + def check_continue(res) + unless res.continue? + raise SMTPUnknownError, "could not get 3xx (#{res.status}: #{res.string})" + end + end + + def check_auth_response(res) + unless res.success? + raise SMTPAuthenticationError, res.message + end + end + + def check_auth_continue(res) + unless res.continue? + raise res.exception_class, res.message + end + end + + # This class represents a response received by the SMTP server. Instances + # of this class are created by the SMTP class; they should not be directly + # created by the user. For more information on SMTP responses, view + # {Section 4.2 of RFC 5321}[http://tools.ietf.org/html/rfc5321#section-4.2] + class Response + # Parses the received response and separates the reply code and the human + # readable reply text + def self.parse(str) + new(str[0,3], str) + end + + # Creates a new instance of the Response class and sets the status and + # string attributes + def initialize(status, string) + @status = status + @string = string + end + + # The three digit reply code of the SMTP response + attr_reader :status + + # The human readable reply text of the SMTP response + attr_reader :string + + # Takes the first digit of the reply code to determine the status type + def status_type_char + @status[0, 1] + end + + # Determines whether the response received was a Positive Completion + # reply (2xx reply code) + def success? + status_type_char() == '2' + end + + # Determines whether the response received was a Positive Intermediate + # reply (3xx reply code) + def continue? + status_type_char() == '3' + end + + # The first line of the human readable reply text + def message + @string.lines.first + end + + # Creates a CRAM-MD5 challenge. You can view more information on CRAM-MD5 + # on Wikipedia: https://en.wikipedia.org/wiki/CRAM-MD5 + def cram_md5_challenge + @string.split(/ /)[1].unpack1('m') + end + + # Returns a hash of the human readable reply text in the response if it + # is multiple lines. It does not return the first line. The key of the + # hash is the first word the value of the hash is an array with each word + # thereafter being a value in the array + def capabilities + return {} unless @string[3, 1] == '-' + h = {} + @string.lines.drop(1).each do |line| + k, *v = line[4..-1].chomp.split + h[k] = v + end + h + end + + # Determines whether there was an error and raises the appropriate error + # based on the reply code of the response + def exception_class + case @status + when /\A4/ then SMTPServerBusy + when /\A50/ then SMTPSyntaxError + when /\A53/ then SMTPAuthenticationError + when /\A5/ then SMTPFatalError + else SMTPUnknownError + end + end + end + + def logging(msg) + @debug_output << msg + "\n" if @debug_output + end + end # class SMTP - SMTPSession = SMTP + SMTPSession = SMTP # :nodoc: -end # module Net +end diff --git a/lib/net/telnet.rb b/lib/net/telnet.rb deleted file mode 100644 index a79537b649..0000000000 --- a/lib/net/telnet.rb +++ /dev/null @@ -1,749 +0,0 @@ -# = net/telnet.rb - Simple Telnet Client Library -# -# Author:: Wakou Aoyama <wakou@ruby-lang.org> -# Documentation:: William Webber and Wakou Aoyama -# -# This file holds the class Net::Telnet, which provides client-side -# telnet functionality. -# -# For documentation, see Net::Telnet. -# - -require "socket" -require "delegate" -require "timeout" -require "English" - -module Net - - # - # == Net::Telnet - # - # Provides telnet client functionality. - # - # This class also has, through delegation, all the methods of a - # socket object (by default, a +TCPSocket+, but can be set by the - # +Proxy+ option to <tt>new()</tt>). This provides methods such as - # <tt>close()</tt> to end the session and <tt>sysread()</tt> to read - # data directly from the host, instead of via the <tt>waitfor()</tt> - # mechanism. Note that if you do use <tt>sysread()</tt> directly - # when in telnet mode, you should probably pass the output through - # <tt>preprocess()</tt> to extract telnet command sequences. - # - # == Overview - # - # The telnet protocol allows a client to login remotely to a user - # account on a server and execute commands via a shell. The equivalent - # is done by creating a Net::Telnet class with the +Host+ option - # set to your host, calling #login() with your user and password, - # issuing one or more #cmd() calls, and then calling #close() - # to end the session. The #waitfor(), #print(), #puts(), and - # #write() methods, which #cmd() is implemented on top of, are - # only needed if you are doing something more complicated. - # - # A Net::Telnet object can also be used to connect to non-telnet - # services, such as SMTP or HTTP. In this case, you normally - # want to provide the +Port+ option to specify the port to - # connect to, and set the +Telnetmode+ option to false to prevent - # the client from attempting to interpret telnet command sequences. - # Generally, #login() will not work with other protocols, and you - # have to handle authentication yourself. - # - # For some protocols, it will be possible to specify the +Prompt+ - # option once when you create the Telnet object and use #cmd() calls; - # for others, you will have to specify the response sequence to - # look for as the Match option to every #cmd() call, or call - # #puts() and #waitfor() directly; for yet others, you will have - # to use #sysread() instead of #waitfor() and parse server - # responses yourself. - # - # It is worth noting that when you create a new Net::Telnet object, - # you can supply a proxy IO channel via the Proxy option. This - # can be used to attach the Telnet object to other Telnet objects, - # to already open sockets, or to any read-write IO object. This - # can be useful, for instance, for setting up a test fixture for - # unit testing. - # - # == Examples - # - # === Log in and send a command, echoing all output to stdout - # - # localhost = Net::Telnet::new("Host" => "localhost", - # "Timeout" => 10, - # "Prompt" => /[$%#>] \z/n) - # localhost.login("username", "password") { |c| print c } - # localhost.cmd("command") { |c| print c } - # localhost.close - # - # - # === Check a POP server to see if you have mail - # - # pop = Net::Telnet::new("Host" => "your_destination_host_here", - # "Port" => 110, - # "Telnetmode" => false, - # "Prompt" => /^\+OK/n) - # pop.cmd("user " + "your_username_here") { |c| print c } - # pop.cmd("pass " + "your_password_here") { |c| print c } - # pop.cmd("list") { |c| print c } - # - # == References - # - # There are a large number of RFCs relevant to the Telnet protocol. - # RFCs 854-861 define the base protocol. For a complete listing - # of relevant RFCs, see - # http://www.omnifarious.org/~hopper/technical/telnet-rfc.html - # - class Telnet < SimpleDelegator - - # :stopdoc: - IAC = 255.chr # "\377" # "\xff" # interpret as command - DONT = 254.chr # "\376" # "\xfe" # you are not to use option - DO = 253.chr # "\375" # "\xfd" # please, you use option - WONT = 252.chr # "\374" # "\xfc" # I won't use option - WILL = 251.chr # "\373" # "\xfb" # I will use option - SB = 250.chr # "\372" # "\xfa" # interpret as subnegotiation - GA = 249.chr # "\371" # "\xf9" # you may reverse the line - EL = 248.chr # "\370" # "\xf8" # erase the current line - EC = 247.chr # "\367" # "\xf7" # erase the current character - AYT = 246.chr # "\366" # "\xf6" # are you there - AO = 245.chr # "\365" # "\xf5" # abort output--but let prog finish - IP = 244.chr # "\364" # "\xf4" # interrupt process--permanently - BREAK = 243.chr # "\363" # "\xf3" # break - DM = 242.chr # "\362" # "\xf2" # data mark--for connect. cleaning - NOP = 241.chr # "\361" # "\xf1" # nop - SE = 240.chr # "\360" # "\xf0" # end sub negotiation - EOR = 239.chr # "\357" # "\xef" # end of record (transparent mode) - ABORT = 238.chr # "\356" # "\xee" # Abort process - SUSP = 237.chr # "\355" # "\xed" # Suspend process - EOF = 236.chr # "\354" # "\xec" # End of file - SYNCH = 242.chr # "\362" # "\xf2" # for telfunc calls - - OPT_BINARY = 0.chr # "\000" # "\x00" # Binary Transmission - OPT_ECHO = 1.chr # "\001" # "\x01" # Echo - OPT_RCP = 2.chr # "\002" # "\x02" # Reconnection - OPT_SGA = 3.chr # "\003" # "\x03" # Suppress Go Ahead - OPT_NAMS = 4.chr # "\004" # "\x04" # Approx Message Size Negotiation - OPT_STATUS = 5.chr # "\005" # "\x05" # Status - OPT_TM = 6.chr # "\006" # "\x06" # Timing Mark - OPT_RCTE = 7.chr # "\a" # "\x07" # Remote Controlled Trans and Echo - OPT_NAOL = 8.chr # "\010" # "\x08" # Output Line Width - OPT_NAOP = 9.chr # "\t" # "\x09" # Output Page Size - OPT_NAOCRD = 10.chr # "\n" # "\x0a" # Output Carriage-Return Disposition - OPT_NAOHTS = 11.chr # "\v" # "\x0b" # Output Horizontal Tab Stops - OPT_NAOHTD = 12.chr # "\f" # "\x0c" # Output Horizontal Tab Disposition - OPT_NAOFFD = 13.chr # "\r" # "\x0d" # Output Formfeed Disposition - OPT_NAOVTS = 14.chr # "\016" # "\x0e" # Output Vertical Tabstops - OPT_NAOVTD = 15.chr # "\017" # "\x0f" # Output Vertical Tab Disposition - OPT_NAOLFD = 16.chr # "\020" # "\x10" # Output Linefeed Disposition - OPT_XASCII = 17.chr # "\021" # "\x11" # Extended ASCII - OPT_LOGOUT = 18.chr # "\022" # "\x12" # Logout - OPT_BM = 19.chr # "\023" # "\x13" # Byte Macro - OPT_DET = 20.chr # "\024" # "\x14" # Data Entry Terminal - OPT_SUPDUP = 21.chr # "\025" # "\x15" # SUPDUP - OPT_SUPDUPOUTPUT = 22.chr # "\026" # "\x16" # SUPDUP Output - OPT_SNDLOC = 23.chr # "\027" # "\x17" # Send Location - OPT_TTYPE = 24.chr # "\030" # "\x18" # Terminal Type - OPT_EOR = 25.chr # "\031" # "\x19" # End of Record - OPT_TUID = 26.chr # "\032" # "\x1a" # TACACS User Identification - OPT_OUTMRK = 27.chr # "\e" # "\x1b" # Output Marking - OPT_TTYLOC = 28.chr # "\034" # "\x1c" # Terminal Location Number - OPT_3270REGIME = 29.chr # "\035" # "\x1d" # Telnet 3270 Regime - OPT_X3PAD = 30.chr # "\036" # "\x1e" # X.3 PAD - OPT_NAWS = 31.chr # "\037" # "\x1f" # Negotiate About Window Size - OPT_TSPEED = 32.chr # " " # "\x20" # Terminal Speed - OPT_LFLOW = 33.chr # "!" # "\x21" # Remote Flow Control - OPT_LINEMODE = 34.chr # "\"" # "\x22" # Linemode - OPT_XDISPLOC = 35.chr # "#" # "\x23" # X Display Location - OPT_OLD_ENVIRON = 36.chr # "$" # "\x24" # Environment Option - OPT_AUTHENTICATION = 37.chr # "%" # "\x25" # Authentication Option - OPT_ENCRYPT = 38.chr # "&" # "\x26" # Encryption Option - OPT_NEW_ENVIRON = 39.chr # "'" # "\x27" # New Environment Option - OPT_EXOPL = 255.chr # "\377" # "\xff" # Extended-Options-List - - NULL = "\000" - CR = "\015" - LF = "\012" - EOL = CR + LF - REVISION = '$Id: telnet.rb,v 1.23.2.4 2005/09/14 15:21:31 matz Exp $' - # :startdoc: - - # - # Creates a new Net::Telnet object. - # - # Attempts to connect to the host (unless the Proxy option is - # provided: see below). If a block is provided, it is yielded - # status messages on the attempt to connect to the server, of - # the form: - # - # Trying localhost... - # Connected to localhost. - # - # +options+ is a hash of options. The following example lists - # all options and their default values. - # - # host = Net::Telnet::new( - # "Host" => "localhost", # default: "localhost" - # "Port" => 23, # default: 23 - # "Binmode" => false, # default: false - # "Output_log" => "output_log", # default: nil (no output) - # "Dump_log" => "dump_log", # default: nil (no output) - # "Prompt" => /[$%#>] \z/n, # default: /[$%#>] \z/n - # "Telnetmode" => true, # default: true - # "Timeout" => 10, # default: 10 - # # if ignore timeout then set "Timeout" to false. - # "Waittime" => 0, # default: 0 - # "Proxy" => proxy # default: nil - # # proxy is Net::Telnet or IO object - # ) - # - # The options have the following meanings: - # - # Host:: the hostname or IP address of the host to connect to, as a String. - # Defaults to "localhost". - # - # Port:: the port to connect to. Defaults to 23. - # - # Binmode:: if false (the default), newline substitution is performed. - # Outgoing LF is - # converted to CRLF, and incoming CRLF is converted to LF. If - # true, this substitution is not performed. This value can - # also be set with the #binmode() method. The - # outgoing conversion only applies to the #puts() and #print() - # methods, not the #write() method. The precise nature of - # the newline conversion is also affected by the telnet options - # SGA and BIN. - # - # Output_log:: the name of the file to write connection status messages - # and all received traffic to. In the case of a proper - # Telnet session, this will include the client input as - # echoed by the host; otherwise, it only includes server - # responses. Output is appended verbatim to this file. - # By default, no output log is kept. - # - # Dump_log:: as for Output_log, except that output is written in hexdump - # format (16 bytes per line as hex pairs, followed by their - # printable equivalent), with connection status messages - # preceded by '#', sent traffic preceded by '>', and - # received traffic preceded by '<'. By default, not dump log - # is kept. - # - # Prompt:: a regular expression matching the host's command-line prompt - # sequence. This is needed by the Telnet class to determine - # when the output from a command has finished and the host is - # ready to receive a new command. By default, this regular - # expression is /[$%#>] \z/n. - # - # Telnetmode:: a boolean value, true by default. In telnet mode, - # traffic received from the host is parsed for special - # command sequences, and these sequences are escaped - # in outgoing traffic sent using #puts() or #print() - # (but not #write()). If you are using the Net::Telnet - # object to connect to a non-telnet service (such as - # SMTP or POP), this should be set to "false" to prevent - # undesired data corruption. This value can also be set - # by the #telnetmode() method. - # - # Timeout:: the number of seconds to wait before timing out both the - # initial attempt to connect to host (in this constructor), - # and all attempts to read data from the host (in #waitfor(), - # #cmd(), and #login()). Exceeding this timeout causes a - # TimeoutError to be raised. The default value is 10 seconds. - # You can disable the timeout by setting this value to false. - # In this case, the connect attempt will eventually timeout - # on the underlying connect(2) socket call with an - # Errno::ETIMEDOUT error (but generally only after a few - # minutes), but other attempts to read data from the host - # will hand indefinitely if no data is forthcoming. - # - # Waittime:: the amount of time to wait after seeing what looks like a - # prompt (that is, received data that matches the Prompt - # option regular expression) to see if more data arrives. - # If more data does arrive in this time, Net::Telnet assumes - # that what it saw was not really a prompt. This is to try to - # avoid false matches, but it can also lead to missing real - # prompts (if, for instance, a background process writes to - # the terminal soon after the prompt is displayed). By - # default, set to 0, meaning not to wait for more data. - # - # Proxy:: a proxy object to used instead of opening a direct connection - # to the host. Must be either another Net::Telnet object or - # an IO object. If it is another Net::Telnet object, this - # instance will use that one's socket for communication. If an - # IO object, it is used directly for communication. Any other - # kind of object will cause an error to be raised. - # - def initialize(options) # :yield: mesg - @options = options - @options["Host"] = "localhost" unless @options.has_key?("Host") - @options["Port"] = 23 unless @options.has_key?("Port") - @options["Prompt"] = /[$%#>] \z/n unless @options.has_key?("Prompt") - @options["Timeout"] = 10 unless @options.has_key?("Timeout") - @options["Waittime"] = 0 unless @options.has_key?("Waittime") - unless @options.has_key?("Binmode") - @options["Binmode"] = false - else - unless (true == @options["Binmode"] or false == @options["Binmode"]) - raise ArgumentError, "Binmode option must be true or false" - end - end - - unless @options.has_key?("Telnetmode") - @options["Telnetmode"] = true - else - unless (true == @options["Telnetmode"] or false == @options["Telnetmode"]) - raise ArgumentError, "Telnetmode option must be true or false" - end - end - - @telnet_option = { "SGA" => false, "BINARY" => false } - - if @options.has_key?("Output_log") - @log = File.open(@options["Output_log"], 'a+') - @log.sync = true - @log.binmode - end - - if @options.has_key?("Dump_log") - @dumplog = File.open(@options["Dump_log"], 'a+') - @dumplog.sync = true - @dumplog.binmode - def @dumplog.log_dump(dir, x) # :nodoc: - len = x.length - addr = 0 - offset = 0 - while 0 < len - if len < 16 - line = x[offset, len] - else - line = x[offset, 16] - end - hexvals = line.unpack('H*')[0] - hexvals += ' ' * (32 - hexvals.length) - hexvals = format("%s %s %s %s " * 4, *hexvals.unpack('a2' * 16)) - line = line.gsub(/[\000-\037\177-\377]/n, '.') - printf "%s 0x%5.5x: %s%s\n", dir, addr, hexvals, line - addr += 16 - offset += 16 - len -= 16 - end - print "\n" - end - end - - if @options.has_key?("Proxy") - if @options["Proxy"].kind_of?(Net::Telnet) - @sock = @options["Proxy"].sock - elsif @options["Proxy"].kind_of?(IO) - @sock = @options["Proxy"] - else - raise "Error: Proxy must be an instance of Net::Telnet or IO." - end - else - message = "Trying " + @options["Host"] + "...\n" - yield(message) if block_given? - @log.write(message) if @options.has_key?("Output_log") - @dumplog.log_dump('#', message) if @options.has_key?("Dump_log") - - begin - if @options["Timeout"] == false - @sock = TCPSocket.open(@options["Host"], @options["Port"]) - else - timeout(@options["Timeout"]) do - @sock = TCPSocket.open(@options["Host"], @options["Port"]) - end - end - rescue TimeoutError - raise TimeoutError, "timed out while opening a connection to the host" - rescue - @log.write($ERROR_INFO.to_s + "\n") if @options.has_key?("Output_log") - @dumplog.log_dump('#', $ERROR_INFO.to_s + "\n") if @options.has_key?("Dump_log") - raise - end - @sock.sync = true - @sock.binmode - - message = "Connected to " + @options["Host"] + ".\n" - yield(message) if block_given? - @log.write(message) if @options.has_key?("Output_log") - @dumplog.log_dump('#', message) if @options.has_key?("Dump_log") - end - - super(@sock) - end # initialize - - # The socket the Telnet object is using. Note that this object becomes - # a delegate of the Telnet object, so normally you invoke its methods - # directly on the Telnet object. - attr :sock - - # Set telnet command interpretation on (+mode+ == true) or off - # (+mode+ == false), or return the current value (+mode+ not - # provided). It should be on for true telnet sessions, off if - # using Net::Telnet to connect to a non-telnet service such - # as SMTP. - def telnetmode(mode = nil) - case mode - when nil - @options["Telnetmode"] - when true, false - @options["Telnetmode"] = mode - else - raise ArgumentError, "argument must be true or false, or missing" - end - end - - # Turn telnet command interpretation on (true) or off (false). It - # should be on for true telnet sessions, off if using Net::Telnet - # to connect to a non-telnet service such as SMTP. - def telnetmode=(mode) - if (true == mode or false == mode) - @options["Telnetmode"] = mode - else - raise ArgumentError, "argument must be true or false" - end - end - - # Turn newline conversion on (+mode+ == false) or off (+mode+ == true), - # or return the current value (+mode+ is not specified). - def binmode(mode = nil) - case mode - when nil - @options["Binmode"] - when true, false - @options["Binmode"] = mode - else - raise ArgumentError, "argument must be true or false" - end - end - - # Turn newline conversion on (false) or off (true). - def binmode=(mode) - if (true == mode or false == mode) - @options["Binmode"] = mode - else - raise ArgumentError, "argument must be true or false" - end - end - - # Preprocess received data from the host. - # - # Performs newline conversion and detects telnet command sequences. - # Called automatically by #waitfor(). You should only use this - # method yourself if you have read input directly using sysread() - # or similar, and even then only if in telnet mode. - def preprocess(string) - # combine CR+NULL into CR - string = string.gsub(/#{CR}#{NULL}/no, CR) if @options["Telnetmode"] - - # combine EOL into "\n" - string = string.gsub(/#{EOL}/no, "\n") unless @options["Binmode"] - - string.gsub(/#{IAC}( - [#{IAC}#{AO}#{AYT}#{DM}#{IP}#{NOP}]| - [#{DO}#{DONT}#{WILL}#{WONT}] - [#{OPT_BINARY}-#{OPT_NEW_ENVIRON}#{OPT_EXOPL}]| - #{SB}[^#{IAC}]*#{IAC}#{SE} - )/xno) do - if IAC == $1 # handle escaped IAC characters - IAC - elsif AYT == $1 # respond to "IAC AYT" (are you there) - self.write("nobody here but us pigeons" + EOL) - '' - elsif DO[0] == $1[0] # respond to "IAC DO x" - if OPT_BINARY[0] == $1[1] - @telnet_option["BINARY"] = true - self.write(IAC + WILL + OPT_BINARY) - else - self.write(IAC + WONT + $1[1..1]) - end - '' - elsif DONT[0] == $1[0] # respond to "IAC DON'T x" with "IAC WON'T x" - self.write(IAC + WONT + $1[1..1]) - '' - elsif WILL[0] == $1[0] # respond to "IAC WILL x" - if OPT_BINARY[0] == $1[1] - self.write(IAC + DO + OPT_BINARY) - elsif OPT_ECHO[0] == $1[1] - self.write(IAC + DO + OPT_ECHO) - elsif OPT_SGA[0] == $1[1] - @telnet_option["SGA"] = true - self.write(IAC + DO + OPT_SGA) - else - self.write(IAC + DONT + $1[1..1]) - end - '' - elsif WONT[0] == $1[0] # respond to "IAC WON'T x" - if OPT_ECHO[0] == $1[1] - self.write(IAC + DONT + OPT_ECHO) - elsif OPT_SGA[0] == $1[1] - @telnet_option["SGA"] = false - self.write(IAC + DONT + OPT_SGA) - else - self.write(IAC + DONT + $1[1..1]) - end - '' - else - '' - end - end - end # preprocess - - # Read data from the host until a certain sequence is matched. - # - # If a block is given, the received data will be yielded as it - # is read in (not necessarily all in one go), or nil if EOF - # occurs before any data is received. Whether a block is given - # or not, all data read will be returned in a single string, or again - # nil if EOF occurs before any data is received. Note that - # received data includes the matched sequence we were looking for. - # - # +options+ can be either a regular expression or a hash of options. - # If a regular expression, this specifies the data to wait for. - # If a hash, this can specify the following options: - # - # Match:: a regular expression, specifying the data to wait for. - # Prompt:: as for Match; used only if Match is not specified. - # String:: as for Match, except a string that will be converted - # into a regular expression. Used only if Match and - # Prompt are not specified. - # Timeout:: the number of seconds to wait for data from the host - # before raising a TimeoutError. If set to false, - # no timeout will occur. If not specified, the - # Timeout option value specified when this instance - # was created will be used, or, failing that, the - # default value of 10 seconds. - # Waittime:: the number of seconds to wait after matching against - # the input data to see if more data arrives. If more - # data arrives within this time, we will judge ourselves - # not to have matched successfully, and will continue - # trying to match. If not specified, the Waittime option - # value specified when this instance was created will be - # used, or, failing that, the default value of 0 seconds, - # which means not to wait for more input. - # - def waitfor(options) # :yield: recvdata - time_out = @options["Timeout"] - waittime = @options["Waittime"] - - if options.kind_of?(Hash) - prompt = if options.has_key?("Match") - options["Match"] - elsif options.has_key?("Prompt") - options["Prompt"] - elsif options.has_key?("String") - Regexp.new( Regexp.quote(options["String"]) ) - end - time_out = options["Timeout"] if options.has_key?("Timeout") - waittime = options["Waittime"] if options.has_key?("Waittime") - else - prompt = options - end - - if time_out == false - time_out = nil - end - - line = '' - buf = '' - rest = '' - until(prompt === line and not IO::select([@sock], nil, nil, waittime)) - unless IO::select([@sock], nil, nil, time_out) - raise TimeoutError, "timed out while waiting for more data" - end - begin - c = @sock.readpartial(1024 * 1024) - @dumplog.log_dump('<', c) if @options.has_key?("Dump_log") - if @options["Telnetmode"] - c = rest + c - if Integer(c.rindex(/#{IAC}#{SE}/no)) < - Integer(c.rindex(/#{IAC}#{SB}/no)) - buf = preprocess(c[0 ... c.rindex(/#{IAC}#{SB}/no)]) - rest = c[c.rindex(/#{IAC}#{SB}/no) .. -1] - elsif pt = c.rindex(/#{IAC}[^#{IAC}#{AO}#{AYT}#{DM}#{IP}#{NOP}]?\z/no) || - c.rindex(/\r\z/no) - buf = preprocess(c[0 ... pt]) - rest = c[pt .. -1] - else - buf = preprocess(c) - rest = '' - end - else - # Not Telnetmode. - # - # We cannot use preprocess() on this data, because that - # method makes some Telnetmode-specific assumptions. - buf = rest + c - rest = '' - unless @options["Binmode"] - if pt = buf.rindex(/\r\z/no) - buf = buf[0 ... pt] - rest = buf[pt .. -1] - end - buf.gsub!(/#{EOL}/no, "\n") - end - end - @log.print(buf) if @options.has_key?("Output_log") - line += buf - yield buf if block_given? - rescue EOFError # End of file reached - if line == '' - line = nil - yield nil if block_given? - end - break - end - end - line - end - - # Write +string+ to the host. - # - # Does not perform any conversions on +string+. Will log +string+ to the - # dumplog, if the Dump_log option is set. - def write(string) - length = string.length - while 0 < length - IO::select(nil, [@sock]) - @dumplog.log_dump('>', string[-length..-1]) if @options.has_key?("Dump_log") - length -= @sock.syswrite(string[-length..-1]) - end - end - - # Sends a string to the host. - # - # This does _not_ automatically append a newline to the string. Embedded - # newlines may be converted and telnet command sequences escaped - # depending upon the values of telnetmode, binmode, and telnet options - # set by the host. - def print(string) - string = string.gsub(/#{IAC}/no, IAC + IAC) if @options["Telnetmode"] - - if @options["Binmode"] - self.write(string) - else - if @telnet_option["BINARY"] and @telnet_option["SGA"] - # IAC WILL SGA IAC DO BIN send EOL --> CR - self.write(string.gsub(/\n/n, CR)) - elsif @telnet_option["SGA"] - # IAC WILL SGA send EOL --> CR+NULL - self.write(string.gsub(/\n/n, CR + NULL)) - else - # NONE send EOL --> CR+LF - self.write(string.gsub(/\n/n, EOL)) - end - end - end - - # Sends a string to the host. - # - # Same as #print(), but appends a newline to the string. - def puts(string) - self.print(string + "\n") - end - - # Send a command to the host. - # - # More exactly, sends a string to the host, and reads in all received - # data until is sees the prompt or other matched sequence. - # - # If a block is given, the received data will be yielded to it as - # it is read in. Whether a block is given or not, the received data - # will be return as a string. Note that the received data includes - # the prompt and in most cases the host's echo of our command. - # - # +options+ is either a String, specified the string or command to - # send to the host; or it is a hash of options. If a hash, the - # following options can be specified: - # - # String:: the command or other string to send to the host. - # Match:: a regular expression, the sequence to look for in - # the received data before returning. If not specified, - # the Prompt option value specified when this instance - # was created will be used, or, failing that, the default - # prompt of /[$%#>] \z/n. - # Timeout:: the seconds to wait for data from the host before raising - # a Timeout error. If not specified, the Timeout option - # value specified when this instance was created will be - # used, or, failing that, the default value of 10 seconds. - # - # The command or other string will have the newline sequence appended - # to it. - def cmd(options) # :yield: recvdata - match = @options["Prompt"] - time_out = @options["Timeout"] - - if options.kind_of?(Hash) - string = options["String"] - match = options["Match"] if options.has_key?("Match") - time_out = options["Timeout"] if options.has_key?("Timeout") - else - string = options - end - - self.puts(string) - if block_given? - waitfor({"Prompt" => match, "Timeout" => time_out}){|c| yield c } - else - waitfor({"Prompt" => match, "Timeout" => time_out}) - end - end - - # Login to the host with a given username and password. - # - # The username and password can either be provided as two string - # arguments in that order, or as a hash with keys "Name" and - # "Password". - # - # This method looks for the strings "login" and "Password" from the - # host to determine when to send the username and password. If the - # login sequence does not follow this pattern (for instance, you - # are connecting to a service other than telnet), you will need - # to handle login yourself. - # - # The password can be omitted, either by only - # provided one String argument, which will be used as the username, - # or by providing a has that has no "Password" key. In this case, - # the method will not look for the "Password:" prompt; if it is - # sent, it will have to be dealt with by later calls. - # - # The method returns all data received during the login process from - # the host, including the echoed username but not the password (which - # the host should not echo). If a block is passed in, this received - # data is also yielded to the block as it is received. - def login(options, password = nil) # :yield: recvdata - login_prompt = /[Ll]ogin[: ]*\z/n - password_prompt = /[Pp]ass(?:word|phrase)[: ]*\z/n - if options.kind_of?(Hash) - username = options["Name"] - password = options["Password"] - login_prompt = options["LoginPrompt"] if options["LoginPrompt"] - password_prompt = options["PasswordPrompt"] if options["PasswordPrompt"] - else - username = options - end - - if block_given? - line = waitfor(login_prompt){|c| yield c } - if password - line += cmd({"String" => username, - "Match" => password_prompt}){|c| yield c } - line += cmd(password){|c| yield c } - else - line += cmd(username){|c| yield c } - end - else - line = waitfor(login_prompt) - if password - line += cmd({"String" => username, - "Match" => password_prompt}) - line += cmd(password) - else - line += cmd(username) - end - end - line - end - - end # class Telnet -end # module Net - |
