jid.py 12.5 KB
Newer Older
Lance Stout's avatar
Lance Stout committed
1

2 3 4 5 6 7
# slixmpp.jid
# ~~~~~~~~~~~~~~~~~~~~~~~
# This module allows for working with Jabber IDs (JIDs).
# Part of Slixmpp: The Slick XMPP Library
# :copyright: (c) 2011 Nathanael C. Fritz
# :license: MIT, see LICENSE for more details
mathieui's avatar
mathieui committed
8 9
from __future__ import annotations

Lance Stout's avatar
Lance Stout committed
10 11 12
import re
import socket

13
from functools import lru_cache
mathieui's avatar
mathieui committed
14 15 16 17
from typing import (
    Optional,
    Union,
)
Lance Stout's avatar
Lance Stout committed
18

19
from slixmpp.stringprep import nodeprep, resourceprep, idna, StringprepError
Lance Stout's avatar
Lance Stout committed
20

21 22
HAVE_INET_PTON = hasattr(socket, 'inet_pton')

Lance Stout's avatar
Lance Stout committed
23 24 25
#: The basic regex pattern that a JID must match in order to determine
#: the local, domain, and resource parts. This regex does NOT do any
#: validation, which requires application of nodeprep, resourceprep, etc.
Lance Stout's avatar
Lance Stout committed
26 27 28
JID_PATTERN = re.compile(
    "^(?:([^\"&'/:<>@]{1,1023})@)?([^/@]{1,1023})(?:/(.{1,1023}))?$"
)
Lance Stout's avatar
Lance Stout committed
29

Lance Stout's avatar
Lance Stout committed
30
#: The set of escape sequences for the characters not allowed by nodeprep.
31 32
JID_ESCAPE_SEQUENCES = {'\\20', '\\22', '\\26', '\\27', '\\2f',
                        '\\3a', '\\3c', '\\3e', '\\40', '\\5c'}
Lance Stout's avatar
Lance Stout committed
33

Lance Stout's avatar
Lance Stout committed
34
#: The reverse mapping of escape sequences to their original forms.
Lance Stout's avatar
Lance Stout committed
35 36 37 38 39 40 41 42 43 44 45
JID_UNESCAPE_TRANSFORMATIONS = {'\\20': ' ',
                                '\\22': '"',
                                '\\26': '&',
                                '\\27': "'",
                                '\\2f': '/',
                                '\\3a': ':',
                                '\\3c': '<',
                                '\\3e': '>',
                                '\\40': '@',
                                '\\5c': '\\'}

Lance Stout's avatar
Lance Stout committed
46

47 48
# TODO: Find the best cache size for a standard usage.
@lru_cache(maxsize=1024)
mathieui's avatar
mathieui committed
49
def _parse_jid(data: str):
Lance Stout's avatar
Lance Stout committed
50 51
    """
    Parse string data into the node, domain, and resource
Lance Stout's avatar
Lance Stout committed
52 53 54 55 56 57 58
    components of a JID, if possible.

    :param string data: A string that is potentially a JID.

    :raises InvalidJID:

    :returns: tuple of the validated local, domain, and resource strings
Lance Stout's avatar
Lance Stout committed
59
    """
Lance Stout's avatar
Lance Stout committed
60
    match = JID_PATTERN.match(data)
Lance Stout's avatar
Lance Stout committed
61
    if not match:
Lance Stout's avatar
Lance Stout committed
62
        raise InvalidJID('JID could not be parsed')
Lance Stout's avatar
Lance Stout committed
63 64 65

    (node, domain, resource) = match.groups()

Lance Stout's avatar
Lance Stout committed
66 67 68
    node = _validate_node(node)
    domain = _validate_domain(domain)
    resource = _validate_resource(resource)
Lance Stout's avatar
Lance Stout committed
69 70 71 72

    return node, domain, resource


Maxime Buquet's avatar
Maxime Buquet committed
73
def _validate_node(node: Optional[str]):
Lance Stout's avatar
Lance Stout committed
74 75 76 77 78 79
    """Validate the local, or username, portion of a JID.

    :raises InvalidJID:

    :returns: The local portion of a JID, as validated by nodeprep.
    """
80
    if node is None:
81
        return ''
82

83 84 85 86 87 88 89 90 91 92
    try:
        node = nodeprep(node)
    except StringprepError:
        raise InvalidJID('Nodeprep failed')

    if not node:
        raise InvalidJID('Localpart must not be 0 bytes')
    if len(node) > 1023:
        raise InvalidJID('Localpart must be less than 1024 bytes')
    return node
Lance Stout's avatar
Lance Stout committed
93 94


