zeroconf 0.149.4__tar.gz → 0.149.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of zeroconf might be problematic. Click here for more details.

Files changed (98) hide show
  1. {zeroconf-0.149.4 → zeroconf-0.149.5}/CHANGELOG.md +31 -0
  2. {zeroconf-0.149.4 → zeroconf-0.149.5}/PKG-INFO +1 -1
  3. {zeroconf-0.149.4 → zeroconf-0.149.5}/pyproject.toml +1 -1
  4. {zeroconf-0.149.4 → zeroconf-0.149.5}/setup.py +1 -1
  5. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/__init__.py +1 -1
  6. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_protocol/incoming.pxd +1 -1
  7. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_protocol/incoming.py +10 -4
  8. {zeroconf-0.149.4 → zeroconf-0.149.5}/tests/__init__.py +14 -0
  9. {zeroconf-0.149.4 → zeroconf-0.149.5}/tests/conftest.py +19 -0
  10. {zeroconf-0.149.4 → zeroconf-0.149.5}/tests/services/test_info.py +84 -77
  11. {zeroconf-0.149.4 → zeroconf-0.149.5}/tests/services/test_types.py +11 -9
  12. {zeroconf-0.149.4 → zeroconf-0.149.5}/tests/test_asyncio.py +17 -9
  13. {zeroconf-0.149.4 → zeroconf-0.149.5}/tests/test_handlers.py +5 -1
  14. {zeroconf-0.149.4 → zeroconf-0.149.5}/tests/test_protocol.py +22 -0
  15. {zeroconf-0.149.4 → zeroconf-0.149.5}/COPYING +0 -0
  16. {zeroconf-0.149.4 → zeroconf-0.149.5}/README.rst +0 -0
  17. {zeroconf-0.149.4 → zeroconf-0.149.5}/build_ext.py +0 -0
  18. {zeroconf-0.149.4 → zeroconf-0.149.5}/docs/Makefile +0 -0
  19. {zeroconf-0.149.4 → zeroconf-0.149.5}/docs/_ext/zeroconfautodocfix.py +0 -0
  20. {zeroconf-0.149.4 → zeroconf-0.149.5}/docs/api.rst +0 -0
  21. {zeroconf-0.149.4 → zeroconf-0.149.5}/docs/conf.py +0 -0
  22. {zeroconf-0.149.4 → zeroconf-0.149.5}/docs/index.rst +0 -0
  23. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_cache.pxd +0 -0
  24. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_cache.py +0 -0
  25. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_core.py +0 -0
  26. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_dns.pxd +0 -0
  27. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_dns.py +0 -0
  28. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_engine.py +0 -0
  29. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_exceptions.py +0 -0
  30. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_handlers/__init__.py +0 -0
  31. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_handlers/answers.pxd +0 -0
  32. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_handlers/answers.py +0 -0
  33. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_handlers/multicast_outgoing_queue.pxd +0 -0
  34. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_handlers/multicast_outgoing_queue.py +0 -0
  35. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_handlers/query_handler.pxd +0 -0
  36. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_handlers/query_handler.py +0 -0
  37. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_handlers/record_manager.pxd +0 -0
  38. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_handlers/record_manager.py +0 -0
  39. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_history.pxd +0 -0
  40. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_history.py +0 -0
  41. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_listener.pxd +0 -0
  42. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_listener.py +0 -0
  43. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_logger.py +0 -0
  44. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_protocol/__init__.py +0 -0
  45. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_protocol/outgoing.pxd +0 -0
  46. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_protocol/outgoing.py +0 -0
  47. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_record_update.pxd +0 -0
  48. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_record_update.py +0 -0
  49. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_services/__init__.pxd +0 -0
  50. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_services/__init__.py +0 -0
  51. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_services/browser.pxd +0 -0
  52. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_services/browser.py +0 -0
  53. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_services/info.pxd +0 -0
  54. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_services/info.py +0 -0
  55. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_services/registry.pxd +0 -0
  56. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_services/registry.py +0 -0
  57. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_services/types.py +0 -0
  58. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_transport.py +0 -0
  59. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_updates.pxd +0 -0
  60. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_updates.py +0 -0
  61. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_utils/__init__.py +0 -0
  62. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_utils/asyncio.py +0 -0
  63. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_utils/ipaddress.pxd +0 -0
  64. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_utils/ipaddress.py +0 -0
  65. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_utils/name.py +0 -0
  66. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_utils/net.py +0 -0
  67. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_utils/time.pxd +0 -0
  68. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/_utils/time.py +0 -0
  69. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/asyncio.py +0 -0
  70. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/const.py +0 -0
  71. {zeroconf-0.149.4 → zeroconf-0.149.5}/src/zeroconf/py.typed +0 -0
  72. {zeroconf-0.149.4 → zeroconf-0.149.5}/tests/benchmarks/__init__.py +0 -0
  73. {zeroconf-0.149.4 → zeroconf-0.149.5}/tests/benchmarks/helpers.py +0 -0
  74. {zeroconf-0.149.4 → zeroconf-0.149.5}/tests/benchmarks/test_cache.py +0 -0
  75. {zeroconf-0.149.4 → zeroconf-0.149.5}/tests/benchmarks/test_incoming.py +0 -0
  76. {zeroconf-0.149.4 → zeroconf-0.149.5}/tests/benchmarks/test_outgoing.py +0 -0
  77. {zeroconf-0.149.4 → zeroconf-0.149.5}/tests/benchmarks/test_send.py +0 -0
  78. {zeroconf-0.149.4 → zeroconf-0.149.5}/tests/benchmarks/test_txt_properties.py +0 -0
  79. {zeroconf-0.149.4 → zeroconf-0.149.5}/tests/services/__init__.py +0 -0
  80. {zeroconf-0.149.4 → zeroconf-0.149.5}/tests/services/test_browser.py +0 -0
  81. {zeroconf-0.149.4 → zeroconf-0.149.5}/tests/services/test_registry.py +0 -0
  82. {zeroconf-0.149.4 → zeroconf-0.149.5}/tests/test_cache.py +0 -0
  83. {zeroconf-0.149.4 → zeroconf-0.149.5}/tests/test_circular_imports.py +0 -0
  84. {zeroconf-0.149.4 → zeroconf-0.149.5}/tests/test_core.py +0 -0
  85. {zeroconf-0.149.4 → zeroconf-0.149.5}/tests/test_dns.py +0 -0
  86. {zeroconf-0.149.4 → zeroconf-0.149.5}/tests/test_engine.py +0 -0
  87. {zeroconf-0.149.4 → zeroconf-0.149.5}/tests/test_exceptions.py +0 -0
  88. {zeroconf-0.149.4 → zeroconf-0.149.5}/tests/test_history.py +0 -0
  89. {zeroconf-0.149.4 → zeroconf-0.149.5}/tests/test_init.py +0 -0
  90. {zeroconf-0.149.4 → zeroconf-0.149.5}/tests/test_listener.py +0 -0
  91. {zeroconf-0.149.4 → zeroconf-0.149.5}/tests/test_logger.py +0 -0
  92. {zeroconf-0.149.4 → zeroconf-0.149.5}/tests/test_services.py +0 -0
  93. {zeroconf-0.149.4 → zeroconf-0.149.5}/tests/test_updates.py +0 -0
  94. {zeroconf-0.149.4 → zeroconf-0.149.5}/tests/utils/__init__.py +0 -0
  95. {zeroconf-0.149.4 → zeroconf-0.149.5}/tests/utils/test_asyncio.py +0 -0
  96. {zeroconf-0.149.4 → zeroconf-0.149.5}/tests/utils/test_ipaddress.py +0 -0
  97. {zeroconf-0.149.4 → zeroconf-0.149.5}/tests/utils/test_name.py +0 -0
  98. {zeroconf-0.149.4 → zeroconf-0.149.5}/tests/utils/test_net.py +0 -0
