# frozen_string_literal: true

# Copyright (C) 2014-2020 MongoDB Inc.
#
# Licensed under the Apache License, Version 2.0 (the 'License');
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#   http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an 'AS IS' BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

module Mongo
  # The URI class provides a way for users to parse the MongoDB uri as
  # defined in the connection string format spec.
  #
  # https://www.mongodb.com/docs/manual/reference/connection-string/
  #
  # @example Use the uri string to make a client connection.
  #   uri = Mongo::URI.new('mongodb://localhost:27017')
  #   client = Mongo::Client.new(uri.servers, uri.options)
  #   client.login(uri.credentials)
  #   client[uri.database]
  #
  # @since 2.0.0
  class URI
    include Loggable
    include Address::Validator

    # The uri parser object options.
    #
    # @since 2.0.0
    attr_reader :options

    # Mongo::Options::Redacted of the options specified in the uri.
    #
    # @since 2.1.0
    attr_reader :uri_options

    # The servers specified in the uri.
    #
    # @since 2.0.0
    attr_reader :servers

    # The mongodb connection string scheme.
    #
    # @deprecated Will be removed in 3.0.
    #
    # @since 2.0.0
    SCHEME = 'mongodb://'

    # The mongodb connection string scheme root.
    #
    # @since 2.5.0
    MONGODB_SCHEME = 'mongodb'

    # The mongodb srv protocol connection string scheme root.
    #
    # @since 2.5.0
    MONGODB_SRV_SCHEME = 'mongodb+srv'

    # Error details for an invalid scheme.
    #
    # @since 2.1.0
    # @deprecated
    INVALID_SCHEME = "Invalid scheme. Scheme must be '#{MONGODB_SCHEME}' or '#{MONGODB_SRV_SCHEME}'"

    # MongoDB URI format specification.
    #
    # @since 2.0.0
    FORMAT = 'mongodb://[username:password@]host1[:port1][,host2[:port2]' +
             ',...[,hostN[:portN]]][/[database][?options]]'

    # MongoDB URI (connection string) documentation url
    #
    # @since 2.0.0
    HELP = 'https://www.mongodb.com/docs/manual/reference/connection-string/'

    # Unsafe characters that must be urlencoded.
    #
    # @since 2.1.0
    UNSAFE = %r{[:/@]}

    # Percent sign that must be encoded in user creds.
    #
    # @since 2.5.1
    PERCENT_CHAR = /%/

    # Unix socket suffix.
    #
    # @since 2.1.0
    UNIX_SOCKET = /.sock/

    # The character delimiting hosts.
    #
    # @since 2.1.0
    HOST_DELIM = ','

    # The character separating a host and port.
    #
    # @since 2.1.0
    HOST_PORT_DELIM = ':'

    # The character delimiting a database.
    #
    # @since 2.1.0
    DATABASE_DELIM = '/'

    # The character delimiting options.
    #
    # @since 2.1.0
    URI_OPTS_DELIM = '?'

    # The character delimiting multiple options.
    #
    # @since 2.1.0
    # @deprecated
    INDIV_URI_OPTS_DELIM = '&'

    # The character delimiting an option and its value.
    #
    # @since 2.1.0
    URI_OPTS_VALUE_DELIM = '='

    # The character separating a username from the password.
    #
    # @since 2.1.0
    AUTH_USER_PWD_DELIM = ':'

    # The character delimiting auth credentials.
    #
    # @since 2.1.0
    AUTH_DELIM = '@'

    # Scheme delimiter.
    #
    # @since 2.5.0
    SCHEME_DELIM = '://'

    # Placeholder used in place of cleartext credentials when a URI is
    # rendered for display, logging, or error reporting.
    CREDENTIALS_PLACEHOLDER = '<credentials>'

    # Pattern matching the userinfo portion of a MongoDB connection string.
    # Anchors at the start and is bounded to the authority component (stops at
    # the first '/', '?', or '#'), but matches greedily up to the last '@' in
    # that component so passwords containing an unescaped '@' are still fully
    # redacted. Case-insensitive so an unusual scheme like 'MongoDB://' is
    # redacted too — the parser will reject it, and the redactor must not be
    # the thing that leaks the credentials in the resulting error.
    USERINFO_REDACTION_REGEX = %r{\A(mongodb(?:\+srv)?://)[^/?#]*@}i.freeze

    # Error details for an invalid options format.
    #
    # @since 2.1.0
    INVALID_OPTS_VALUE_DELIM = 'Options and their values must be delimited' +
                               " by '#{URI_OPTS_VALUE_DELIM}'"

    # Error details for an non-urlencoded user name or password.
    #
    # @since 2.1.0
    UNESCAPED_USER_PWD = 'User name and password must be urlencoded.'

    # Error details for a non-urlencoded unix socket path.
    #
    # @since 2.1.0
    UNESCAPED_UNIX_SOCKET = 'UNIX domain sockets must be urlencoded.'

    # Error details for a non-urlencoded auth database name.
    #
    # @since 2.1.0
    UNESCAPED_DATABASE = 'Auth database must be urlencoded.'

    # Error details for providing options without a database delimiter.
    #
    # @since 2.1.0
    INVALID_OPTS_DELIM = "Database delimiter '#{DATABASE_DELIM}' must be present if options are specified."

    # Error details for a missing host.
    #
    # @since 2.1.0
    INVALID_HOST = 'Missing host; at least one must be provided.'

    # Error details for an invalid port.
    #
    # @since 2.1.0
    INVALID_PORT = 'Invalid port. Port must be an integer greater than 0 and less than 65536'

    # Map of URI read preference modes to Ruby driver read preference modes
    #
    # @since 2.0.0
    READ_MODE_MAP = {
      'primary' => :primary,
      'primarypreferred' => :primary_preferred,
      'secondary' => :secondary,
      'secondarypreferred' => :secondary_preferred,
      'nearest' => :nearest
    }.freeze

    # Map of URI authentication mechanisms to Ruby driver mechanisms
    #
    # @since 2.0.0
    AUTH_MECH_MAP = {
      'GSSAPI' => :gssapi,
      'MONGODB-AWS' => :aws,
      # MONGODB-CR is deprecated and will be removed in driver version 3.0
      'MONGODB-CR' => :mongodb_cr,
      'MONGODB-X509' => :mongodb_x509,
      'PLAIN' => :plain,
      'SCRAM-SHA-1' => :scram,
      'SCRAM-SHA-256' => :scram256,
    }.freeze

    # Valid values for the serverMonitoringMode URI option.
    SERVER_MONITORING_MODES = %w[stream poll auto].freeze

    # Options that are allowed to appear more than once in the uri.
    #
    # In order to follow the URI options spec requirement that all instances
    # of 'tls' and 'ssl' have the same value, we need to keep track of all
    # of the values passed in for those options. Assuming they don't conflict,
    # they will be condensed to a single value immediately after parsing the URI.
    #
    # @since 2.1.0
    REPEATABLE_OPTIONS = %i[tag_sets ssl]

    # Replace the userinfo portion of a MongoDB connection string with a
    # placeholder so the result can safely be logged, displayed, or embedded
    # in an exception message.
    #
    # The input is matched as a string, not parsed, so this is safe to call
    # on malformed URIs (which is exactly when {InvalidURI} is raised).
    #
    # @param [ String ] string The raw URI string.
    #
    # @return [ String ] The URI with any userinfo replaced by
    #   {CREDENTIALS_PLACEHOLDER}, or the input unchanged if it is not a
    #   string or has no userinfo.
    def self.redact(string)
      return string unless string.is_a?(String)

      string.sub(USERINFO_REDACTION_REGEX, "\\1#{CREDENTIALS_PLACEHOLDER}@")
    end

    # Get either a URI object or a SRVProtocol URI object.
    #
    # @example Get the uri object.
    #   URI.get(string)
    #
    # @param [ String ] string The URI to parse.
    # @param [ Hash ] opts The options.
    #
    # @option options [ Logger ] :logger A custom logger to use.
    #
    # @return [URI, URI::SRVProtocol] The uri object.
    #
    # @since 2.5.0
    def self.get(string, opts = {})
      raise Error::InvalidURI.new(string, 'URI must be a string, not nil.') unless string
      raise Error::InvalidURI.new(string, 'Cannot parse an empty URI.') if string.empty?

      scheme, = string.partition(SCHEME_DELIM)
      case scheme
      when MONGODB_SCHEME
        URI.new(string, opts)
      when MONGODB_SRV_SCHEME
        SRVProtocol.new(string, opts)
      else
        raise Error::InvalidURI.new(string,
                                    "Invalid scheme '#{scheme}'. Scheme must be '#{MONGODB_SCHEME}' or '#{MONGODB_SRV_SCHEME}'")
      end
    end

    # Gets the options hash that needs to be passed to a Mongo::Client on
    # instantiation, so we don't have to merge the credentials and database in
    # at that point - we only have a single point here.
    #
    # @example Get the client options.
    #   uri.client_options
    #
    # @return [ Mongo::Options::Redacted ] The options passed to the Mongo::Client
    #
    # @since 2.0.0
    def client_options
      opts = uri_options.tap do |opts|
        opts[:database] = @database if @database
      end

      @user ? opts.merge(credentials) : opts
    end

    def srv_records
      nil
    end

    # Create the new uri from the provided string.
    #
    # @example Create the new URI.
    #   URI.new('mongodb://localhost:27017')
    #
    # @param [ String ] string The URI to parse.
    # @param [ Hash ] options The options.
    #
    # @option options [ Logger ] :logger A custom logger to use.
    #
    # @raise [ Error::InvalidURI ] If the uri does not match the spec.
    #
    # @since 2.0.0
    def initialize(string, options = {})
      raise Error::InvalidURI.new(string, 'URI must be a string, not nil.') unless string
      raise Error::InvalidURI.new(string, 'Cannot parse an empty URI.') if string.empty?

      @string = string
      @options = options
      parsed_scheme, _, remaining = string.partition(SCHEME_DELIM)
      unless parsed_scheme == scheme
        raise_invalid_error!("Invalid scheme '#{parsed_scheme}'. Scheme must be '#{MONGODB_SCHEME}'. Use URI#get to parse SRV URIs.")
      end
      raise_invalid_error!('No hosts in the URI') if remaining.empty?
      parse!(remaining)
      validate_uri_options!
    end

    # Get the credentials provided in the URI.
    #
    # @example Get the credentials.
    #   uri.credentials
    #
    # @return [ Hash ] The credentials.
    #   * :user [ String ] The user.
    #   * :password [ String ] The provided password.
    #
    # @since 2.0.0
    def credentials
      { user: @user, password: @password }
    end

    # Get the database provided in the URI.
    #
    # @example Get the database.
    #   uri.database
    #
    # @return [String] The database.
    #
    # @since 2.0.0
    def database
      @database || Database::ADMIN
    end

    # Get the uri as a string with any credentials redacted.
    #
    # Credentials are replaced with {CREDENTIALS_PLACEHOLDER} so the result is
    # safe to log or display. Use {#credentials} to recover the original user
    # and password.
    #
    # @example Get the uri as a string.
    #   uri.to_s
    #
    # @return [ String ] The redacted uri string.
    def to_s
      reconstruct_uri
    end

    # Return a redacted, human-readable representation of the URI. The
    # default {Object#inspect} would dump {@string} and {@password} as
    # instance variables, leaking credentials.
    #
    # @return [ String ] The redacted inspect string.
    def inspect
      "#<#{self.class.name}: #{reconstruct_uri}>"
    end

    private

    # Reconstruct the URI from its parts with credentials redacted. Invalid
    # options are dropped and options are converted to camelCase.
    #
    # @return [ String ] the uri.
    def reconstruct_uri
      servers = @servers.join(',')
      options = options_mapper.ruby_to_string(@uri_options).map do |k, vs|
        unless vs.nil?
          if vs.is_a?(Array)
            vs.map { |v| "#{k}=#{v}" }.join('&')
          else
            "#{k}=#{vs}"
          end
        end
      end.compact.join('&')

      uri = "#{scheme}#{SCHEME_DELIM}"
      uri += "#{CREDENTIALS_PLACEHOLDER}@" if @user || @password
      uri += @query_hostname || servers
      uri += '/' if @database || !options.empty?
      uri += @database.to_s if @database
      uri += "?#{options}" unless options.empty?
      uri
    end

    def scheme
      MONGODB_SCHEME
    end

    def parse!(remaining)
      hosts_and_db, options = remaining.split('?', 2)
      if options && options.index('?')
        raise_invalid_error!('Options contain an unescaped question mark (?), or the database name contains a question mark and was not escaped')
      end

      hosts, db = hosts_and_db.split('/', 2)
      raise_invalid_error!("Database name contains an unescaped slash (/): #{db}") if db && db.index('/')

      if hosts.index('@')
        creds, hosts = hosts.split('@', 2)
        raise_invalid_error!('Empty hosts list') if hosts.empty?
        raise_invalid_error!('Unescaped @ in auth info') if hosts.index('@')
      end

      raise_invalid_error!('Missing host; at least one must be provided') unless hosts.length > 0

      @servers = hosts.split(',').map do |host|
        raise_invalid_error!('Empty host given in the host list') if host.empty?
        decode(host).tap do |host|
          validate_address_str!(host)
        end
      end

      @user = parse_user!(creds)
      @password = parse_password!(creds)
      @uri_options = Options::Redacted.new(parse_uri_options!(options))
      @database = parse_database!(db) if db
    rescue Error::InvalidAddress => e
      raise_invalid_error!(e.message)
    end

    def options_mapper
      @options_mapper ||= OptionsMapper.new(
        logger: @options[:logger]
      )
    end

    def parse_uri_options!(string)
      uri_options = {}
      return uri_options unless string

      string.split('&').each do |option_str|
        next if option_str.empty?

        key, value = option_str.split('=', 2)
        raise_invalid_error!("Option #{key} has no value") if value.nil?
        key = decode(key)
        value = decode(value)
        options_mapper.add_uri_option(key, value, uri_options)
      end
      uri_options
    end

    def parse_user!(string)
      if string && (user = string.partition(AUTH_USER_PWD_DELIM)[0])
        raise_invalid_error!(UNESCAPED_USER_PWD) if UNSAFE.match?(user)
        user_decoded = decode(user)
        raise_invalid_error!(UNESCAPED_USER_PWD) if user_decoded =~ PERCENT_CHAR && encode(user_decoded) != user
        user_decoded
      end
    end

    def parse_password!(string)
      if string && (pwd = string.partition(AUTH_USER_PWD_DELIM)[2]) && (pwd.length > 0)
        raise_invalid_error!(UNESCAPED_USER_PWD) if UNSAFE.match?(pwd)
        pwd_decoded = decode(pwd)
        raise_invalid_error!(UNESCAPED_USER_PWD) if pwd_decoded =~ PERCENT_CHAR && encode(pwd_decoded) != pwd
        pwd_decoded
      end
    end

    def parse_database!(string)
      raise_invalid_error!(UNESCAPED_DATABASE) if UNSAFE.match?(string)
      decode(string) if string.length > 0
    end

    def raise_invalid_error!(details)
      raise Error::InvalidURI.new(@string, details, FORMAT)
    end

    def raise_invalid_error_no_fmt!(details)
      raise Error::InvalidURI.new(@string, details)
    end

    def decode(value)
      ::URI::DEFAULT_PARSER.unescape(value)
    end

    def encode(value)
      CGI.escape(value).gsub('+', '%20')
    end

    def validate_uri_options!
      # The URI options spec requires that we raise an error if there are conflicting values of
      # 'tls' and 'ssl'. In order to fulfill this, we parse the values of each instance into an
      # array; assuming all values in the array are the same, we replace the array with that value.
      unless uri_options[:ssl].nil? || uri_options[:ssl].empty?
        # A nil element means an invalid value (e.g. tls=yes) was supplied.
        # Refuse it rather than silently connecting over plaintext (RUBY-3832).
        if uri_options[:ssl].include?(nil)
          raise_invalid_error_no_fmt!("invalid boolean value for 'tls' or 'ssl'; only 'true' and 'false' are accepted")
        end

        unless uri_options[:ssl].uniq.length == 1
          raise_invalid_error_no_fmt!("all instances of 'tls' and 'ssl' must have the same value")
        end

        uri_options[:ssl] = uri_options[:ssl].first
      end

      # Check for conflicting TLS insecure options.
      unless uri_options[:ssl_verify].nil?
        unless uri_options[:ssl_verify_certificate].nil?
          raise_invalid_error_no_fmt!("'tlsInsecure' and 'tlsAllowInvalidCertificates' cannot both be specified")
        end

        unless uri_options[:ssl_verify_hostname].nil?
          raise_invalid_error_no_fmt!("tlsInsecure' and 'tlsAllowInvalidHostnames' cannot both be specified")
        end

        unless uri_options[:ssl_verify_ocsp_endpoint].nil?
          raise_invalid_error_no_fmt!("tlsInsecure' and 'tlsDisableOCSPEndpointCheck' cannot both be specified")
        end
      end

      if !uri_options[:ssl_verify_certificate].nil? && !uri_options[:ssl_verify_ocsp_endpoint].nil?
        raise_invalid_error_no_fmt!("tlsAllowInvalidCertificates' and 'tlsDisableOCSPEndpointCheck' cannot both be specified")
      end

      # Since we know that the only URI option that sets :ssl_cert is
      # "tlsCertificateKeyFile", any value set for :ssl_cert must also be set
      # for :ssl_key.
      uri_options[:ssl_key] = uri_options[:ssl_cert] if uri_options[:ssl_cert]

      if uri_options[:write_concern] && !uri_options[:write_concern].empty?
        begin
          WriteConcern.get(uri_options[:write_concern])
        rescue Error::InvalidWriteConcern => e
          raise_invalid_error_no_fmt!("#{e.class}: #{e}")
        end
      end

      if uri_options[:direct_connection]
        if uri_options[:connect] && uri_options[:connect].to_s != 'direct'
          raise_invalid_error_no_fmt!("directConnection=true cannot be used with connect=#{uri_options[:connect]}")
        end
        raise_invalid_error_no_fmt!('directConnection=true cannot be used with multiple seeds') if servers.length > 1
      elsif uri_options[:direct_connection] == false && uri_options[:connect].to_s == 'direct'
        raise_invalid_error_no_fmt!('directConnection=false cannot be used with connect=direct')
      end

      if uri_options[:load_balanced]
        raise_invalid_error_no_fmt!('loadBalanced=true cannot be used with multiple seeds') if servers.length > 1

        if uri_options[:direct_connection]
          raise_invalid_error_no_fmt!('directConnection=true cannot be used with loadBalanced=true')
        end

        if uri_options[:connect] && uri_options[:connect].to_sym == :direct
          raise_invalid_error_no_fmt!('connect=direct cannot be used with loadBalanced=true')
        end

        if uri_options[:replica_set]
          raise_invalid_error_no_fmt!('loadBalanced=true cannot be used with replicaSet option')
        end
      end

      unless is_a?(URI::SRVProtocol)
        raise_invalid_error_no_fmt!('srvMaxHosts cannot be used on non-SRV URI') if uri_options[:srv_max_hosts]

        raise_invalid_error_no_fmt!('srvServiceName cannot be used on non-SRV URI') if uri_options[:srv_service_name]
      end

      return unless uri_options[:srv_max_hosts] && uri_options[:srv_max_hosts] > 0

      raise_invalid_error_no_fmt!('srvMaxHosts > 0 cannot be used with replicaSet option') if uri_options[:replica_set]

      return unless options[:load_balanced]

      raise_invalid_error_no_fmt!('srvMaxHosts > 0 cannot be used with loadBalanced=true')
    end
  end
end

require 'mongo/uri/options_mapper'
require 'mongo/uri/srv_protocol'
