1"""
2ldap.syncrepl - for implementing syncrepl consumer (see RFC 4533)
3
4See https://www.python-ldap.org/ for project details.
5"""
6
7from uuid import UUID
8
9# Imports from pyasn1
10from pyasn1.type import tag, namedtype, namedval, univ, constraint
11from pyasn1.codec.ber import encoder, decoder
12
13from ldap.pkginfo import __version__, __author__, __license__
14from ldap.controls import RequestControl, ResponseControl, KNOWN_RESPONSE_CONTROLS
15
16__all__ = [
17    'SyncreplConsumer',
18]
19
20
21class SyncUUID(univ.OctetString):
22    """
23    syncUUID ::= OCTET STRING (SIZE(16))
24    """
25    subtypeSpec = constraint.ValueSizeConstraint(16, 16)
26
27
28class SyncCookie(univ.OctetString):
29    """
30    syncCookie ::= OCTET STRING
31    """
32
33
34class SyncRequestMode(univ.Enumerated):
35    """
36           mode ENUMERATED {
37               -- 0 unused
38               refreshOnly       (1),
39               -- 2 reserved
40               refreshAndPersist (3)
41           },
42    """
43    namedValues = namedval.NamedValues(
44        ('refreshOnly', 1),
45        ('refreshAndPersist', 3)
46    )
47    subtypeSpec = univ.Enumerated.subtypeSpec + constraint.SingleValueConstraint(1, 3)
48
49
50class SyncRequestValue(univ.Sequence):
51    """
52       syncRequestValue ::= SEQUENCE {
53           mode ENUMERATED {
54               -- 0 unused
55               refreshOnly       (1),
56               -- 2 reserved
57               refreshAndPersist (3)
58           },
59           cookie     syncCookie OPTIONAL,
60           reloadHint BOOLEAN DEFAULT FALSE
61       }
62    """
63    componentType = namedtype.NamedTypes(
64        namedtype.NamedType('mode', SyncRequestMode()),
65        namedtype.OptionalNamedType('cookie', SyncCookie()),
66        namedtype.DefaultedNamedType('reloadHint', univ.Boolean(False))
67    )
68
69
70class SyncRequestControl(RequestControl):
71    """
72    The Sync Request Control is an LDAP Control [RFC4511] where the
73    controlType is the object identifier 1.3.6.1.4.1.4203.1.9.1.1 and the
74    controlValue, an OCTET STRING, contains a BER-encoded
75    syncRequestValue.  The criticality field is either TRUE or FALSE.
76    [..]
77    The Sync Request Control is only applicable to the SearchRequest
78    Message.
79    """
80    controlType = '1.3.6.1.4.1.4203.1.9.1.1'
81
82    def __init__(self, criticality=1, cookie=None, mode='refreshOnly', reloadHint=False):
83        self.criticality = criticality
84        self.cookie = cookie
85        self.mode = mode
86        self.reloadHint = reloadHint
87
88    def encodeControlValue(self):
89        rcv = SyncRequestValue()
90        rcv.setComponentByName('mode', SyncRequestMode(self.mode))
91        if self.cookie is not None:
92            rcv.setComponentByName('cookie', SyncCookie(self.cookie))
93        if self.reloadHint:
94            rcv.setComponentByName('reloadHint', univ.Boolean(self.reloadHint))
95        return encoder.encode(rcv)
96
97
98class SyncStateOp(univ.Enumerated):
99    """
100           state ENUMERATED {
101               present (0),
102               add (1),
103               modify (2),
104               delete (3)
105           },
106    """
107    namedValues = namedval.NamedValues(
108        ('present', 0),
109        ('add', 1),
110        ('modify', 2),
111        ('delete', 3)
112    )
113    subtypeSpec = univ.Enumerated.subtypeSpec + constraint.SingleValueConstraint(0, 1, 2, 3)
114
115
116class SyncStateValue(univ.Sequence):
117    """
118       syncStateValue ::= SEQUENCE {
119           state ENUMERATED {
120               present (0),
121               add (1),
122               modify (2),
123               delete (3)
124           },
125           entryUUID syncUUID,
126           cookie    syncCookie OPTIONAL
127       }
128    """
129    componentType = namedtype.NamedTypes(
130        namedtype.NamedType('state', SyncStateOp()),
131        namedtype.NamedType('entryUUID', SyncUUID()),
132        namedtype.OptionalNamedType('cookie', SyncCookie())
133    )
134
135
136class SyncStateControl(ResponseControl):
137    """
138    The Sync State Control is an LDAP Control [RFC4511] where the
139    controlType is the object identifier 1.3.6.1.4.1.4203.1.9.1.2 and the
140    controlValue, an OCTET STRING, contains a BER-encoded SyncStateValue.
141    The criticality is FALSE.
142    [..]
143    The Sync State Control is only applicable to SearchResultEntry and
144    SearchResultReference Messages.
145    """
146    controlType = '1.3.6.1.4.1.4203.1.9.1.2'
147    opnames = ('present', 'add', 'modify', 'delete')
148
149    def decodeControlValue(self, encodedControlValue):
150        d = decoder.decode(encodedControlValue, asn1Spec=SyncStateValue())
151        state = d[0].getComponentByName('state')
152        uuid = UUID(bytes=bytes(d[0].getComponentByName('entryUUID')))
153        cookie = d[0].getComponentByName('cookie')
154        if cookie is not None and cookie.hasValue():
155            self.cookie = str(cookie)
156        else:
157            self.cookie = None
158        self.state = self.__class__.opnames[int(state)]
159        self.entryUUID = str(uuid)
160
161KNOWN_RESPONSE_CONTROLS[SyncStateControl.controlType] = SyncStateControl
162
163
164class SyncDoneValue(univ.Sequence):
165    """
166       syncDoneValue ::= SEQUENCE {
167           cookie          syncCookie OPTIONAL,
168           refreshDeletes  BOOLEAN DEFAULT FALSE
169       }
170    """
171    componentType = namedtype.NamedTypes(
172        namedtype.OptionalNamedType('cookie', SyncCookie()),
173        namedtype.DefaultedNamedType('refreshDeletes', univ.Boolean(False))
174    )
175
176
177class SyncDoneControl(ResponseControl):
178    """
179    The Sync Done Control is an LDAP Control [RFC4511] where the
180    controlType is the object identifier 1.3.6.1.4.1.4203.1.9.1.3 and the
181    controlValue contains a BER-encoded syncDoneValue.  The criticality
182    is FALSE (and hence absent).
183    [..]
184    The Sync Done Control is only applicable to the SearchResultDone
185    Message.
186    """
187    controlType = '1.3.6.1.4.1.4203.1.9.1.3'
188
189    def decodeControlValue(self, encodedControlValue):
190        d = decoder.decode(encodedControlValue, asn1Spec=SyncDoneValue())
191        cookie = d[0].getComponentByName('cookie')
192        if cookie.hasValue():
193            self.cookie = str(cookie)
194        else:
195            self.cookie = None
196        refresh_deletes = d[0].getComponentByName('refreshDeletes')
197        if refresh_deletes.hasValue():
198            self.refreshDeletes = bool(refresh_deletes)
199        else:
200            self.refreshDeletes = None
201
202KNOWN_RESPONSE_CONTROLS[SyncDoneControl.controlType] = SyncDoneControl
203
204
205class RefreshDelete(univ.Sequence):
206    """
207           refreshDelete  [1] SEQUENCE {
208               cookie         syncCookie OPTIONAL,
209               refreshDone    BOOLEAN DEFAULT TRUE
210           },
211    """
212    componentType = namedtype.NamedTypes(
213        namedtype.OptionalNamedType('cookie', SyncCookie()),
214        namedtype.DefaultedNamedType('refreshDone', univ.Boolean(True))
215    )
216
217
218class RefreshPresent(univ.Sequence):
219    """
220           refreshPresent [2] SEQUENCE {
221               cookie         syncCookie OPTIONAL,
222               refreshDone    BOOLEAN DEFAULT TRUE
223           },
224    """
225    componentType = namedtype.NamedTypes(
226        namedtype.OptionalNamedType('cookie', SyncCookie()),
227        namedtype.DefaultedNamedType('refreshDone', univ.Boolean(True))
228    )
229
230
231class SyncUUIDs(univ.SetOf):
232    """
233    syncUUIDs      SET OF syncUUID
234    """
235    componentType = SyncUUID()
236
237
238class SyncIdSet(univ.Sequence):
239    """
240     syncIdSet      [3] SEQUENCE {
241         cookie         syncCookie OPTIONAL,
242         refreshDeletes BOOLEAN DEFAULT FALSE,
243         syncUUIDs      SET OF syncUUID
244     }
245    """
246    componentType = namedtype.NamedTypes(
247        namedtype.OptionalNamedType('cookie', SyncCookie()),
248        namedtype.DefaultedNamedType('refreshDeletes', univ.Boolean(False)),
249        namedtype.NamedType('syncUUIDs', SyncUUIDs())
250    )
251
252
253class SyncInfoValue(univ.Choice):
254    """
255       syncInfoValue ::= CHOICE {
256           newcookie      [0] syncCookie,
257           refreshDelete  [1] SEQUENCE {
258               cookie         syncCookie OPTIONAL,
259               refreshDone    BOOLEAN DEFAULT TRUE
260           },
261           refreshPresent [2] SEQUENCE {
262               cookie         syncCookie OPTIONAL,
263               refreshDone    BOOLEAN DEFAULT TRUE
264           },
265           syncIdSet      [3] SEQUENCE {
266               cookie         syncCookie OPTIONAL,
267               refreshDeletes BOOLEAN DEFAULT FALSE,
268               syncUUIDs      SET OF syncUUID
269           }
270       }
271    """
272    componentType = namedtype.NamedTypes(
273        namedtype.NamedType(
274            'newcookie',
275            SyncCookie().subtype(
276                implicitTag=tag.Tag(tag.tagClassContext, tag.tagFormatSimple, 0)
277            )
278        ),
279        namedtype.NamedType(
280            'refreshDelete',
281            RefreshDelete().subtype(
282                implicitTag=tag.Tag(tag.tagClassContext, tag.tagFormatSimple, 1)
283            )
284        ),
285        namedtype.NamedType(
286            'refreshPresent',
287            RefreshPresent().subtype(
288                implicitTag=tag.Tag(tag.tagClassContext, tag.tagFormatSimple, 2)
289            )
290        ),
291        namedtype.NamedType(
292            'syncIdSet',
293            SyncIdSet().subtype(
294                implicitTag=tag.Tag(tag.tagClassContext, tag.tagFormatSimple, 3)
295            )
296        )
297    )
298
299
300class SyncInfoMessage:
301    """
302    The Sync Info Message is an LDAP Intermediate Response Message
303    [RFC4511] where responseName is the object identifier
304    1.3.6.1.4.1.4203.1.9.1.4 and responseValue contains a BER-encoded
305    syncInfoValue.  The criticality is FALSE (and hence absent).
306    """
307    responseName = '1.3.6.1.4.1.4203.1.9.1.4'
308
309    def __init__(self, encodedMessage):
310        d = decoder.decode(encodedMessage, asn1Spec=SyncInfoValue())
311        self.newcookie = None
312        self.refreshDelete = None
313        self.refreshPresent = None
314        self.syncIdSet = None
315
316        # Due to the way pyasn1 works, refreshDelete and refreshPresent are both
317        # valid in the components as they are fully populated defaults. We must
318        # get the component directly from the message, not by iteration.
319        attr = d[0].getName()
320        comp = d[0].getComponent()
321
322        if comp is not None and comp.hasValue():
323            if attr == 'newcookie':
324                self.newcookie = str(comp)
325                return
326
327            val = {}
328
329            cookie = comp.getComponentByName('cookie')
330            if cookie.hasValue():
331                val['cookie'] = str(cookie)
332
333            if attr.startswith('refresh'):
334                val['refreshDone'] = bool(comp.getComponentByName('refreshDone'))
335            elif attr == 'syncIdSet':
336                uuids = []
337                ids = comp.getComponentByName('syncUUIDs')
338                for i in range(len(ids)):
339                    uuid = UUID(bytes=bytes(ids.getComponentByPosition(i)))
340                    uuids.append(str(uuid))
341                val['syncUUIDs'] = uuids
342                val['refreshDeletes'] = bool(comp.getComponentByName('refreshDeletes'))
343
344            setattr(self, attr, val)
345
346
347class SyncreplConsumer:
348    """
349    SyncreplConsumer - LDAP syncrepl consumer object.
350    """
351
352    def syncrepl_search(self, base, scope, mode='refreshOnly', cookie=None, **search_args):
353        """
354        Starts syncrepl search operation.
355
356        base, scope, and search_args are passed along to
357        self.search_ext unmodified (aside from adding a Sync
358        Request control to any serverctrls provided).
359
360        mode provides syncrepl mode. Can be 'refreshOnly'
361        to finish after synchronization, or
362        'refreshAndPersist' to persist (continue to
363        receive updates) after synchronization.
364
365        cookie: an opaque value representing the replication
366        state of the client.  Subclasses should override
367        the syncrepl_set_cookie() and syncrepl_get_cookie()
368        methods to store the cookie appropriately, rather than
369        passing it.
370
371        Only a single syncrepl search may be active on a SyncreplConsumer
372        object.  Multiple concurrent syncrepl searches require multiple
373        separate SyncreplConsumer objects and thus multiple connections
374        (LDAPObject instances).
375        """
376        if cookie is None:
377            cookie = self.syncrepl_get_cookie()
378
379        syncreq = SyncRequestControl(cookie=cookie, mode=mode)
380
381        if 'serverctrls' in search_args:
382            search_args['serverctrls'] += [syncreq]
383        else:
384            search_args['serverctrls'] = [syncreq]
385
386        self.__refreshDone = False
387        return self.search_ext(base, scope, **search_args)
388
389    def syncrepl_poll(self, msgid=-1, timeout=None, all=0):
390        """
391        polls for and processes responses to the syncrepl_search() operation.
392        Returns False when operation finishes, True if it is in progress, or
393        raises an exception on error.
394
395        If timeout is specified, raises ldap.TIMEOUT in the event of a timeout.
396
397        If all is set to a nonzero value, poll() will return only when finished
398        or when an exception is raised.
399
400        """
401        while True:
402            type, msg, mid, ctrls, n, v = self.result4(
403                msgid=msgid,
404                timeout=timeout,
405                add_intermediates=1,
406                add_ctrls=1,
407                all=0,
408            )
409
410            if type == 101:
411                # search result. This marks the end of a refreshOnly session.
412                # look for a SyncDone control, save the cookie, and if necessary
413                # delete non-present entries.
414                for c in ctrls:
415                    if c.__class__.__name__ != 'SyncDoneControl':
416                        continue
417                    self.syncrepl_present(None, refreshDeletes=c.refreshDeletes)
418                    if c.cookie is not None:
419                        self.syncrepl_set_cookie(c.cookie)
420
421                return False
422
423            elif type == 100:
424                # search entry with associated SyncState control
425                for m in msg:
426                    dn, attrs, ctrls = m
427                    for c in ctrls:
428                        if c.__class__.__name__ != 'SyncStateControl':
429                            continue
430                        if c.state == 'present':
431                            self.syncrepl_present([c.entryUUID])
432                        elif c.state == 'delete':
433                            self.syncrepl_delete([c.entryUUID])
434                        else:
435                            self.syncrepl_entry(dn, attrs, c.entryUUID)
436                            if self.__refreshDone is False:
437                                self.syncrepl_present([c.entryUUID])
438                        if c.cookie is not None:
439                            self.syncrepl_set_cookie(c.cookie)
440                        break
441
442            elif type == 121:
443                # Intermediate message. If it is a SyncInfoMessage, parse it
444                for m in msg:
445                    rname, resp, ctrls = m
446                    if rname != SyncInfoMessage.responseName:
447                        continue
448                    sim = SyncInfoMessage(resp)
449                    if sim.newcookie is not None:
450                        self.syncrepl_set_cookie(sim.newcookie)
451                    elif sim.refreshPresent is not None:
452                        self.syncrepl_present(None, refreshDeletes=False)
453                        if 'cookie' in sim.refreshPresent:
454                            self.syncrepl_set_cookie(sim.refreshPresent['cookie'])
455                        if sim.refreshPresent['refreshDone']:
456                            self.__refreshDone = True
457                            self.syncrepl_refreshdone()
458                    elif sim.refreshDelete is not None:
459                        self.syncrepl_present(None, refreshDeletes=True)
460                        if 'cookie' in sim.refreshDelete:
461                            self.syncrepl_set_cookie(sim.refreshDelete['cookie'])
462                        if sim.refreshDelete['refreshDone']:
463                            self.__refreshDone = True
464                            self.syncrepl_refreshdone()
465                    elif sim.syncIdSet is not None:
466                        if sim.syncIdSet['refreshDeletes'] is True:
467                            self.syncrepl_delete(sim.syncIdSet['syncUUIDs'])
468                        else:
469                            self.syncrepl_present(sim.syncIdSet['syncUUIDs'])
470                        if 'cookie' in sim.syncIdSet:
471                            self.syncrepl_set_cookie(sim.syncIdSet['cookie'])
472
473            if all == 0:
474                return True
475
476
477    # virtual methods -- subclass must override these to do useful work
478
479    def syncrepl_set_cookie(self, cookie):
480        """
481        Called by syncrepl_poll() to store a new cookie provided by the server.
482        """
483        pass
484
485    def syncrepl_get_cookie(self):
486        """
487        Called by syncrepl_search() to retrieve the cookie stored by syncrepl_set_cookie()
488        """
489        pass
490
491    def syncrepl_present(self, uuids, refreshDeletes=False):
492        """
493        Called by syncrepl_poll() whenever entry UUIDs are presented to the client.
494        syncrepl_present() is given a list of entry UUIDs (uuids) and a flag
495        (refreshDeletes) which indicates whether the server explicitly deleted
496        non-present entries during the refresh operation.
497
498        If called with a list of uuids, the syncrepl_present() implementation
499        should record those uuids as present in the directory.
500
501        If called with uuids set to None and refreshDeletes set to False,
502        syncrepl_present() should delete all non-present entries from the local
503        mirror, and reset the list of recorded uuids.
504
505        If called with uuids set to None and refreshDeletes set to True,
506        syncrepl_present() should reset the list of recorded uuids, without
507        deleting any entries.
508        """
509        pass
510
511    def syncrepl_delete(self, uuids):
512        """
513        Called by syncrepl_poll() to delete entries. A list
514        of UUIDs of the entries to be deleted is given in the
515        uuids parameter.
516        """
517        pass
518
519    def syncrepl_entry(self, dn, attrs, uuid):
520        """
521        Called by syncrepl_poll() for any added or modified entries.
522
523        The provided uuid is used to identify the provided entry in
524        any future modification (including dn modification), deletion,
525        and presentation operations.
526        """
527        pass
528
529    def syncrepl_refreshdone(self):
530        """
531        Called by syncrepl_poll() between refresh and persist phase.
532
533        It indicates that initial synchronization is done and persist phase
534        follows.
535        """
536        pass
537