@@ -2,6 +2,37 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v0.149.5 (2026-05-18)
6
+
7
+ ### Bug Fixes
8
+
9
+ - Bound DNS compression-pointer chain depth in DNSIncoming
10
+ ([#1719](https://github.com/python-zeroconf/python-zeroconf/pull/1719),
11
+ [`f9e2359`](https://github.com/python-zeroconf/python-zeroconf/commit/f9e23592137f30fdf7ef710dba065da31c79b1cf))
12
+
13
+ ### Testing
14
+
15
+ - Cap helper get_service_info timeout in suppression test (~3.0s → ~1.85s)
16
+ ([#1708](https://github.com/python-zeroconf/python-zeroconf/pull/1708),
17
+ [`ee3c7d7`](https://github.com/python-zeroconf/python-zeroconf/commit/ee3c7d74ff45327a3a6d520b86a691e21e2bc219))
18
+
19
+ - Drop ZeroconfServiceTypes.find() timeouts from 500ms to 200ms on loopback
20
+ ([#1710](https://github.com/python-zeroconf/python-zeroconf/pull/1710),
21
+ [`64d143d`](https://github.com/python-zeroconf/python-zeroconf/commit/64d143d2ee7874ee1d9cef0dd2799c008b4aa791))
22
+
23
+ - Eliminate test_get_info_single race by injecting from the send mock
24
+ ([#1716](https://github.com/python-zeroconf/python-zeroconf/pull/1716),
25
+ [`963d3d7`](https://github.com/python-zeroconf/python-zeroconf/commit/963d3d70e1cde056967eba0d8747ddcd247ae707))
26
+
27
+ - Fix race in test_register_and_lookup_type_by_uppercase_name
28
+ ([#1712](https://github.com/python-zeroconf/python-zeroconf/pull/1712),
29
+ [`91aa21d`](https://github.com/python-zeroconf/python-zeroconf/commit/91aa21d52a0873f5fc12d43675b1b521dfe20519))
30
+
31
+ - Speed up service-info request tests with quick_request_timing fixture
32
+ ([#1709](https://github.com/python-zeroconf/python-zeroconf/pull/1709),
33
+ [`4bae30a`](https://github.com/python-zeroconf/python-zeroconf/commit/4bae30a2ed0910ee7c4f1d0f92f2c400a7b10f31))
34
+
35
+
5
36
  ## v0.149.4 (2026-05-17)
6
37
 
7
38
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zeroconf
3
- Version: 0.149.4
3
+ Version: 0.149.5
4
4
  Summary: A pure python implementation of multicast DNS service discovery
5
5
  License-Expression: LGPL-2.1-or-later
6
6
  License-File: COPYING
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [project]
6
6
  name = "zeroconf"
7
- version = "0.149.4"
7
+ version = "0.149.5"
8
8
  license = "LGPL-2.1-or-later"
9
9
  description = "A pure python implementation of multicast DNS service discovery"
10
10
  readme = "README.rst"
@@ -19,7 +19,7 @@ install_requires = \
19
19
 
20
20
  setup_kwargs = {
21
21
  'name': 'zeroconf',
22
- 'version': '0.149.4',
22
+ 'version': '0.149.5',
23
23
  'description': 'A pure python implementation of multicast DNS service discovery',
24
24
  'long_description': 'python-zeroconf\n===============\n\n.. image:: https://github.com/python-zeroconf/python-zeroconf/workflows/CI/badge.svg\n :target: https://github.com/python-zeroconf/python-zeroconf?query=workflow%3ACI+branch%3Amaster\n\n.. image:: https://img.shields.io/pypi/v/zeroconf.svg\n :target: https://pypi.python.org/pypi/zeroconf\n\n.. image:: https://codecov.io/gh/python-zeroconf/python-zeroconf/branch/master/graph/badge.svg\n :target: https://codecov.io/gh/python-zeroconf/python-zeroconf\n\n.. image:: https://img.shields.io/endpoint?url=https://codspeed.io/badge.json\n :target: https://codspeed.io/python-zeroconf/python-zeroconf\n :alt: Codspeed.io status for python-zeroconf\n\n.. image:: https://readthedocs.org/projects/python-zeroconf/badge/?version=latest\n :target: https://python-zeroconf.readthedocs.io/en/latest/?badge=latest\n :alt: Documentation Status\n\n`Documentation <https://python-zeroconf.readthedocs.io/en/latest/>`_.\n\nThis is fork of pyzeroconf, Multicast DNS Service Discovery for Python,\noriginally by Paul Scott-Murphy (https://github.com/paulsm/pyzeroconf),\nmodified by William McBrine (https://github.com/wmcbrine/pyzeroconf).\n\nThe original William McBrine\'s fork note::\n\n This fork is used in all of my TiVo-related projects: HME for Python\n (and therefore HME/VLC), Network Remote, Remote Proxy, and pyTivo.\n Before this, I was tracking the changes for zeroconf.py in three\n separate repos. I figured I should have an authoritative source.\n\n Although I make changes based on my experience with TiVos, I expect that\n they\'re generally applicable. This version also includes patches found\n on the now-defunct (?) Launchpad repo of pyzeroconf, and elsewhere\n around the net -- not always well-documented, sorry.\n\nCompatible with:\n\n* Bonjour\n* Avahi\n\nCompared to some other Zeroconf/Bonjour/Avahi Python packages, python-zeroconf:\n\n* isn\'t tied to Bonjour or Avahi\n* doesn\'t use D-Bus\n* doesn\'t force you to use particular event loop or Twisted (asyncio is used under the hood but not required)\n* is pip-installable\n* has PyPI distribution\n* has an optional cython extension for performance (pure python is supported as well)\n\nPython compatibility\n--------------------\n\n* CPython 3.10+\n* PyPy 3.10+\n\nVersioning\n----------\n\nThis project uses semantic versioning.\n\nStatus\n------\n\nThis project is actively maintained.\n\nTraffic Reduction\n-----------------\n\nBefore version 0.32, most traffic reduction techniques described in https://datatracker.ietf.org/doc/html/rfc6762#section-7\nwhere not implemented which could lead to excessive network traffic. It is highly recommended that version 0.32 or later\nis used if this is a concern.\n\nIPv6 support\n------------\n\nIPv6 support is relatively new and currently limited, specifically:\n\n* `InterfaceChoice.All` is an alias for `InterfaceChoice.Default` on non-POSIX\n systems.\n* Dual-stack IPv6 sockets are used, which may not be supported everywhere (some\n BSD variants do not have them).\n* Listening on localhost (`::1`) does not work. Help with understanding why is\n appreciated.\n\nHow to get python-zeroconf?\n===========================\n\n* PyPI page https://pypi.org/project/zeroconf/\n* GitHub project https://github.com/python-zeroconf/python-zeroconf\n\nThe easiest way to install python-zeroconf is using pip::\n\n pip install zeroconf\n\n\n\nHow do I use it?\n================\n\nHere\'s an example of browsing for a service:\n\n.. code-block:: python\n\n from zeroconf import ServiceBrowser, ServiceListener, Zeroconf\n\n\n class MyListener(ServiceListener):\n\n def update_service(self, zc: Zeroconf, type_: str, name: str) -> None:\n print(f"Service {name} updated")\n\n def remove_service(self, zc: Zeroconf, type_: str, name: str) -> None:\n print(f"Service {name} removed")\n\n def add_service(self, zc: Zeroconf, type_: str, name: str) -> None:\n info = zc.get_service_info(type_, name)\n print(f"Service {name} added, service info: {info}")\n\n\n zeroconf = Zeroconf()\n listener = MyListener()\n browser = ServiceBrowser(zeroconf, "_http._tcp.local.", listener)\n try:\n input("Press enter to exit...\\n\\n")\n finally:\n zeroconf.close()\n\n.. note::\n\n Discovery and service registration use *all* available network interfaces by default.\n If you want to customize that you need to specify ``interfaces`` argument when\n constructing ``Zeroconf`` object (see the code for details).\n\nIf you don\'t know the name of the service you need to browse for, try:\n\n.. code-block:: python\n\n from zeroconf import ZeroconfServiceTypes\n print(\'\\n\'.join(ZeroconfServiceTypes.find()))\n\nSee examples directory for more.\n\nChangelog\n=========\n\n`Changelog <CHANGELOG.md>`_\n\nLicense\n=======\n\nLGPL, see COPYING file for details.\n',
25
25
  'author': 'Paul Scott-Murphy',
@@ -88,7 +88,7 @@ from ._utils.time import ( # noqa # import needed for backwards compat
88
88
 
89
89
  __author__ = "Paul Scott-Murphy, William McBrine"
90
90
  __maintainer__ = "Jakub Stasiak <jakub@stasiak.at>"
91
- __version__ = "0.149.4"
91
+ __version__ = "0.149.5"
92
92
  __license__ = "LGPL"
93
93
 
94
94
 
@@ -83,7 +83,7 @@ cdef class DNSIncoming:
83
83
  link_py_int=object,
84
84
  linked_labels=cython.list
85
85
  )
86
- cdef unsigned int _decode_labels_at_offset(self, unsigned int off, cython.list labels, cython.set seen_pointers)
86
+ cdef unsigned int _decode_labels_at_offset(self, unsigned int off, cython.list labels, cython.set seen_pointers, unsigned int depth)
87
87
 
88
88
  @cython.locals(offset="unsigned int")
89
89
  cdef void _read_header(self)
@@ -60,7 +60,7 @@ DNS_COMPRESSION_POINTER_LEN = 2
60
60
  MAX_DNS_LABELS = 128
61
61
  MAX_NAME_LENGTH = 253
62
62
 
63
- DECODE_EXCEPTIONS = (IndexError, struct.error, IncomingDecodeError)
63
+ DECODE_EXCEPTIONS = (IndexError, struct.error, IncomingDecodeError, RecursionError)
64
64
 
65
65
 
66
66
  _seen_logs: dict[str, int | tuple] = {}
@@ -409,7 +409,7 @@ class DNSIncoming:
409
409
  labels: list[str] = []
410
410
  seen_pointers: set[int] = set()
411
411
  original_offset = self.offset
412
- self.offset = self._decode_labels_at_offset(original_offset, labels, seen_pointers)
412
+ self.offset = self._decode_labels_at_offset(original_offset, labels, seen_pointers, 0)
413
413
  self._name_cache[original_offset] = labels
414
414
  name = ".".join(labels) + "."
415
415
  if len(name) > MAX_NAME_LENGTH:
@@ -418,8 +418,14 @@ class DNSIncoming:
418
418
  )
419
419
  return name
420
420
 
421
- def _decode_labels_at_offset(self, off: _int, labels: list[str], seen_pointers: set[int]) -> int:
421
+ def _decode_labels_at_offset(
422
+ self, off: _int, labels: list[str], seen_pointers: set[int], depth: _int
423
+ ) -> int:
422
424
  # This is a tight loop that is called frequently, small optimizations can make a difference.
425
+ if depth > MAX_DNS_LABELS:
426
+ raise IncomingDecodeError(
427
+ f"DNS compression pointer chain exceeds {MAX_DNS_LABELS} at {off} from {self.source}"
428
+ )
423
429
  view = self.view
424
430
  while off < self._data_len:
425
431
  length = view[off]
@@ -457,7 +463,7 @@ class DNSIncoming:
457
463
  if not linked_labels:
458
464
  linked_labels = []
459
465
  seen_pointers.add(link_py_int)
460
- self._decode_labels_at_offset(link, linked_labels, seen_pointers)
466
+ self._decode_labels_at_offset(link, linked_labels, seen_pointers, depth + 1)
461
467
  self._name_cache[link_py_int] = linked_labels
462
468
  labels.extend(linked_labels)
463
469
  if len(labels) > MAX_DNS_LABELS:
@@ -35,6 +35,20 @@ from zeroconf._history import QuestionHistory
35
35
 
36
36
  _MONOTONIC_RESOLUTION = time.get_clock_info("monotonic").resolution
37
37
 
38
+ # get_service_info / async_request timeout for tests using the
39
+ # `quick_request_timing` fixture. The fixture cuts the initial-query
40
+ # delay to ~15ms (10ms _LISTENER_TIME + 1-5ms jitter), so 50ms is
41
+ # ample headroom for tests that only need to observe the first one
42
+ # or two queries.
43
+ QUICK_REQUEST_TIMEOUT_MS = 50
44
+
45
+ # Timeout for ZeroconfServiceTypes.find() / AsyncZeroconfServiceTypes.async_find()
46
+ # in loopback integration tests. `find()` is just `time.sleep(timeout)` —
47
+ # it doesn't short-circuit on the first matching response — so the
48
+ # timeout becomes a lower bound on the test runtime. On loopback the
49
+ # registrar's response lands within a few ms; 200ms is ~50x headroom.
50
+ LOOPBACK_FIND_TIMEOUT = 0.2
51
+
38
52
 
39
53
  class QuestionHistoryWithoutSuppression(QuestionHistory):
40
54
  def suppresses(self, question: DNSQuestion, now: float, known_answers: set[DNSRecord]) -> bool:
@@ -10,6 +10,7 @@ import pytest
10
10
 
11
11
  from zeroconf import _core, const
12
12
  from zeroconf._handlers import query_handler
13
+ from zeroconf._services import info as service_info
13
14
 
14
15
 
15
16
  @pytest.fixture(autouse=True)
@@ -59,3 +60,21 @@ def quick_timing() -> Generator[None]:
59
60
  patch.object(_core, "_UNREGISTER_TIME", 10),
60
61
  ):
61
62
  yield
63
+
64
+
65
+ @pytest.fixture
66
+ def quick_request_timing() -> Generator[None]:
67
+ """Shorten the initial-query delay used by AsyncServiceInfo.async_request.
68
+
69
+ The 200ms `_LISTENER_TIME` and 20-120ms random jitter (RFC 6762
70
+ §5.2) help spread queries from multiple clients on real networks.
71
+ On loopback they're pure overhead — get_service_info-style tests
72
+ wait ~250ms before the first query even fires. Opt in by adding
73
+ `quick_request_timing` to a test's argument list, then drop the
74
+ test's own timeouts (which had to accommodate that delay).
75
+ """
76
+ with (
77
+ patch.object(service_info, "_LISTENER_TIME", 10),
78
+ patch.object(service_info, "_AVOID_SYNC_DELAY_RANDOM_INTERVAL", (1, 5)),
79
+ ):
80
+ yield
@@ -23,7 +23,7 @@ from zeroconf._services.info import ServiceInfo
23
23
  from zeroconf._utils.net import IPVersion
24
24
  from zeroconf.asyncio import AsyncZeroconf
25
25
 
26
- from .. import _inject_response, has_working_ipv6
26
+ from .. import QUICK_REQUEST_TIMEOUT_MS, _inject_response, has_working_ipv6
27
27
 
28
28
  log = logging.getLogger("zeroconf")
29
29
  original_logging_level = logging.NOTSET
@@ -430,9 +430,9 @@ class TestServiceInfo(unittest.TestCase):
430
430
 
431
431
  return r.DNSIncoming(generated.packets()[0])
432
432
 
433
- def get_service_info_helper(zc, type, name):
433
+ def get_service_info_helper(zc, type, name, timeout):
434
434
  nonlocal service_info
435
- service_info = zc.get_service_info(type, name)
435
+ service_info = zc.get_service_info(type, name, timeout)
436
436
  service_info_event.set()
437
437
 
438
438
  try:
@@ -453,9 +453,14 @@ class TestServiceInfo(unittest.TestCase):
453
453
  for question in seed_history_questions:
454
454
  zc.question_history.add_question_at_time(question, far_future, set())
455
455
 
456
+ # No answers ever come back (all queries are suppressed),
457
+ # so cap the helper at the worst-case sum of the three
458
+ # phase waits below plus margin instead of the 3000ms
459
+ # default. Phase 3 waits ~1.6s (the 999ms QM gap plus
460
+ # jitter and 500ms buffer); 1500ms covers it.
456
461
  helper_thread = threading.Thread(
457
462
  target=get_service_info_helper,
458
- args=(zc, service_type, service_name),
463
+ args=(zc, service_type, service_name, 1500),
459
464
  )
460
465
  helper_thread.start()
461
466
  wait_time = (const._LISTENER_TIME + info._AVOID_SYNC_DELAY_RANDOM_INTERVAL[1] + 500) / 1000
@@ -506,6 +511,7 @@ class TestServiceInfo(unittest.TestCase):
506
511
  zc.remove_all_service_listeners()
507
512
  zc.close()
508
513
 
514
+ @pytest.mark.usefixtures("quick_request_timing")
509
515
  def test_get_info_single(self):
510
516
  zc = r.Zeroconf(interfaces=["127.0.0.1"])
511
517
 
@@ -516,91 +522,88 @@ class TestServiceInfo(unittest.TestCase):
516
522
  service_address = "10.0.1.2"
517
523
 
518
524
  service_info = None
519
- send_event = Event()
520
525
  service_info_event = Event()
521
526
 
522
- last_sent: r.DNSOutgoing | None = None
527
+ ttl = 120
528
+ response_records = [
529
+ r.DNSText(
530
+ service_name,
531
+ const._TYPE_TXT,
532
+ const._CLASS_IN | const._CLASS_UNIQUE,
533
+ ttl,
534
+ service_text,
535
+ ),
536
+ r.DNSService(
537
+ service_name,
538
+ const._TYPE_SRV,
539
+ const._CLASS_IN | const._CLASS_UNIQUE,
540
+ ttl,
541
+ 0,
542
+ 0,
543
+ 80,
544
+ service_server,
545
+ ),
546
+ r.DNSAddress(
547
+ service_server,
548
+ const._TYPE_A,
549
+ const._CLASS_IN | const._CLASS_UNIQUE,
550
+ ttl,
551
+ socket.inet_pton(socket.AF_INET, service_address),
552
+ ),
553
+ ]
523
554
 
524
- def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()):
525
- """Sends an outgoing packet."""
526
- nonlocal last_sent
555
+ def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming:
556
+ generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE)
557
+ for record in records:
558
+ generated.add_answer_at_time(record, 0)
559
+ return r.DNSIncoming(generated.packets()[0])
527
560
 
528
- last_sent = out
529
- send_event.set()
561
+ sent_queries: list[r.DNSOutgoing] = []
562
+
563
+ def send(out, addr=const._MDNS_ADDR, port=const._MDNS_PORT, v6_flow_scope=()):
564
+ """Capture each query and, on the first one, fill the cache
565
+ inline so the next iteration of `async_request` finds
566
+ `_is_complete=True` and exits without sending another query.
567
+
568
+ Running the inject from inside `send` keeps it on the event
569
+ loop thread and atomic with the first send — eliminating the
570
+ test-thread → `run_coroutine_threadsafe` race that flaked
571
+ under PyPy + use_cython when `quick_request_timing` shortens
572
+ the inter-iteration delay to ~15ms.
573
+ """
574
+ sent_queries.append(out)
575
+ if len(sent_queries) == 1:
576
+ zc.record_manager.async_updates_from_response(mock_incoming_msg(response_records))
577
+
578
+ def get_service_info_helper(zc, type, name):
579
+ nonlocal service_info
580
+ service_info = zc.get_service_info(type, name)
581
+ service_info_event.set()
530
582
 
531
583
  # patch the zeroconf send
532
584
  with patch.object(zc, "async_send", send):
533
-
534
- def mock_incoming_msg(records: Iterable[r.DNSRecord]) -> r.DNSIncoming:
535
- generated = r.DNSOutgoing(const._FLAGS_QR_RESPONSE)
536
-
537
- for record in records:
538
- generated.add_answer_at_time(record, 0)
539
-
540
- return r.DNSIncoming(generated.packets()[0])
541
-
542
- def get_service_info_helper(zc, type, name):
543
- nonlocal service_info
544
- service_info = zc.get_service_info(type, name)
545
- service_info_event.set()
546
-
547
585
  try:
548
- ttl = 120
549
586
  helper_thread = threading.Thread(
550
587
  target=get_service_info_helper,
551
588
  args=(zc, service_type, service_name),
552
589
  )
553
590
  helper_thread.start()
554
- wait_time = 1
555
591
 
556
- # Expect query for SRV, TXT, A, AAAA
557
- send_event.wait(wait_time)
558
- assert last_sent is not None
559
- assert len(last_sent.questions) == 4
560
- assert r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) in last_sent.questions
561
- assert r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN) in last_sent.questions
562
- assert r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) in last_sent.questions
563
- assert r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) in last_sent.questions
564
- assert service_info is None
565
-
566
- # Expect no further queries
567
- last_sent = None
568
- send_event.clear()
569
- _inject_response(
570
- zc,
571
- mock_incoming_msg(
572
- [
573
- r.DNSText(
574
- service_name,
575
- const._TYPE_TXT,
576
- const._CLASS_IN | const._CLASS_UNIQUE,
577
- ttl,
578
- service_text,
579
- ),
580
- r.DNSService(
581
- service_name,
582
- const._TYPE_SRV,
583
- const._CLASS_IN | const._CLASS_UNIQUE,
584
- ttl,
585
- 0,
586
- 0,
587
- 80,
588
- service_server,
589
- ),
590
- r.DNSAddress(
591
- service_server,
592
- const._TYPE_A,
593
- const._CLASS_IN | const._CLASS_UNIQUE,
594
- ttl,
595
- socket.inet_pton(socket.AF_INET, service_address),
596
- ),
597
- ]
598
- ),
599
- )
600
- send_event.wait(wait_time)
601
- assert last_sent is None
592
+ # Helper should complete promptly — the inline inject in
593
+ # `send` populates the cache before the request loop's
594
+ # next iteration.
595
+ service_info_event.wait(1)
602
596
  assert service_info is not None
603
597
 
598
+ # First (and only) query: QU for SRV/TXT/A/AAAA.
599
+ assert len(sent_queries) == 1
600
+ first_sent = sent_queries[0]
601
+ assert len(first_sent.questions) == 4
602
+ assert r.DNSQuestion(service_name, const._TYPE_SRV, const._CLASS_IN) in first_sent.questions
603
+ assert r.DNSQuestion(service_name, const._TYPE_TXT, const._CLASS_IN) in first_sent.questions
604
+ assert r.DNSQuestion(service_name, const._TYPE_A, const._CLASS_IN) in first_sent.questions
605
+ assert r.DNSQuestion(service_name, const._TYPE_AAAA, const._CLASS_IN) in first_sent.questions
606
+
604
607
  finally:
605
608
  helper_thread.join()
606
609
  zc.remove_all_service_listeners()
@@ -980,7 +983,7 @@ def test_serviceinfo_accepts_bytes_or_string_dict():
980
983
  assert info_service.dns_text().text == b"\x0epath=/~paulsm/"
981
984
 
982
985
 
983
- def test_asking_qu_questions():
986
+ def test_asking_qu_questions(quick_request_timing):
984
987
  """Verify explicitly asking QU questions."""
985
988
  type_ = "_quservice._tcp.local."
986
989
  zeroconf = r.Zeroconf(interfaces=["127.0.0.1"])
@@ -999,12 +1002,14 @@ def test_asking_qu_questions():
999
1002
 
1000
1003
  # patch the zeroconf send
1001
1004
  with patch.object(zeroconf, "async_send", send):
1002
- zeroconf.get_service_info(f"name.{type_}", type_, 500, question_type=r.DNSQuestionType.QU)
1005
+ zeroconf.get_service_info(
1006
+ f"name.{type_}", type_, QUICK_REQUEST_TIMEOUT_MS, question_type=r.DNSQuestionType.QU
1007
+ )
1003
1008
  assert first_outgoing.questions[0].unicast is True # type: ignore[union-attr]
1004
1009
  zeroconf.close()
1005
1010
 
1006
1011
 
1007
- def test_asking_qm_questions():
1012
+ def test_asking_qm_questions(quick_request_timing):
1008
1013
  """Verify explicitly asking QM questions."""
1009
1014
  type_ = "_quservice._tcp.local."
1010
1015
  zeroconf = r.Zeroconf(interfaces=["127.0.0.1"])
@@ -1023,7 +1028,9 @@ def test_asking_qm_questions():
1023
1028
 
1024
1029
  # patch the zeroconf send
1025
1030
  with patch.object(zeroconf, "async_send", send):
1026
- zeroconf.get_service_info(f"name.{type_}", type_, 500, question_type=r.DNSQuestionType.QM)
1031
+ zeroconf.get_service_info(
1032
+ f"name.{type_}", type_, QUICK_REQUEST_TIMEOUT_MS, question_type=r.DNSQuestionType.QM
1033
+ )
1027
1034
  assert first_outgoing.questions[0].unicast is False # type: ignore[union-attr]
1028
1035
  zeroconf.close()
1029
1036
 
@@ -11,7 +11,7 @@ import unittest
11
11
  import zeroconf as r
12
12
  from zeroconf import ServiceInfo, Zeroconf, ZeroconfServiceTypes
13
13
 
14
- from .. import _clear_cache, has_working_ipv6
14
+ from .. import LOOPBACK_FIND_TIMEOUT, _clear_cache, has_working_ipv6
15
15
 
16
16
  log = logging.getLogger("zeroconf")
17
17
  original_logging_level = logging.NOTSET
@@ -47,10 +47,10 @@ def test_integration_with_listener(disable_duplicate_packet_suppression):
47
47
  )
48
48
  zeroconf_registrar.registry.async_add(info)
49
49
  try:
50
- service_types = ZeroconfServiceTypes.find(interfaces=["127.0.0.1"], timeout=0.5)
50
+ service_types = ZeroconfServiceTypes.find(interfaces=["127.0.0.1"], timeout=LOOPBACK_FIND_TIMEOUT)
51
51
  assert type_ in service_types
52
52
  _clear_cache(zeroconf_registrar)
53
- service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5)
53
+ service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=LOOPBACK_FIND_TIMEOUT)
54
54
  assert type_ in service_types
55
55
 
56
56
  finally:
@@ -79,10 +79,10 @@ def test_integration_with_listener_v6_records(disable_duplicate_packet_suppressi
79
79
  )
80
80
  zeroconf_registrar.registry.async_add(info)
81
81
  try:
82
- service_types = ZeroconfServiceTypes.find(interfaces=["127.0.0.1"], timeout=0.5)
82
+ service_types = ZeroconfServiceTypes.find(interfaces=["127.0.0.1"], timeout=LOOPBACK_FIND_TIMEOUT)
83
83
  assert type_ in service_types
84
84
  _clear_cache(zeroconf_registrar)
85
- service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5)
85
+ service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=LOOPBACK_FIND_TIMEOUT)
86
86
  assert type_ in service_types
87
87
 
88
88
  finally:
@@ -115,10 +115,12 @@ def test_integration_with_listener_ipv6(disable_duplicate_packet_suppression):
115
115
  )
116
116
  zeroconf_registrar.registry.async_add(info)
117
117
  try:
118
- service_types = ZeroconfServiceTypes.find(ip_version=r.IPVersion.V6Only, timeout=0.5)
118
+ service_types = ZeroconfServiceTypes.find(
119
+ ip_version=r.IPVersion.V6Only, timeout=LOOPBACK_FIND_TIMEOUT
120
+ )
119
121
  assert type_ in service_types
120
122
  _clear_cache(zeroconf_registrar)
121
- service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5)
123
+ service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=LOOPBACK_FIND_TIMEOUT)
122
124
  assert type_ in service_types
123
125
 
124
126
  finally:
@@ -147,10 +149,10 @@ def test_integration_with_subtype_and_listener(disable_duplicate_packet_suppress
147
149
  )
148
150
  zeroconf_registrar.registry.async_add(info)
149
151
  try:
150
- service_types = ZeroconfServiceTypes.find(interfaces=["127.0.0.1"], timeout=0.5)
152
+ service_types = ZeroconfServiceTypes.find(interfaces=["127.0.0.1"], timeout=LOOPBACK_FIND_TIMEOUT)
151
153
  assert discovery_type in service_types
152
154
  _clear_cache(zeroconf_registrar)
153
- service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=0.5)
155
+ service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=LOOPBACK_FIND_TIMEOUT)
154
156
  assert discovery_type in service_types
155
157
 
156
158
  finally:
@@ -43,6 +43,8 @@ from zeroconf.asyncio import (
43
43
  from zeroconf.const import _LISTENER_TIME
44
44
 
45
45
  from . import (
46
+ LOOPBACK_FIND_TIMEOUT,
47
+ QUICK_REQUEST_TIMEOUT_MS,
46
48
  QuestionHistoryWithoutSuppression,
47
49
  _clear_cache,
48
50
  has_working_ipv6,
@@ -918,14 +920,20 @@ async def test_async_zeroconf_service_types(quick_timing: None) -> None:
918
920
  )
919
921
  task = await zeroconf_registrar.async_register_service(info)
920
922
  await task
921
- # Ensure we do not clear the cache until after the last broadcast is processed
922
- await asyncio.sleep(0.2)
923
+ # Wait for the last announce broadcast before clearing. With
924
+ # `quick_timing` the broadcasts use _REGISTER_TIME=10ms apart so
925
+ # 50ms is plenty.
926
+ await asyncio.sleep(0.05)
923
927
  _clear_cache(zeroconf_registrar.zeroconf)
924
928
  try:
925
- service_types = await AsyncZeroconfServiceTypes.async_find(interfaces=["127.0.0.1"], timeout=0.5)
929
+ service_types = await AsyncZeroconfServiceTypes.async_find(
930
+ interfaces=["127.0.0.1"], timeout=LOOPBACK_FIND_TIMEOUT
931
+ )
926
932
  assert type_ in service_types
927
933
  _clear_cache(zeroconf_registrar.zeroconf)
928
- service_types = await AsyncZeroconfServiceTypes.async_find(aiozc=zeroconf_registrar, timeout=0.5)
934
+ service_types = await AsyncZeroconfServiceTypes.async_find(
935
+ aiozc=zeroconf_registrar, timeout=LOOPBACK_FIND_TIMEOUT
936
+ )
929
937
  assert type_ in service_types
930
938
 
931
939
  finally:
@@ -1139,7 +1147,7 @@ async def test_integration(quick_timing: None) -> None:
1139
1147
 
1140
1148
 
1141
1149
  @pytest.mark.asyncio
1142
- async def test_info_asking_default_is_asking_qm_questions_after_the_first_qu():
1150
+ async def test_info_asking_default_is_asking_qm_questions_after_the_first_qu(quick_request_timing):
1143
1151
  """Verify the service info first question is QU and subsequent ones are QM questions."""
1144
1152
  type_ = "_quservice._tcp.local."
1145
1153
  aiozc = AsyncZeroconf(interfaces=["127.0.0.1"])
@@ -1182,11 +1190,11 @@ async def test_info_asking_default_is_asking_qm_questions_after_the_first_qu():
1182
1190
  # patch the zeroconf send
1183
1191
  with patch.object(zeroconf_info, "async_send", send):
1184
1192
  aiosinfo = AsyncServiceInfo(type_, registration_name)
1185
- # Patch _is_complete so we send multiple times. 500ms covers
1186
- # the QU query at 0ms plus the QM query at ~_LISTENER_TIME +
1187
- # max random delay (~320ms).
1193
+ # Patch _is_complete so we send multiple times. Under
1194
+ # `quick_request_timing` both the QU query at 0ms and the QM
1195
+ # query at ~15ms land well inside QUICK_REQUEST_TIMEOUT_MS.
1188
1196
  with patch("zeroconf.asyncio.AsyncServiceInfo._is_complete", False):
1189
- await aiosinfo.async_request(aiozc.zeroconf, 500)
1197
+ await aiosinfo.async_request(aiozc.zeroconf, QUICK_REQUEST_TIMEOUT_MS)
1190
1198
  try:
1191
1199
  assert first_outgoing.questions[0].unicast is True # type: ignore[union-attr]
1192
1200
  assert second_outgoing.questions[0].unicast is False # type: ignore[attr-defined]
@@ -219,7 +219,11 @@ class TestRegistrar(unittest.TestCase):
219
219
  for _ in range(50):
220
220
  time.sleep(0.02)
221
221
  info.load_from_cache(zc)
222
- if info.addresses:
222
+ # Wait for both A and TXT records — they arrive as separate
223
+ # cache updates and the listener may schedule the assertions
224
+ # between the two. Breaking on just `info.addresses` makes
225
+ # the test flaky under PyPy / skip_cython.
226
+ if info.addresses and info.properties:
223
227
  break
224
228
  assert info.addresses == [socket.inet_pton(socket.AF_INET, "1.2.3.4")]
225
229
  assert info.properties == {b"version": b"1.0"}
@@ -1011,6 +1011,28 @@ def test_label_compression_attack():
1011
1011
  assert len(parsed.answers()) == 1
1012
1012
 
1013
1013
 
1014
+ def test_dns_compression_pointer_chain_depth_attack() -> None:
1015
+ """Test our wire parser rejects deeply chained compression pointers without recursing."""
1016
+ # Build a packet with one question whose name is a 1500-deep chain of forward
1017
+ # compression pointers, ending in a root label. Each pointer is 2 bytes,
1018
+ # so chain length easily exceeds CPython's default recursion limit.
1019
+ header = b"\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00"
1020
+ # Question at offset 12: pointer to offset 18 (past the question's type/class).
1021
+ question_name = bytes([0xC0, 18])
1022
+ question_type_class = b"\x00\x01\x00\x01"
1023
+ chain_depth = 1500
1024
+ chain = bytearray()
1025
+ for i in range(chain_depth):
1026
+ target = 18 + 2 * (i + 1)
1027
+ chain.append(0xC0 | (target >> 8))
1028
+ chain.append(target & 0xFF)
1029
+ chain.append(0x00)
1030
+ packet = header + question_name + question_type_class + bytes(chain)
1031
+ parsed = r.DNSIncoming(packet, ("1.2.3.4", 5353))
1032
+ assert parsed.valid is False
1033
+ assert parsed.questions == []
1034
+
1035
+
1014
1036
  def test_dns_compression_loop_attack():
1015
1037
  """Test our wire parser does not loop forever when dns compression is in a loop."""
1016
1038
  packet = (
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes