our
$VERSION
=
'v1.999.0'
;
Any
ArrayRef
HashRef
Str
)
;
my
$uri_re
=
qr{
mongodb(?:\+srv|)://
(?: ([^:]*) (?: : ([^@]*) )? @ )? # [username(:password)?@]
([^/?]*) # host1[:port1][,host2[:port2],...[,hostN[:portN]]]
(?:
/ ([^?]*) # /[database]
(?: [?] (.*) )? # [?options]
)?
}
x;
my
%options_with_list_type
=
map
{
lc
(
$_
) => 1 }
qw(
readPreferenceTags
)
;
has
uri
=> (
is
=>
'ro'
,
isa
=> Str,
required
=> 1,
);
has
username
=> (
is
=>
'ro'
,
isa
=> Any,
writer
=>
'_set_username'
,
);
has
password
=> (
is
=>
'ro'
,
isa
=> Any,
writer
=>
'_set_password'
,
);
has
db_name
=> (
is
=>
'ro'
,
isa
=> Str,
writer
=>
'_set_db_name'
,
default
=>
''
,
);
has
options
=> (
is
=>
'ro'
,
isa
=> HashRef,
writer
=>
'_set_options'
,
default
=>
sub
{ {} },
);
has
hostids
=> (
is
=>
'ro'
,
isa
=> ArrayRef,
writer
=>
'_set_hostids'
,
default
=>
sub
{ [] },
);
has
valid_options
=> (
is
=>
'ro'
,
isa
=> HashRef,
builder
=>
'_build_valid_options'
,
);
sub
_build_valid_options {
return
{
map
{
lc
(
$_
) => 1 }
qw(
appName
authMechanism
authMechanismProperties
authSource
compressors
connectTimeoutMS
connect
heartbeatFrequencyMS
journal
localThresholdMS
maxStalenessSeconds
maxTimeMS
readPreference
readPreferenceTags
replicaSet
retryWrites
serverSelectionTimeoutMS
serverSelectionTryOnce
socketCheckIntervalMS
socketTimeoutMS
ssl
w
wTimeoutMS
readConcernLevel
zlibCompressionLevel
)
};
}
has
valid_srv_options
=> (
is
=>
'ro'
,
isa
=> HashRef,
builder
=>
'_build_valid_srv_options'
,
);
sub
_build_valid_srv_options {
return
{
map
{
lc
(
$_
) => 1 }
qw(
authSource
replicaSet
)
};
}
sub
_unescape_all {
my
$str
=
shift
;
return
''
unless
defined
$str
;
if
(
$str
=~ s/%([0-9a-f]{2})/
chr
(
hex
($1))/ieg ) {
$str
= Encode::decode(
'UTF-8'
,
$str
);
}
return
$str
;
}
sub
_parse_doc {
my
(
$name
,
$string
) =
@_
;
my
$set
= {};
for
my
$tag
(
split
/,/,
$string
) {
if
(
$tag
=~ /\S/ ) {
my
@kv
=
map
{
my
$s
=
$_
;
$s
=~ s{^\s*}{};
$s
=~ s{\s*$}{};
$s
}
split
/:/,
$tag
, 2;
MongoDB::UsageError->throw(
"in option '$name', '$tag' is not a key:value pair"
)
unless
@kv
== 2;
$set
->{
$kv
[0]} =
$kv
[1];
}
}
return
$set
;
}
sub
_parse_options {
my
(
$self
,
$valid
,
$result
,
$txt_record
) =
@_
;
my
%parsed
;
for
my
$opt
(
split
'&'
,
$result
->{options} ) {
my
@kv
=
split
'='
,
$opt
, -1;
MongoDB::UsageError->throw(
"expected key value pair"
)
unless
@kv
== 2;
my
(
$k
,
$v
) =
map
{ _unescape_all(
$_
) }
@kv
;
(
my
$lc_k
=
$k
) =~
tr
[A-Z][a-z];
if
( !
$valid
->{
$lc_k
} ) {
if
(
$txt_record
) {
MongoDB::Error->throw(
"Unsupported option '$k' in URI $self for TXT record $txt_record\n"
);
}
else
{
warn
"Unsupported option '$k' in URI $self\n"
;
}
next
;
}
if
(
exists
$parsed
{
$lc_k
} && !
exists
$options_with_list_type
{
$lc_k
} ) {
warn
"Multiple options were found for the same value '$lc_k'. The first occurrence will be used\n"
;
next
;
}
if
(
$lc_k
eq
'authmechanismproperties'
) {
$parsed
{
$lc_k
} = _parse_doc(
$k
,
$v
);
}
elsif
(
$lc_k
eq
'compressors'
) {
$parsed
{
$lc_k
} = [
split
m{,},
$v
, -1];
}
elsif
(
$lc_k
eq
'authsource'
) {
$result
->{db_name} =
$v
;
$parsed
{
$lc_k
} =
$v
;
}
elsif
(
$lc_k
eq
'readpreferencetags'
) {
$parsed
{
$lc_k
} ||= [];
push
@{
$parsed
{
$lc_k
} }, _parse_doc(
$k
,
$v
);
}
elsif
(
$lc_k
eq
'ssl'
||
$lc_k
eq
'journal'
||
$lc_k
eq
'serverselectiontryonce'
) {
$parsed
{
$lc_k
} = __str_to_bool(
$k
,
$v
);
}
else
{
$parsed
{
$lc_k
} =
$v
;
}
}
return
\
%parsed
;
}
sub
_fetch_dns_seedlist {
my
(
$self
,
$host_name
) =
@_
;
my
@split_name
=
split
(
'\.'
,
$host_name
);
MongoDB::Error->throw(
"URI '$self' must contain domain name and hostname"
)
unless
scalar
(
@split_name
) > 2;
my
$res
= Net::DNS::Resolver->new;
my
$srv_data
=
$res
->query(
sprintf
(
'_mongodb._tcp.%s'
,
$host_name
),
'SRV'
);
my
@hosts
;
my
$options
= {};
my
$domain_name
=
join
(
'.'
,
@split_name
[1..
$#split_name
] );
if
(
$srv_data
) {
foreach
my
$rr
(
$srv_data
->answer ) {
next
unless
$rr
->type eq
'SRV'
;
my
$target
=
$rr
->target;
unless
(
$target
=~ /\.\Q
$domain_name
\E$/ ) {
MongoDB::Error->throw(
"URI '$self' SRV record returns FQDN '$target'"
.
" which does not match domain name '${$domain_name}'"
);
}
push
@hosts
, {
target
=>
$target
,
port
=>
$rr
->port,
};
}
my
$txt_data
=
$res
->query(
$host_name
,
'TXT'
);
if
(
defined
$txt_data
) {
my
@txt_answers
;
foreach
my
$rr
(
$txt_data
->answer ) {
next
unless
$rr
->type eq
'TXT'
;
push
@txt_answers
,
$rr
;
}
if
(
scalar
(
@txt_answers
) > 1 ) {
MongoDB::Error->throw(
"URI '$self' returned more than one TXT result"
);
}
elsif
(
scalar
(
@txt_answers
) == 1 ) {
my
$txt_opt_string
=
join
(
''
,
$txt_answers
[0]->txtdata );
$options
=
$self
->_parse_options(
$self
->valid_srv_options, {
options
=>
$txt_opt_string
},
$txt_opt_string
);
}
}
}
else
{
MongoDB::Error->throw(
"URI '$self' does not return any SRV results"
);
}
return
( \
@hosts
,
$options
);
}
sub
_parse_srv_uri {
my
(
$self
,
$uri
) =
@_
;
my
%result
;
$uri
=~ m{^
$uri_re
$};
(
$result
{username},
$result
{password},
$result
{hostids},
$result
{db_name},
$result
{options}
) = ( $1, $2, $3, $4, $5 );
$result
{hostids} =
lc
_unescape_all(
$result
{hostids} );
if
( !
defined
$result
{hostids} || !
length
$result
{hostids} ) {
MongoDB::Error->throw(
"URI '$self' cannot be empty if using an SRV connection string"
);
}
if
(
$result
{hostids} =~ /,/ ) {
MongoDB::Error->throw(
"URI '$self' cannot contain a comma or multiple host names if using an SRV connection string"
);
}
if
(
$result
{hostids} =~ /:\d+$/ ) {
MongoDB::Error->throw(
"URI '$self' cannot contain port number if using an SRV connection string"
);
}
if
(
defined
$result
{options} ) {
$result
{options} =
$self
->_parse_options(
$self
->valid_options, \
%result
);
}
my
(
$hosts
,
$options
) =
$self
->_fetch_dns_seedlist(
$result
{hostids} );
$options
= {
ssl
=>
'true'
,
%$options
,
%{
$result
{options} || {} },
};
if
( !
$options
->{ssl} &&
$options
->{ssl} == 0 ) {
$options
->{ssl} =
'false'
;
}
my
$auth
=
""
;
if
(
defined
$result
{username} ||
defined
$result
{password} ) {
$auth
=
join
(
":"
,
map
{
$_
//
""
}
$result
{username},
$result
{password});
$auth
.=
"@"
;
}
my
$new_uri
=
sprintf
(
$auth
,
join
(
','
,
map
{
sprintf
(
'%s:%s'
,
$_
->{target},
$_
->{port} ) }
@$hosts
),
(
$result
{db_name} //
""
),
scalar
(
keys
%$options
) ?
'?'
:
''
,
join
(
'&'
,
map
{
sprintf
(
'%s=%s'
,
$_
, __uri_escape(
$options
->{
$_
} ) ) }
keys
%$options
),
);
return
$new_uri
;
}
sub
BUILD {
my
(
$self
) =
@_
;
my
$uri
=
$self
->uri;
my
%result
;
if
(
$uri
=~ m{^mongodb\+srv://} ) {
$uri
=
$self
->_parse_srv_uri(
$uri
);
}
if
(
$uri
!~ m{^
$uri_re
$} ) {
MongoDB::Error->throw(
"URI '$self' could not be parsed"
);
}
(
$result
{username},
$result
{password},
$result
{hostids},
$result
{db_name},
$result
{options}
) = ( $1, $2, $3, $4, $5 );
if
(
defined
$result
{username} ) {
MongoDB::Error->throw(
"URI '$self' could not be parsed (username must be URL encoded)"
)
if
__userinfo_invalid_chars(
$result
{username});
$result
{username} = _unescape_all(
$result
{username} );
}
if
(
defined
$result
{password} ) {
MongoDB::Error->throw(
"URI '$self' could not be parsed (password must be URL encoded)"
)
if
__userinfo_invalid_chars(
$result
{password});
$result
{password} = _unescape_all(
$result
{password} );
}
if
( !
defined
$result
{hostids} || !
length
$result
{hostids} ) {
MongoDB::Error->throw(
"URI '$self' could not be parsed (missing host list)"
);
}
$result
{hostids} = [
map
{
lc
_unescape_all(
$_
) }
split
','
,
$result
{hostids} ];
for
my
$hostid
( @{
$result
{hostids} } ) {
MongoDB::Error->throw(
"URI '$self' could not be parsed (Unix domain sockets are not supported)"
)
if
$hostid
=~ /\// &&
$hostid
=~ /\.sock/;
MongoDB::Error->throw(
"URI '$self' could not be parsed (IP literals are not supported)"
)
if
substr
(
$hostid
, 0, 1 ) eq
'['
;
my
(
$host
,
$port
) =
split
":"
,
$hostid
, 2;
MongoDB::Error->throw(
"host list '@{ $result{hostids} }' contains empty host"
)
unless
length
$host
;
if
(
defined
$port
) {
MongoDB::Error->throw(
"URI '$self' could not be parsed (invalid port '$port')"
)
unless
$port
=~ /^\d+$/;
MongoDB::Error->throw(
"URI '$self' could not be parsed (invalid port '$port' (must be in range [1,65535])"
)
unless
$port
>= 1 &&
$port
<= 65535;
}
}
$result
{hostids} = [
map
{ /:/ ?
$_
:
$_
.
":27017"
} @{
$result
{hostids} } ];
if
(
defined
$result
{db_name} ) {
MongoDB::Error->throw(
"URI '$self' could not be parsed (database name must be URL encoded, found unescaped '/'"
)
if
$result
{db_name} =~ /\//;
$result
{db_name} = _unescape_all(
$result
{db_name} );
}
if
(
defined
$result
{options} ) {
$result
{options} =
$self
->_parse_options(
$self
->valid_options, \
%result
);
}
for
my
$attr
(
qw/username password db_name options hostids/
) {
my
$setter
=
"_set_$attr"
;
$self
->
$setter
(
$result
{
$attr
} )
if
defined
$result
{
$attr
};
}
return
;
}
sub
__str_to_bool {
my
(
$k
,
$str
) =
@_
;
MongoDB::UsageError->throw(
"cannot convert undef to bool for key '$k'"
)
unless
defined
$str
;
my
$ret
=
$str
eq
"true"
? 1 :
$str
eq
"false"
? 0 :
undef
;
return
$ret
if
defined
$ret
;
MongoDB::UsageError->throw(
"expected boolean string 'true' or 'false' for key '$k' but instead received '$str'"
);
}
my
%escapes
=
map
{
chr
(
$_
) =>
sprintf
(
"%%%02X"
,
$_
) } 0..255;
$escapes
{
' '
}=
"+"
;
my
$unsafe_char
=
qr/[^A-Za-z0-9\-\._~]/
;
sub
__uri_escape {
my
(
$str
) =
@_
;
if
( $] ge
'5.008'
) {
utf8::encode(
$str
);
}
else
{
$str
=
pack
(
"U*"
,
unpack
(
"C*"
,
$str
))
if
(
length
$str
==
do
{
use
bytes;
length
$str
} );
$str
=
pack
(
"C*"
,
unpack
(
"C*"
,
$str
));
}
$str
=~ s/(
$unsafe_char
)/
$escapes
{$1}/ge;
return
$str
;
}
my
$unreserved
=
q[a-z0-9._~-]
;
my
$subdelimit
=
q[!$&'()*+,;=]
;
my
$allowed
=
"%$subdelimit$unreserved"
;
my
$not_allowed_re
=
qr/[^$allowed]/
i;
my
$not_pct_enc_re
=
qr/%(?![0-9a-f]{2})/
i;
sub
__userinfo_invalid_chars {
my
(
$str
) =
@_
;
return
$str
=~
$not_pct_enc_re
||
$str
=~
$not_allowed_re
;
}
'""'
=>
sub
{
(
my
$s
=
$_
[0]->uri) =~ s{^(\w+)://[^/]+\@}{$1://[*
*REDACTED
**]\@};
return
$s
},
'fallback'
=> 1;
1;