Maxime Buquet's avatar
Maxime Buquet committed
95
def _validate_domain(domain: str):
Lance Stout's avatar
Lance Stout committed
96 97 98 99 100 101 102 103 104 105 106 107 108
    """Validate the domain portion of a JID.

    IP literal addresses are left as-is, if valid. Domain names
    are stripped of any trailing label separators (`.`), and are
    checked with the nameprep profile of stringprep. If the given
    domain is actually a punyencoded version of a domain name, it
    is converted back into its original Unicode form. Domains must
    also not start or end with a dash (`-`).

    :raises InvalidJID:

    :returns: The validated domain name
    """
Lance Stout's avatar
Lance Stout committed
109 110
    ip_addr = False

Lance Stout's avatar
Lance Stout committed
111
    # First, check if this is an IPv4 address
Lance Stout's avatar
Lance Stout committed
112 113 114 115 116 117
    try:
        socket.inet_aton(domain)
        ip_addr = True
    except socket.error:
        pass

Lance Stout's avatar
Lance Stout committed
118
    # Check if this is an IPv6 address
119
    if not ip_addr and HAVE_INET_PTON and domain[0] == '[' and domain[-1] == ']':
Lance Stout's avatar
Lance Stout committed
120
        try:
121 122
            ip = domain[1:-1]
            socket.inet_pton(socket.AF_INET6, ip)
Lance Stout's avatar
Lance Stout committed
123
            ip_addr = True
124
        except (socket.error, ValueError):
Lance Stout's avatar
Lance Stout committed
125 126 127
            pass

    if not ip_addr:
Lance Stout's avatar
Lance Stout committed
128 129
        # This is a domain name, which must be checked further

130 131 132
        if domain and domain[-1] == '.':
            domain = domain[:-1]

133 134 135 136
        try:
            domain = idna(domain)
        except StringprepError:
            raise InvalidJID('idna validation failed')
Lance Stout's avatar
Lance Stout committed
137

138 139 140 141 142
        if ':' in domain:
            raise InvalidJID('Domain containing a port')
        for label in domain.split('.'):
            if not label:
                raise InvalidJID('Domain containing too many dots')
Lance Stout's avatar
Lance Stout committed
143
            if '-' in (label[0], label[-1]):
Lance Stout's avatar
Lance Stout committed
144
                raise InvalidJID('Domain started or ended with -')
Lance Stout's avatar
Lance Stout committed
145

Lance Stout's avatar
Lance Stout committed
146
    if not domain:
147
        raise InvalidJID('Domain must not be 0 bytes')
148 149
    if len(domain) > 1023:
        raise InvalidJID('Domain must be less than 1024 bytes')
Lance Stout's avatar
Lance Stout committed
150

Lance Stout's avatar
Lance Stout committed
151 152
    return domain

Lance Stout's avatar
Lance Stout committed
153

Maxime Buquet's avatar
Maxime Buquet committed
154
def _validate_resource(resource: Optional[str]):
Lance Stout's avatar
Lance Stout committed
155 156 157 158 159 160
    """Validate the resource portion of a JID.

    :raises InvalidJID:

    :returns: The local portion of a JID, as validated by resourceprep.
    """
161
    if resource is None:
162
        return ''
Lance Stout's avatar
Lance Stout committed
163

164 165 166 167
    try:
        resource = resourceprep(resource)
    except StringprepError:
        raise InvalidJID('Resourceprep failed')
Lance Stout's avatar
Lance Stout committed
168

169 170 171 172 173
    if not resource:
        raise InvalidJID('Resource must not be 0 bytes')
    if len(resource) > 1023:
        raise InvalidJID('Resource must be less than 1024 bytes')
    return resource
Lance Stout's avatar
Lance Stout committed
174 175


Maxime Buquet's avatar
Maxime Buquet committed
176
def _unescape_node(node: str):
Lance Stout's avatar
Lance Stout committed
177 178 179 180 181 182
    """Unescape a local portion of a JID.

    .. note::
        The unescaped local portion is meant ONLY for presentation,
        and should not be used for other purposes.
    """
Lance Stout's avatar
Lance Stout committed
183 184 185 186 187 188 189 190 191 192 193 194 195 196 197
    unescaped = []
    seq = ''
    for i, char in enumerate(node):
        if char == '\\':
            seq = node[i:i+3]
            if seq not in JID_ESCAPE_SEQUENCES:
                seq = ''
        if seq:
            if len(seq) == 3:
                unescaped.append(JID_UNESCAPE_TRANSFORMATIONS.get(seq, char))

            # Pop character off the escape sequence, and ignore it
            seq = seq[1:]
        else:
            unescaped.append(char)
198
    return ''.join(unescaped)
Lance Stout's avatar
Lance Stout committed
199 200


Maxime Buquet's avatar
Maxime Buquet committed
201 202 203 204 205
def _format_jid(
        local: Optional[str] = None,
        domain: Optional[str] = None,
        resource: Optional[str] = None,
    ):
Lance Stout's avatar
Lance Stout committed
206 207 208 209 210 211 212 213
    """Format the given JID components into a full or bare JID.

    :param string local: Optional. The local portion of the JID.
    :param string domain: Required. The domain name portion of the JID.
    :param strin resource: Optional. The resource portion of the JID.

    :return: A full or bare JID string.
    """
Link Mauve's avatar
Link Mauve committed
214 215
    if domain is None:
        return ''
216
    if local is not None:
Link Mauve's avatar
Link Mauve committed
217 218 219
        result = local + '@' + domain
    else:
        result = domain
220
    if resource is not None:
Link Mauve's avatar
Link Mauve committed
221 222
        result += '/' + resource
    return result
Lance Stout's avatar
Lance Stout committed
223 224 225


class InvalidJID(ValueError):
Lance Stout's avatar
Lance Stout committed
226 227
    """
    Raised when attempting to create a JID that does not pass validation.
Lance Stout's avatar
Lance Stout committed
228

Lance Stout's avatar
Lance Stout committed
229 230 231 232
    It can also be raised if modifying an existing JID in such a way as
    to make it invalid, such trying to remove the domain from an existing
    full JID while the local and resource portions still exist.
    """
Lance Stout's avatar
Lance Stout committed
233

Lance Stout's avatar
Lance Stout committed
234
# pylint: disable=R0903
235
class UnescapedJID:
Lance Stout's avatar
Lance Stout committed
236

Lance Stout's avatar
Lance Stout committed
237 238 239 240
    """
    .. versionadded:: 1.1.10
    """

241
    __slots__ = ('_node', '_domain', '_resource')
Lance Stout's avatar
Lance Stout committed
242

Maxime Buquet's avatar
Maxime Buquet committed
243 244 245 246 247 248
    def __init__(
            self,
            node: Optional[str],
            domain: Optional[str],
            resource: Optional[str],
        ):
249 250 251 252
        self._node = node
        self._domain = domain
        self._resource = resource

Maxime Buquet's avatar
Maxime Buquet committed
253
    def __getattribute__(self, name: str):
Lance Stout's avatar
Lance Stout committed
254 255
        """Retrieve the given JID component.

Lance Stout's avatar
Lance Stout committed
256 257 258 259
        :param name: one of: user, server, domain, resource,
                     full, or bare.
        """
        if name == 'resource':
260
            return self._resource or ''
261
        if name in ('user', 'username', 'local', 'node'):
262
            return self._node or ''
263
        if name in ('server', 'domain', 'host'):
264
            return self._domain or ''
265 266 267 268 269
        if name in ('full', 'jid'):
            return _format_jid(self._node, self._domain, self._resource)
        if name == 'bare':
            return _format_jid(self._node, self._domain)
        return object.__getattribute__(self, name)
Lance Stout's avatar
Lance Stout committed
270 271 272

    def __str__(self):
        """Use the full JID as the string value."""
273
        return _format_jid(self._node, self._domain, self._resource)
Lance Stout's avatar
Lance Stout committed
274 275

    def __repr__(self):
Lance Stout's avatar
Lance Stout committed
276
        """Use the full JID as the representation."""
277
        return _format_jid(self._node, self._domain, self._resource)
Lance Stout's avatar
Lance Stout committed
278 279


280
class JID:
Lance Stout's avatar
Lance Stout committed
281 282 283 284 285 286 287 288 289 290 291 292

    """
    A representation of a Jabber ID, or JID.

    Each JID may have three components: a user, a domain, and an optional
    resource. For example: user@domain/resource

    When a resource is not used, the JID is called a bare JID.
    The JID is a full JID otherwise.

    **JID Properties:**
        :full: The string value of the full JID.
293
        :jid: Alias for ``full``.
Lance Stout's avatar
Lance Stout committed
294
        :bare: The string value of the bare JID.
295 296 297 298
        :node: The node portion of the JID.
        :user: Alias for ``node``.
        :local: Alias for ``node``.
        :username: Alias for ``node``.
Lance Stout's avatar
Lance Stout committed
299 300 301 302 303
        :domain: The domain name portion of the JID.
        :server: Alias for ``domain``.
        :host: Alias for ``domain``.
        :resource: The resource portion of the JID.

Lance Stout's avatar
Lance Stout committed
304 305 306 307
    :param string jid:
        A string of the form ``'[user@]domain[/resource]'``.

    :raises InvalidJID:
Lance Stout's avatar
Lance Stout committed
308 309
    """

310
    __slots__ = ('_node', '_domain', '_resource', '_bare', '_full')
Joe Hildebrand's avatar
Joe Hildebrand committed
311

mathieui's avatar
mathieui committed
312
    def __init__(self, jid: Optional[Union[str, 'JID']] = None):
313
        if not jid:
314 315 316 317 318 319
            self._node = ''
            self._domain = ''
            self._resource = ''
            self._bare = ''
            self._full = ''
            return
320
        elif not isinstance(jid, JID):
321
            self._node, self._domain, self._resource = _parse_jid(jid)
322
        else:
323 324 325
            self._node = jid._node
            self._domain = jid._domain
            self._resource = jid._resource
326
        self._update_bare_full()
Lance Stout's avatar
Lance Stout committed
327 328

    def unescape(self):
Lance Stout's avatar
Lance Stout committed
329 330 331 332 333 334 335 336 337 338
        """Return an unescaped JID object.

        Using an unescaped JID is preferred for displaying JIDs
        to humans, and they should NOT be used for any other
        purposes than for presentation.

        :return: :class:`UnescapedJID`

        .. versionadded:: 1.1.10
        """
339 340 341
        return UnescapedJID(_unescape_node(self._node),
                            self._domain,
                            self._resource)
Lance Stout's avatar
Lance Stout committed
342

343 344 345 346 347 348 349 350 351 352
    def _update_bare_full(self):
        """Format the given JID into a bare and a full JID.
        """
        self._bare = (self._node + '@' + self._domain
                      if self._node
                      else self._domain)
        self._full = (self._bare + '/' + self._resource
                      if self._resource
                      else self._bare)

353
    @property
mathieui's avatar
mathieui committed
354
    def bare(self) -> str:
355
        return self._bare
356 357

    @property
mathieui's avatar
mathieui committed
358 359
    def node(self) -> str:
        return self._node
360

361
    @node.setter
Maxime Buquet's avatar
Maxime Buquet committed
362
    def node(self, value: str):
363
        self._node = _validate_node(value)
364
        self._update_bare_full()
365

mathieui's avatar
mathieui committed
366 367 368 369
    @property
    def domain(self) -> str:
        return self._domain

370
    @domain.setter
Maxime Buquet's avatar
Maxime Buquet committed
371
    def domain(self, value: str):
372
        self._domain = _validate_domain(value)
373
        self._update_bare_full()
374 375

    @bare.setter
Maxime Buquet's avatar
Maxime Buquet committed
376
    def bare(self, value: str):
377 378 379 380
        node, domain, resource = _parse_jid(value)
        assert not resource
        self._node = node
        self._domain = domain
381
        self._update_bare_full()
382

mathieui's avatar
mathieui committed
383 384 385 386
    @property
    def resource(self) -> str:
        return self._resource

387
    @resource.setter
Maxime Buquet's avatar
Maxime Buquet committed
388
    def resource(self, value: str):
389
        self._resource = _validate_resource(value)
390
        self._update_bare_full()
391

mathieui's avatar
mathieui committed
392 393 394 395
    @property
    def full(self) -> str:
        return self._full

396
    @full.setter
Maxime Buquet's avatar
Maxime Buquet committed
397
    def full(self, value: str):
398
        self._node, self._domain, self._resource = _parse_jid(value)
399
        self._update_bare_full()
400

401 402 403 404 405 406 407 408
    user = node
    local = node
    username = node

    server = domain
    host = domain

    jid = full
Lance Stout's avatar
Lance Stout committed
409 410 411

    def __str__(self):
        """Use the full JID as the string value."""
412
        return self._full
Lance Stout's avatar
Lance Stout committed
413 414

    def __repr__(self):
Lance Stout's avatar
Lance Stout committed
415
        """Use the full JID as the representation."""
416
        return self._full
Lance Stout's avatar
Lance Stout committed
417

Lance Stout's avatar
Lance Stout committed
418
    # pylint: disable=W0212
Lance Stout's avatar
Lance Stout committed
419
    def __eq__(self, other):
Lance Stout's avatar
Lance Stout committed
420
        """Two JIDs are equal if they have the same full JID value."""
Lance Stout's avatar
Lance Stout committed
421 422
        if isinstance(other, UnescapedJID):
            return False
423
        if not isinstance(other, JID):
424 425
            try:
                other = JID(other)
426
            except InvalidJID:
427
                return NotImplemented
Lance Stout's avatar
Lance Stout committed
428

429 430 431
        return (self._node == other._node and
                self._domain == other._domain and
                self._resource == other._resource)
Lance Stout's avatar
Lance Stout committed
432 433 434

    def __ne__(self, other):
        """Two JIDs are considered unequal if they are not equal."""
Lance Stout's avatar
Lance Stout committed
435
        return not self == other
Lance Stout's avatar
Lance Stout committed
436 437 438

    def __hash__(self):
        """Hash a JID based on the string version of its full JID."""
439
        return hash(self._full)