# frozen_string_literal: true # rubocop:todo all # 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://'.freeze # The mongodb connection string scheme root. # # @since 2.5.0 MONGODB_SCHEME = 'mongodb'.freeze # The mongodb srv protocol connection string scheme root. # # @since 2.5.0 MONGODB_SRV_SCHEME = 'mongodb+srv'.freeze # Error details for an invalid scheme. # # @since 2.1.0 # @deprecated INVALID_SCHEME = "Invalid scheme. Scheme must be '#{MONGODB_SCHEME}' or '#{MONGODB_SRV_SCHEME}'".freeze # MongoDB URI format specification. # # @since 2.0.0 FORMAT = 'mongodb://[username:password@]host1[:port1][,host2[:port2]' + ',...[,hostN[:portN]]][/[database][?options]]'.freeze # MongoDB URI (connection string) documentation url # # @since 2.0.0 HELP = 'https://www.mongodb.com/docs/manual/reference/connection-string/'.freeze # Unsafe characters that must be urlencoded. # # @since 2.1.0 UNSAFE = /[\:\/\@]/ # 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 = ','.freeze # The character separating a host and port. # # @since 2.1.0 HOST_PORT_DELIM = ':'.freeze # The character delimiting a database. # # @since 2.1.0 DATABASE_DELIM = '/'.freeze # The character delimiting options. # # @since 2.1.0 URI_OPTS_DELIM = '?'.freeze # The character delimiting multiple options. # # @since 2.1.0 # @deprecated INDIV_URI_OPTS_DELIM = '&'.freeze # The character delimiting an option and its value. # # @since 2.1.0 URI_OPTS_VALUE_DELIM = '='.freeze # The character separating a username from the password. # # @since 2.1.0 AUTH_USER_PWD_DELIM = ':'.freeze # The character delimiting auth credentials. # # @since 2.1.0 AUTH_DELIM = '@'.freeze # Scheme delimiter. # # @since 2.5.0 SCHEME_DELIM = '://'.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}'".freeze # Error details for an non-urlencoded user name or password. # # @since 2.1.0 UNESCAPED_USER_PWD = "User name and password must be urlencoded.".freeze # Error details for a non-urlencoded unix socket path. # # @since 2.1.0 UNESCAPED_UNIX_SOCKET = "UNIX domain sockets must be urlencoded.".freeze # Error details for a non-urlencoded auth database name. # # @since 2.1.0 UNESCAPED_DATABASE = "Auth database must be urlencoded.".freeze # 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.".freeze # Error details for a missing host. # # @since 2.1.0 INVALID_HOST = "Missing host; at least one must be provided.".freeze # 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".freeze # 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 # 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 = [ :tag_sets, :ssl ] # 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 = {}) unless string raise Error::InvalidURI.new(string, 'URI must be a string, not nil.') end if string.empty? raise Error::InvalidURI.new(string, 'Cannot parse an empty URI.') end 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 = {}) unless string raise Error::InvalidURI.new(string, 'URI must be a string, not nil.') end if string.empty? raise Error::InvalidURI.new(string, 'Cannot parse an empty URI.') end @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 if remaining.empty? raise_invalid_error!('No hosts in the URI') end 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 : Database::ADMIN end # Get the uri as a string. # # @example Get the uri as a string. # uri.to_s # # @return [ String ] The uri string. def to_s reconstruct_uri end private # Reconstruct the URI from its parts. 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 += @user.to_s if @user uri += "#{AUTH_USER_PWD_DELIM}#{@password}" if @password uri += "@" 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 if options && !hosts_and_db.index('/') raise_invalid_error!("MongoDB URI must have a slash (/) after the hosts if options are given") end hosts, db = hosts_and_db.split('/', 2) if db && db.index('/') raise_invalid_error!("Database name contains an unescaped slash (/): #{db}") end if hosts.index('@') creds, hosts = hosts.split('@', 2) if hosts.empty? raise_invalid_error!("Empty hosts list") end if hosts.index('@') raise_invalid_error!("Unescaped @ in auth info") end end unless hosts.length > 0 raise_invalid_error!("Missing host; at least one must be provided") end @servers = hosts.split(',').map do |host| if host.empty? raise_invalid_error!('Empty host given in the host list') end 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)) if db @database = parse_database!(db) end 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 = {} unless string return uri_options end string.split('&').each do |option_str| if option_str.empty? next end key, value = option_str.split('=', 2) if value.nil? raise_invalid_error!("Option #{key} has no value") end 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 user =~ UNSAFE user_decoded = decode(user) if user_decoded =~ PERCENT_CHAR && encode(user_decoded) != user raise_invalid_error!(UNESCAPED_USER_PWD) end user_decoded end end def parse_password!(string) if (string && pwd = string.partition(AUTH_USER_PWD_DELIM)[2]) if pwd.length > 0 raise_invalid_error!(UNESCAPED_USER_PWD) if pwd =~ UNSAFE pwd_decoded = decode(pwd) if pwd_decoded =~ PERCENT_CHAR && encode(pwd_decoded) != pwd raise_invalid_error!(UNESCAPED_USER_PWD) end pwd_decoded end end end def parse_database!(string) raise_invalid_error!(UNESCAPED_DATABASE) if string =~ UNSAFE 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? 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 unless uri_options[:ssl_verify_certificate].nil? unless uri_options[:ssl_verify_ocsp_endpoint].nil? raise_invalid_error_no_fmt!("tlsAllowInvalidCertificates' and 'tlsDisableOCSPEndpointCheck' cannot both be specified") end 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. if uri_options[:ssl_cert] uri_options[:ssl_key] = uri_options[:ssl_cert] end 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 if servers.length > 1 raise_invalid_error_no_fmt!("directConnection=true cannot be used with multiple seeds") end 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] if servers.length > 1 raise_invalid_error_no_fmt!("loadBalanced=true cannot be used with multiple seeds") end 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 self.is_a?(URI::SRVProtocol) if uri_options[:srv_max_hosts] raise_invalid_error_no_fmt!("srvMaxHosts cannot be used on non-SRV URI") end if uri_options[:srv_service_name] raise_invalid_error_no_fmt!("srvServiceName cannot be used on non-SRV URI") end end if uri_options[:srv_max_hosts] && uri_options[:srv_max_hosts] > 0 if uri_options[:replica_set] raise_invalid_error_no_fmt!("srvMaxHosts > 0 cannot be used with replicaSet option") end if options[:load_balanced] raise_invalid_error_no_fmt!("srvMaxHosts > 0 cannot be used with loadBalanced=true") end end end end end require 'mongo/uri/options_mapper' require 'mongo/uri/srv_protocol'