Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/dns/transaction.py: 27%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# Copyright (C) Dnspython Contributors, see LICENSE for text of ISC license
3import collections
4from collections.abc import Callable, Iterator
5from typing import Any
7import dns.exception
8import dns.name
9import dns.node
10import dns.rdata
11import dns.rdataclass
12import dns.rdataset
13import dns.rdatatype
14import dns.rrset
15import dns.serial
16import dns.ttl
19class TransactionManager:
20 def reader(self) -> "Transaction":
21 """Begin a read-only transaction."""
22 raise NotImplementedError # pragma: no cover
24 def writer(self, replacement: bool = False) -> "Transaction":
25 """Begin a writable transaction.
27 :param bool replacement: If ``True``, the content of the transaction
28 completely replaces any prior content. If ``False`` (the default),
29 the content of the transaction updates the existing content.
30 """
31 raise NotImplementedError # pragma: no cover
33 def origin_information(
34 self,
35 ) -> tuple[dns.name.Name | None, bool, dns.name.Name | None]:
36 """Return origin information for the transaction manager.
38 This information is used by code working with transactions to
39 allow it to coordinate relativization. The transaction code
40 itself takes what it gets (i.e. does not change name
41 relativity).
43 :returns: A tuple ``(absolute_origin, relativize, effective_origin)``
44 giving the absolute name of the default origin for any relative
45 domain names, whether names should be relativized, and the
46 "effective origin". The effective origin is the absolute origin if
47 *relativize* is ``False``, and the empty name if *relativize* is
48 ``True``. (The effective origin is provided even though it can be
49 computed from the absolute origin and relativize setting because it
50 avoids a lot of code duplication.) If the returned names are
51 ``None``, then no origin information is available.
52 :rtype: tuple[:py:class:`dns.name.Name` or ``None``, bool,
53 :py:class:`dns.name.Name` or ``None``]
54 """
55 raise NotImplementedError # pragma: no cover
57 def get_class(self) -> dns.rdataclass.RdataClass:
58 """The class of the transaction manager."""
59 raise NotImplementedError # pragma: no cover
61 def from_wire_origin(self) -> dns.name.Name | None:
62 """Origin to use in from_wire() calls."""
63 absolute_origin, relativize, _ = self.origin_information()
64 if relativize:
65 return absolute_origin
66 else:
67 return None
70class DeleteNotExact(dns.exception.DNSException):
71 """Existing data did not match data specified by an exact delete."""
74class ReadOnly(dns.exception.DNSException):
75 """Tried to write to a read-only transaction."""
78class AlreadyEnded(dns.exception.DNSException):
79 """Tried to use an already-ended transaction."""
82def _ensure_immutable_rdataset(rdataset) -> dns.rdataset.ImmutableRdataset | None:
83 if rdataset is None or isinstance(rdataset, dns.rdataset.ImmutableRdataset):
84 return rdataset
85 return dns.rdataset.ImmutableRdataset(rdataset)
88def _ensure_immutable_node(node) -> dns.node.ImmutableNode | None:
89 if node is None or node.is_immutable():
90 return node
91 return dns.node.ImmutableNode(node)
94CheckPutRdatasetType = Callable[
95 ["Transaction", dns.name.Name, dns.rdataset.Rdataset], None
96]
97CheckDeleteRdatasetType = Callable[
98 ["Transaction", dns.name.Name, dns.rdatatype.RdataType, dns.rdatatype.RdataType],
99 None,
100]
101CheckDeleteNameType = Callable[["Transaction", dns.name.Name], None]
104class Transaction:
105 def __init__(
106 self,
107 manager: TransactionManager,
108 replacement: bool = False,
109 read_only: bool = False,
110 ):
111 self.manager = manager
112 self.replacement = replacement
113 self.read_only = read_only
114 self.unicode: set[str] = set()
115 self._ended = False
116 self._check_put_rdataset: list[CheckPutRdatasetType] = []
117 self._check_delete_rdataset: list[CheckDeleteRdatasetType] = []
118 self._check_delete_name: list[CheckDeleteNameType] = []
120 #
121 # This is the high level API
122 #
123 # Note that we currently use non-immutable types in the return type signature to
124 # avoid covariance problems, e.g. if the caller has a list[Rdataset], mypy will be
125 # unhappy if we return an ImmutableRdataset.
127 def get(
128 self,
129 name: dns.name.Name | str | None,
130 rdtype: dns.rdatatype.RdataType | str,
131 covers: dns.rdatatype.RdataType | str = dns.rdatatype.NONE,
132 ) -> dns.rdataset.Rdataset | None:
133 """Return the rdataset associated with *name*, *rdtype*, and *covers*,
134 or ``None`` if not found.
136 Note that the returned rdataset is immutable.
137 """
138 self._check_ended()
139 if isinstance(name, str):
140 name = dns.name.from_text(name, None)
141 rdtype = dns.rdatatype.RdataType.make(rdtype)
142 covers = dns.rdatatype.RdataType.make(covers)
143 rdataset = self._get_rdataset(name, rdtype, covers)
144 return _ensure_immutable_rdataset(rdataset)
146 def get_node(self, name: dns.name.Name) -> dns.node.Node | None:
147 """Return the node at *name*, if any.
149 :returns: An immutable node, or ``None`` if *name* does not exist.
150 :rtype: :py:class:`dns.node.ImmutableNode` or ``None``
151 """
152 return _ensure_immutable_node(self._get_node(name))
154 def _check_read_only(self) -> None:
155 if self.read_only:
156 raise ReadOnly
158 def add(self, *args: Any) -> None:
159 """Add records.
161 The arguments may be:
163 - rrset
165 - name, rdataset...
167 - name, ttl, rdata...
168 """
169 self._check_ended()
170 self._check_read_only()
171 self._add(False, args)
173 def replace(self, *args: Any) -> None:
174 """Replace the existing rdataset at the name with the specified
175 rdataset, or add the specified rdataset if there was no existing
176 rdataset.
178 The arguments may be:
180 - rrset
182 - name, rdataset...
184 - name, ttl, rdata...
186 Note that if you want to replace the entire node, you should do
187 a delete of the name followed by one or more calls to add() or
188 replace().
189 """
190 self._check_ended()
191 self._check_read_only()
192 self._add(True, args)
194 def delete(self, *args: Any) -> None:
195 """Delete records.
197 It is not an error if some of the records are not in the existing
198 set.
200 The arguments may be:
202 - rrset
204 - name
206 - name, rdatatype, [covers]
208 - name, rdataset...
210 - name, rdata...
211 """
212 self._check_ended()
213 self._check_read_only()
214 self._delete(False, args)
216 def delete_exact(self, *args: Any) -> None:
217 """Delete records.
219 The arguments may be:
221 - rrset
223 - name
225 - name, rdatatype, [covers]
227 - name, rdataset...
229 - name, rdata...
231 :raises: :py:exc:`dns.transaction.DeleteNotExact` if some of the
232 records are not in the existing set.
233 """
234 self._check_ended()
235 self._check_read_only()
236 self._delete(True, args)
238 def name_exists(self, name: dns.name.Name | str) -> bool:
239 """Does the specified name exist?"""
240 self._check_ended()
241 if isinstance(name, str):
242 name = dns.name.from_text(name, None)
243 return self._name_exists(name)
245 def update_serial(
246 self,
247 value: int = 1,
248 relative: bool = True,
249 name: dns.name.Name = dns.name.empty,
250 ) -> None:
251 """Update the serial number.
253 :param int value: An increment if *relative* is ``True``, or the
254 actual value to set if *relative* is ``False``.
255 :param bool relative: Whether *value* is a relative increment or an
256 absolute value. The default is ``True``.
257 :param name: The name of the SOA record to update.
258 :type name: :py:class:`dns.name.Name`
259 :raises KeyError: If there is no SOA rdataset at *name*.
260 :raises ValueError: If *value* is negative or if the increment is
261 so large that it would cause the new serial to be less than the
262 prior value.
263 """
264 self._check_ended()
265 if value < 0:
266 raise ValueError("negative update_serial() value")
267 if isinstance(name, str):
268 name = dns.name.from_text(name, None)
269 rdataset = self._get_rdataset(name, dns.rdatatype.SOA, dns.rdatatype.NONE)
270 if rdataset is None or len(rdataset) == 0:
271 raise KeyError
272 if relative:
273 serial = dns.serial.Serial(rdataset[0].serial) + value
274 else:
275 serial = dns.serial.Serial(value)
276 serial = serial.value # convert back to int
277 if serial == 0:
278 serial = 1
279 rdata = rdataset[0].replace(serial=serial)
280 new_rdataset = dns.rdataset.from_rdata(rdataset.ttl, rdata)
281 self.replace(name, new_rdataset)
283 def add_unicode(self, attribute: str):
284 self.unicode.add(attribute.upper())
286 def __iter__(self):
287 self._check_ended()
288 return self._iterate_rdatasets()
290 def changed(self) -> bool:
291 """Has this transaction changed anything?
293 For read-only transactions, the result is always ``False``.
295 For writable transactions, the result is ``True`` if at some time
296 during the life of the transaction, the content was changed.
297 """
298 self._check_ended()
299 return self._changed()
301 def commit(self) -> None:
302 """Commit the transaction.
304 Normally transactions are used as context managers and commit
305 or rollback automatically, but it may be done explicitly if needed.
307 :raises: :py:exc:`dns.transaction.AlreadyEnded` if the transaction
308 has already been committed or rolled back.
309 :raises: An exception if the commit fails, in which case the
310 transaction is also rolled back.
311 """
312 self._end(True)
314 def rollback(self) -> None:
315 """Rollback the transaction.
317 Normally transactions are used as context managers and commit
318 or rollback automatically, but it may be done explicitly if needed.
319 Rollback cannot otherwise fail.
321 :raises: :py:exc:`dns.transaction.AlreadyEnded` if the transaction
322 has already been committed or rolled back.
323 """
324 self._end(False)
326 def check_put_rdataset(self, check: CheckPutRdatasetType) -> None:
327 """Call *check* before putting (storing) an rdataset.
329 The function is called with the transaction, the name, and the rdataset.
331 The check function may safely make non-mutating transaction method
332 calls, but behavior is undefined if mutating transaction methods are
333 called. The check function should raise an exception if it objects to
334 the put, and otherwise should return ``None``.
335 """
336 self._check_put_rdataset.append(check)
338 def check_delete_rdataset(self, check: CheckDeleteRdatasetType) -> None:
339 """Call *check* before deleting an rdataset.
341 The function is called with the transaction, the name, the rdatatype,
342 and the covered rdatatype.
344 The check function may safely make non-mutating transaction method
345 calls, but behavior is undefined if mutating transaction methods are
346 called. The check function should raise an exception if it objects to
347 the put, and otherwise should return ``None``.
348 """
349 self._check_delete_rdataset.append(check)
351 def check_delete_name(self, check: CheckDeleteNameType) -> None:
352 """Call *check* before putting (storing) an rdataset.
354 The function is called with the transaction and the name.
356 The check function may safely make non-mutating transaction method
357 calls, but behavior is undefined if mutating transaction methods are
358 called. The check function should raise an exception if it objects to
359 the put, and otherwise should return ``None``.
360 """
361 self._check_delete_name.append(check)
363 def iterate_rdatasets(
364 self,
365 ) -> Iterator[tuple[dns.name.Name, dns.rdataset.Rdataset]]:
366 """Iterate all the rdatasets in the transaction, returning
367 (:py:class:`dns.name.Name`, :py:class:`dns.rdataset.Rdataset`) tuples.
369 Note that as is usual with Python iterators, adding or removing items
370 while iterating will invalidate the iterator and may raise
371 :py:exc:`RuntimeError` or fail to iterate over all entries."""
372 self._check_ended()
373 return self._iterate_rdatasets()
375 def iterate_names(self) -> Iterator[dns.name.Name]:
376 """Iterate all the names in the transaction.
378 Note that as is usual with Python iterators, adding or removing names
379 while iterating will invalidate the iterator and may raise
380 :py:exc:`RuntimeError` or fail to iterate over all entries."""
381 self._check_ended()
382 return self._iterate_names()
384 #
385 # Helper methods
386 #
388 def _raise_if_not_empty(self, method, args):
389 if len(args) != 0:
390 raise TypeError(f"extra parameters to {method}")
392 def _rdataset_from_args(self, method, deleting, args):
393 try:
394 arg = args.popleft()
395 if isinstance(arg, dns.rrset.RRset):
396 rdataset = arg.to_rdataset()
397 elif isinstance(arg, dns.rdataset.Rdataset):
398 rdataset = arg
399 else:
400 if deleting:
401 ttl = 0
402 else:
403 if isinstance(arg, int):
404 ttl = arg
405 if ttl > dns.ttl.MAX_TTL:
406 raise ValueError(f"{method}: TTL value too big")
407 else:
408 raise TypeError(f"{method}: expected a TTL")
409 arg = args.popleft()
410 if isinstance(arg, dns.rdata.Rdata):
411 rdataset = dns.rdataset.from_rdata(ttl, arg)
412 else:
413 raise TypeError(f"{method}: expected an Rdata")
414 return rdataset
415 except IndexError:
416 if deleting:
417 return None
418 else:
419 # reraise
420 raise TypeError(f"{method}: expected more arguments")
422 def _add(self, replace, args):
423 if replace:
424 method = "replace()"
425 else:
426 method = "add()"
427 try:
428 args = collections.deque(args)
429 arg = args.popleft()
430 if isinstance(arg, str):
431 arg = dns.name.from_text(arg, None)
432 if isinstance(arg, dns.name.Name):
433 name = arg
434 rdataset = self._rdataset_from_args(method, False, args)
435 elif isinstance(arg, dns.rrset.RRset):
436 rrset = arg
437 name = rrset.name
438 # rrsets are also rdatasets, but they don't print the
439 # same and can't be stored in nodes, so convert.
440 rdataset = rrset.to_rdataset()
441 else:
442 raise TypeError(
443 f"{method} requires a name or RRset as the first argument"
444 )
445 assert rdataset is not None # for type checkers
446 if rdataset.rdclass != self.manager.get_class():
447 raise ValueError(f"{method} has objects of wrong RdataClass")
448 if rdataset.rdtype == dns.rdatatype.SOA:
449 _, _, origin = self._origin_information()
450 if name != origin:
451 raise ValueError(f"{method} has non-origin SOA")
452 self._raise_if_not_empty(method, args)
453 if not replace:
454 existing = self._get_rdataset(name, rdataset.rdtype, rdataset.covers)
455 if existing is not None:
456 if isinstance(existing, dns.rdataset.ImmutableRdataset):
457 trds = dns.rdataset.Rdataset(
458 existing.rdclass, existing.rdtype, existing.covers
459 )
460 trds.update(existing)
461 existing = trds
462 rdataset = existing.union(rdataset)
463 self._checked_put_rdataset(name, rdataset)
464 except IndexError:
465 raise TypeError(f"not enough parameters to {method}")
467 def _delete(self, exact, args):
468 if exact:
469 method = "delete_exact()"
470 else:
471 method = "delete()"
472 try:
473 args = collections.deque(args)
474 arg = args.popleft()
475 if isinstance(arg, str):
476 arg = dns.name.from_text(arg, None)
477 if isinstance(arg, dns.name.Name):
478 name = arg
479 if len(args) > 0 and (
480 isinstance(args[0], int) or isinstance(args[0], str)
481 ):
482 # deleting by type and (optionally) covers
483 rdtype = dns.rdatatype.RdataType.make(args.popleft())
484 if len(args) > 0:
485 covers = dns.rdatatype.RdataType.make(args.popleft())
486 else:
487 covers = dns.rdatatype.NONE
488 self._raise_if_not_empty(method, args)
489 existing = self._get_rdataset(name, rdtype, covers)
490 if existing is None:
491 if exact:
492 raise DeleteNotExact(f"{method}: missing rdataset")
493 else:
494 self._checked_delete_rdataset(name, rdtype, covers)
495 return
496 else:
497 rdataset = self._rdataset_from_args(method, True, args)
498 elif isinstance(arg, dns.rrset.RRset):
499 rdataset = arg # rrsets are also rdatasets
500 name = rdataset.name
501 else:
502 raise TypeError(
503 f"{method} requires a name or RRset as the first argument"
504 )
505 self._raise_if_not_empty(method, args)
506 if rdataset:
507 if rdataset.rdclass != self.manager.get_class():
508 raise ValueError(f"{method} has objects of wrong RdataClass")
509 existing = self._get_rdataset(name, rdataset.rdtype, rdataset.covers)
510 if existing is not None:
511 if exact:
512 intersection = existing.intersection(rdataset)
513 if intersection != rdataset:
514 raise DeleteNotExact(f"{method}: missing rdatas")
515 rdataset = existing.difference(rdataset)
516 if len(rdataset) == 0:
517 self._checked_delete_rdataset(
518 name, rdataset.rdtype, rdataset.covers
519 )
520 else:
521 self._checked_put_rdataset(name, rdataset)
522 elif exact:
523 raise DeleteNotExact(f"{method}: missing rdataset")
524 else:
525 if exact and not self._name_exists(name):
526 raise DeleteNotExact(f"{method}: name not known")
527 self._checked_delete_name(name)
528 except IndexError:
529 raise TypeError(f"not enough parameters to {method}")
531 def _check_ended(self):
532 if self._ended:
533 raise AlreadyEnded
535 def _end(self, commit):
536 self._check_ended()
537 try:
538 self._end_transaction(commit)
539 finally:
540 self._ended = True
542 def _checked_put_rdataset(self, name, rdataset):
543 for check in self._check_put_rdataset:
544 check(self, name, rdataset)
545 self._put_rdataset(name, rdataset)
547 def _checked_delete_rdataset(self, name, rdtype, covers):
548 for check in self._check_delete_rdataset:
549 check(self, name, rdtype, covers)
550 self._delete_rdataset(name, rdtype, covers)
552 def _checked_delete_name(self, name):
553 for check in self._check_delete_name:
554 check(self, name)
555 self._delete_name(name)
557 #
558 # Transactions are context managers.
559 #
561 def __enter__(self):
562 return self
564 def __exit__(self, exc_type, exc_val, exc_tb):
565 if not self._ended:
566 if exc_type is None:
567 self.commit()
568 else:
569 self.rollback()
570 return False
572 #
573 # This is the low level API, which must be implemented by subclasses
574 # of Transaction.
575 #
577 def _get_rdataset(self, name, rdtype, covers):
578 """Return the rdataset associated with *name*, *rdtype*, and *covers*,
579 or ``None`` if not found.
580 """
581 raise NotImplementedError # pragma: no cover
583 def _put_rdataset(self, name, rdataset):
584 """Store the rdataset."""
585 raise NotImplementedError # pragma: no cover
587 def _delete_name(self, name):
588 """Delete all data associated with *name*.
590 It is not an error if the name does not exist.
591 """
592 raise NotImplementedError # pragma: no cover
594 def _delete_rdataset(self, name, rdtype, covers):
595 """Delete all data associated with *name*, *rdtype*, and *covers*.
597 It is not an error if the rdataset does not exist.
598 """
599 raise NotImplementedError # pragma: no cover
601 def _name_exists(self, name):
602 """Does name exist?
604 :rtype: bool
605 """
606 raise NotImplementedError # pragma: no cover
608 def _changed(self):
609 """Has this transaction changed anything?"""
610 raise NotImplementedError # pragma: no cover
612 def _end_transaction(self, commit):
613 """End the transaction.
615 :param bool commit: If ``True``, commit the transaction; otherwise
616 roll it back. If committing and the commit fails, then roll back
617 and raise an exception.
618 """
619 raise NotImplementedError # pragma: no cover
621 def _set_origin(self, origin):
622 """Set the origin.
624 This method is called when reading a possibly relativized
625 source, and an origin setting operation occurs (e.g. $ORIGIN
626 in a zone file).
627 """
628 raise NotImplementedError # pragma: no cover
630 def _iterate_rdatasets(self):
631 """Return an iterator that yields (name, rdataset) tuples."""
632 raise NotImplementedError # pragma: no cover
634 def _iterate_names(self):
635 """Return an iterator that yields a name."""
636 raise NotImplementedError # pragma: no cover
638 def _get_node(self, name):
639 """Return the node at *name*, if any.
641 :returns: The node, or ``None`` if *name* does not exist.
642 :rtype: :py:class:`dns.node.Node` or ``None``
643 """
644 raise NotImplementedError # pragma: no cover
646 #
647 # Low-level API with a default implementation, in case a subclass needs
648 # to override.
649 #
651 def _origin_information(self):
652 # This is only used by _add()
653 return self.manager.origin_information()
656class TooManyChanges(dns.exception.DNSException):
657 """Too many changes"""
660class TransactionLimiter:
661 """Transaction setup that enforces a maximum number of changes in the transaction.
663 Each rdataset put or deleted counts as a change. Each whole name deletion counts
664 as a change.
666 The limiter is meant to ensure that reading a zonefile from an untrusted source
667 cannot cause too many changes, e.g. via large $GENERATE statements. It is not
668 meant to limit the total number of records in a zone or the memory used by the
669 zone.
670 """
672 def __init__(self, limit: int):
673 super().__init__()
674 self.limit = limit
675 self.changes: int = 0
677 def _check_limit(self):
678 if self.changes >= self.limit:
679 raise TooManyChanges(f"limit is {self.limit} changes")
681 def _check_put_rdataset(
682 self, txn: Transaction, name: dns.name.Name, rdataset: dns.rdataset.Rdataset
683 ):
684 self.changes += 1
685 self._check_limit()
687 def _check_delete_rdataset(
688 self,
689 txn: Transaction,
690 name: dns.name.Name,
691 type: dns.rdatatype.RdataType,
692 covers: dns.rdatatype.RdataType | None,
693 ):
694 self.changes += 1
695 self._check_limit()
697 def _check_delete_name(
698 self,
699 txn: Transaction,
700 name: dns.name.Name,
701 ):
702 self.changes += 1
703 self._check_limit()
705 def __call__(self, txn: Transaction):
706 txn.check_put_rdataset(self._check_put_rdataset)
707 txn.check_delete_rdataset(self._check_delete_rdataset)
708 txn.check_delete_name(self._check_delete_name)