|
4 | 4 |
|
5 | 5 | from base64 import decodebytes as base64_decode |
6 | 6 | from dataclasses import dataclass |
| 7 | +import re |
7 | 8 | import typing |
8 | 9 | import uuid |
| 10 | +import socket |
| 11 | + |
| 12 | +from tarantool.error import ConfigurationError |
9 | 13 |
|
10 | 14 | ENCODING_DEFAULT = "utf-8" |
11 | 15 |
|
| 16 | +DSN_DEFAULT_HOST = "127.0.0.1" |
| 17 | +""" |
| 18 | +Host to use if a DSN consists of a port only. |
| 19 | +""" |
| 20 | + |
| 21 | +DSN_UNIX_PREFIX = "unix/:" |
| 22 | +""" |
| 23 | +Prefix of a Unix socket address in a DSN. |
| 24 | +""" |
| 25 | + |
| 26 | +DSN_SCHEME_RE = re.compile(r'[A-Za-z][A-Za-z0-9+.\-]*://') |
| 27 | +""" |
| 28 | +Scheme of a DSN. Only a scheme at the very beginning of a DSN is |
| 29 | +recognized as such, the same way Tarantool does it. |
| 30 | +
|
| 31 | +:meta private: |
| 32 | +""" |
| 33 | + |
| 34 | +DSN_OPTIONS: typing.Tuple[str, ...] = ( |
| 35 | + 'transport', |
| 36 | + 'ssl_key_file', |
| 37 | + 'ssl_cert_file', |
| 38 | + 'ssl_ca_file', |
| 39 | + 'ssl_ciphers', |
| 40 | + 'ssl_password', |
| 41 | + 'ssl_password_file', |
| 42 | + 'auth_type', |
| 43 | +) |
| 44 | +""" |
| 45 | +Tarantool URI parameters allowed in a DSN query. |
| 46 | +""" |
| 47 | + |
12 | 48 |
|
13 | 49 | def strxor(rhs, lhs): |
14 | 50 | """ |
@@ -146,3 +182,245 @@ def greeting_decode(greeting_buf): |
146 | 182 | except ValueError as exc: |
147 | 183 | print('exx', exc) |
148 | 184 | raise ValueError("Invalid greeting: " + str(greeting_buf)) from exc |
| 185 | + |
| 186 | + |
| 187 | +def _dsn_error(dsn: str, msg: str) -> ConfigurationError: |
| 188 | + """ |
| 189 | + Build a DSN parse error. |
| 190 | +
|
| 191 | + :param dsn: Source DSN. |
| 192 | + :type dsn: :obj:`str` |
| 193 | +
|
| 194 | + :param msg: Error description. |
| 195 | + :type msg: :obj:`str` |
| 196 | +
|
| 197 | + :rtype: :exc:`~tarantool.error.ConfigurationError` |
| 198 | +
|
| 199 | + :meta private: |
| 200 | + """ |
| 201 | + |
| 202 | + return ConfigurationError(f'DSN "{dsn}": {msg}') |
| 203 | + |
| 204 | + |
| 205 | +def _is_dsn_port(port_str: str) -> bool: |
| 206 | + """ |
| 207 | + Check whether a DSN substring is a port. |
| 208 | +
|
| 209 | + :param port_str: Port substring. |
| 210 | + :type port_str: :obj:`str` |
| 211 | +
|
| 212 | + :rtype: :obj:`bool` |
| 213 | +
|
| 214 | + :meta private: |
| 215 | + """ |
| 216 | + |
| 217 | + return port_str.isascii() and port_str.isdigit() |
| 218 | + |
| 219 | + |
| 220 | +def _is_dsn_userinfo(userinfo: str) -> bool: |
| 221 | + """ |
| 222 | + Check whether a DSN substring before an ``@`` is a user name with |
| 223 | + a password rather than a part of a Unix socket path. Tarantool |
| 224 | + allows neither ``/`` nor more than one ``:`` in a user name and a |
| 225 | + password, so ``/tmp/tt@1.sock`` is a socket path, not a user name. |
| 226 | +
|
| 227 | + :param userinfo: Substring before the first ``@``. |
| 228 | + :type userinfo: :obj:`str` |
| 229 | +
|
| 230 | + :rtype: :obj:`bool` |
| 231 | +
|
| 232 | + :meta private: |
| 233 | + """ |
| 234 | + |
| 235 | + return '/' not in userinfo |
| 236 | + |
| 237 | + |
| 238 | +def _parse_dsn_port(dsn: str, port_str: str) -> int: |
| 239 | + """ |
| 240 | + Parse the port of a DSN. |
| 241 | +
|
| 242 | + :param dsn: Source DSN. |
| 243 | + :type dsn: :obj:`str` |
| 244 | +
|
| 245 | + :param port_str: Port substring. |
| 246 | + :type port_str: :obj:`str` |
| 247 | +
|
| 248 | + :rtype: :obj:`int` |
| 249 | +
|
| 250 | + :raise: :exc:`~tarantool.error.ConfigurationError` |
| 251 | +
|
| 252 | + :meta private: |
| 253 | + """ |
| 254 | + |
| 255 | + if not _is_dsn_port(port_str): |
| 256 | + raise _dsn_error(dsn, f'port "{port_str}" is not a number') |
| 257 | + port = int(port_str) |
| 258 | + if port < 1 or port > 65535: |
| 259 | + raise _dsn_error(dsn, 'port must be in range [1, 65535]') |
| 260 | + return port |
| 261 | + |
| 262 | + |
| 263 | +def _parse_dsn_address( |
| 264 | + dsn: str, |
| 265 | + address: str) -> typing.Tuple[typing.Optional[str], |
| 266 | + typing.Union[int, str]]: |
| 267 | + """ |
| 268 | + Parse the address of a DSN. For a Unix socket address, host is |
| 269 | + ``None`` and port is a socket path. |
| 270 | +
|
| 271 | + :param dsn: Source DSN. |
| 272 | + :type dsn: :obj:`str` |
| 273 | +
|
| 274 | + :param address: Address substring. |
| 275 | + :type address: :obj:`str` |
| 276 | +
|
| 277 | + :return: `(host, port)` pair. |
| 278 | + :rtype: :obj:`tuple` |
| 279 | +
|
| 280 | + :raise: :exc:`~tarantool.error.ConfigurationError` |
| 281 | +
|
| 282 | + :meta private: |
| 283 | + """ |
| 284 | + # pylint: disable=too-many-return-statements,too-many-branches |
| 285 | + |
| 286 | + if address.startswith(DSN_UNIX_PREFIX): |
| 287 | + path = address[len(DSN_UNIX_PREFIX):] |
| 288 | + if not path: |
| 289 | + raise _dsn_error(dsn, 'Unix socket path is empty') |
| 290 | + if not path.startswith(('/', './')): |
| 291 | + raise _dsn_error(dsn, f'Unix socket path "{path}" is neither ' |
| 292 | + 'absolute nor started with "./"') |
| 293 | + return None, path |
| 294 | + |
| 295 | + if address.startswith(('/', './')): |
| 296 | + return None, address |
| 297 | + |
| 298 | + if '/' in address: |
| 299 | + raise _dsn_error(dsn, f'address "{address}" is neither a host with ' |
| 300 | + 'a port nor a Unix socket path') |
| 301 | + |
| 302 | + if address.startswith('['): |
| 303 | + delim = address.find(']') |
| 304 | + if delim == -1: |
| 305 | + raise _dsn_error(dsn, 'IPv6 address is not closed with "]"') |
| 306 | + host, tail = address[1:delim], address[delim + 1:] |
| 307 | + try: |
| 308 | + socket.inet_pton(socket.AF_INET6, host) |
| 309 | + except (OSError, ValueError): |
| 310 | + raise _dsn_error(dsn, f'"{host}" is not an IPv6 address') from None |
| 311 | + if not tail.startswith(':'): |
| 312 | + raise _dsn_error(dsn, 'port is not specified') |
| 313 | + return host, _parse_dsn_port(dsn, tail[1:]) |
| 314 | + |
| 315 | + if ':' in address: |
| 316 | + host, port_str = address.rsplit(':', 1) |
| 317 | + if not host: |
| 318 | + raise _dsn_error(dsn, 'host value is empty') |
| 319 | + if ':' in host: |
| 320 | + raise _dsn_error(dsn, 'IPv6 address must be enclosed in "[]"') |
| 321 | + return host, _parse_dsn_port(dsn, port_str) |
| 322 | + |
| 323 | + if _is_dsn_port(address): |
| 324 | + return DSN_DEFAULT_HOST, _parse_dsn_port(dsn, address) |
| 325 | + |
| 326 | + raise _dsn_error(dsn, 'port is not specified') |
| 327 | + |
| 328 | + |
| 329 | +def _parse_dsn_options(dsn: str, query: str) -> typing.Dict[str, str]: |
| 330 | + """ |
| 331 | + Parse the query of a DSN into connection options. |
| 332 | +
|
| 333 | + :param dsn: Source DSN. |
| 334 | + :type dsn: :obj:`str` |
| 335 | +
|
| 336 | + :param query: Query substring. |
| 337 | + :type query: :obj:`str` |
| 338 | +
|
| 339 | + :rtype: :obj:`dict` |
| 340 | +
|
| 341 | + :raise: :exc:`~tarantool.error.ConfigurationError` |
| 342 | +
|
| 343 | + :meta private: |
| 344 | + """ |
| 345 | + |
| 346 | + options: typing.Dict[str, str] = {} |
| 347 | + for option_str in query.split('&'): |
| 348 | + if not option_str: |
| 349 | + continue |
| 350 | + name, delim, value = option_str.partition('=') |
| 351 | + if not delim: |
| 352 | + raise _dsn_error(dsn, f'option "{name}" has no value') |
| 353 | + if name not in DSN_OPTIONS: |
| 354 | + raise _dsn_error(dsn, f'unknown option "{name}"') |
| 355 | + options[name] = value |
| 356 | + return options |
| 357 | + |
| 358 | + |
| 359 | +def parse_dsn(dsn: str) -> typing.Dict[str, typing.Any]: |
| 360 | + """ |
| 361 | + Parse a Tarantool DSN string into :class:`~tarantool.Connection` |
| 362 | + parameters. |
| 363 | +
|
| 364 | + Expected format is |
| 365 | + ``[scheme://][user[:password]@]host:port[?option=value&...]``. |
| 366 | + A Unix socket address is either ``unix/:path`` or a path itself, |
| 367 | + absolute or started with ``./``; an IPv6 address must be enclosed |
| 368 | + in ``[]``. The scheme, if any, is ignored, as Tarantool itself |
| 369 | + ignores it, but it is recognized only at the very beginning of a |
| 370 | + DSN. Values are not percent-decoded, so neither ``@`` nor ``/`` |
| 371 | + is allowed in a user name or a password. Whitespace is not |
| 372 | + allowed anywhere. Allowed options are the Tarantool URI parameters |
| 373 | + listed in :data:`~tarantool.utils.DSN_OPTIONS`. |
| 374 | +
|
| 375 | + :param dsn: Tarantool server DSN. |
| 376 | + :type dsn: :obj:`str` |
| 377 | +
|
| 378 | + :return: Keyword arguments for :class:`~tarantool.Connection`. |
| 379 | + Only the parameters explicitly set in the DSN are present. |
| 380 | + :rtype: :obj:`dict` |
| 381 | +
|
| 382 | + :raise: :exc:`~tarantool.error.ConfigurationError` |
| 383 | + """ |
| 384 | + |
| 385 | + if not isinstance(dsn, str): |
| 386 | + raise ConfigurationError('DSN should be of a string type') |
| 387 | + |
| 388 | + source = dsn |
| 389 | + if not dsn: |
| 390 | + raise ConfigurationError('DSN should not be an empty string') |
| 391 | + |
| 392 | + if any(char.isspace() for char in dsn): |
| 393 | + raise _dsn_error(source, 'whitespace is not allowed') |
| 394 | + |
| 395 | + params: typing.Dict[str, typing.Any] = {} |
| 396 | + |
| 397 | + scheme = DSN_SCHEME_RE.match(dsn) |
| 398 | + if scheme: |
| 399 | + dsn = dsn[scheme.end():] |
| 400 | + |
| 401 | + query = '' |
| 402 | + if '?' in dsn: |
| 403 | + dsn, query = dsn.split('?', 1) |
| 404 | + |
| 405 | + userinfo, delim, tail = dsn.partition('@') |
| 406 | + if delim and _is_dsn_userinfo(userinfo): |
| 407 | + dsn = tail |
| 408 | + if '@' in dsn: |
| 409 | + raise _dsn_error(source, '"@" is not allowed in a user name ' |
| 410 | + 'or a password') |
| 411 | + user, delim, password = userinfo.partition(':') |
| 412 | + if not user: |
| 413 | + raise _dsn_error(source, 'user value is empty') |
| 414 | + if ':' in password: |
| 415 | + raise _dsn_error(source, '":" is not allowed in a password') |
| 416 | + params['user'] = user |
| 417 | + if delim: |
| 418 | + params['password'] = password |
| 419 | + |
| 420 | + if not dsn: |
| 421 | + raise _dsn_error(source, 'address is not specified') |
| 422 | + |
| 423 | + params['host'], params['port'] = _parse_dsn_address(source, dsn) |
| 424 | + params.update(_parse_dsn_options(source, query)) |
| 425 | + |
| 426 | + return params |
0 commit comments