zeroconf 0.149.7__tar.gz → 0.149.9__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.
Files changed (100) hide show
  1. {zeroconf-0.149.7 → zeroconf-0.149.9}/CHANGELOG.md +32 -0
  2. {zeroconf-0.149.7 → zeroconf-0.149.9}/PKG-INFO +1 -1
  3. {zeroconf-0.149.7 → zeroconf-0.149.9}/pyproject.toml +4 -4
  4. {zeroconf-0.149.7 → zeroconf-0.149.9}/setup.py +1 -1
  5. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/__init__.py +1 -1
  6. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_history.pxd +5 -1
  7. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_history.py +19 -1
  8. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_protocol/incoming.pxd +1 -0
  9. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_protocol/incoming.py +14 -0
  10. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/const.py +6 -0
  11. {zeroconf-0.149.7 → zeroconf-0.149.9}/tests/__init__.py +14 -1
  12. {zeroconf-0.149.7 → zeroconf-0.149.9}/tests/services/test_types.py +3 -3
  13. {zeroconf-0.149.7 → zeroconf-0.149.9}/tests/test_handlers.py +6 -6
  14. {zeroconf-0.149.7 → zeroconf-0.149.9}/tests/test_history.py +55 -0
  15. {zeroconf-0.149.7 → zeroconf-0.149.9}/tests/test_protocol.py +79 -0
  16. {zeroconf-0.149.7 → zeroconf-0.149.9}/COPYING +0 -0
  17. {zeroconf-0.149.7 → zeroconf-0.149.9}/README.rst +0 -0
  18. {zeroconf-0.149.7 → zeroconf-0.149.9}/build_ext.py +0 -0
  19. {zeroconf-0.149.7 → zeroconf-0.149.9}/docs/Makefile +0 -0
  20. {zeroconf-0.149.7 → zeroconf-0.149.9}/docs/_ext/zeroconfautodocfix.py +0 -0
  21. {zeroconf-0.149.7 → zeroconf-0.149.9}/docs/api.rst +0 -0
  22. {zeroconf-0.149.7 → zeroconf-0.149.9}/docs/conf.py +0 -0
  23. {zeroconf-0.149.7 → zeroconf-0.149.9}/docs/index.rst +0 -0
  24. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_cache.pxd +0 -0
  25. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_cache.py +0 -0
  26. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_core.py +0 -0
  27. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_dns.pxd +0 -0
  28. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_dns.py +0 -0
  29. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_engine.py +0 -0
  30. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_exceptions.py +0 -0
  31. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_handlers/__init__.py +0 -0
  32. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_handlers/answers.pxd +0 -0
  33. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_handlers/answers.py +0 -0
  34. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_handlers/multicast_outgoing_queue.pxd +0 -0
  35. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_handlers/multicast_outgoing_queue.py +0 -0
  36. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_handlers/query_handler.pxd +0 -0
  37. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_handlers/query_handler.py +0 -0
  38. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_handlers/record_manager.pxd +0 -0
  39. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_handlers/record_manager.py +0 -0
  40. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_listener.pxd +0 -0
  41. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_listener.py +0 -0
  42. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_logger.py +0 -0
  43. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_protocol/__init__.py +0 -0
  44. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_protocol/outgoing.pxd +0 -0
  45. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_protocol/outgoing.py +0 -0
  46. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_record_update.pxd +0 -0
  47. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_record_update.py +0 -0
  48. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_services/__init__.pxd +0 -0
  49. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_services/__init__.py +0 -0
  50. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_services/browser.pxd +0 -0
  51. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_services/browser.py +0 -0
  52. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_services/info.pxd +0 -0
  53. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_services/info.py +0 -0
  54. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_services/registry.pxd +0 -0
  55. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_services/registry.py +0 -0
  56. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_services/types.py +0 -0
  57. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_transport.py +0 -0
  58. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_updates.pxd +0 -0
  59. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_updates.py +0 -0
  60. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_utils/__init__.py +0 -0
  61. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_utils/asyncio.py +0 -0
  62. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_utils/ipaddress.pxd +0 -0
  63. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_utils/ipaddress.py +0 -0
  64. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_utils/name.py +0 -0
  65. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_utils/net.py +0 -0
  66. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_utils/time.pxd +0 -0
  67. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/_utils/time.py +0 -0
  68. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/asyncio.py +0 -0
  69. {zeroconf-0.149.7 → zeroconf-0.149.9}/src/zeroconf/py.typed +0 -0
  70. {zeroconf-0.149.7 → zeroconf-0.149.9}/tests/benchmarks/__init__.py +0 -0
  71. {zeroconf-0.149.7 → zeroconf-0.149.9}/tests/benchmarks/helpers.py +0 -0
  72. {zeroconf-0.149.7 → zeroconf-0.149.9}/tests/benchmarks/test_cache.py +0 -0
  73. {zeroconf-0.149.7 → zeroconf-0.149.9}/tests/benchmarks/test_cache_bound.py +0 -0
  74. {zeroconf-0.149.7 → zeroconf-0.149.9}/tests/benchmarks/test_incoming.py +0 -0
  75. {zeroconf-0.149.7 → zeroconf-0.149.9}/tests/benchmarks/test_mark_seen.py +0 -0
  76. {zeroconf-0.149.7 → zeroconf-0.149.9}/tests/benchmarks/test_outgoing.py +0 -0
  77. {zeroconf-0.149.7 → zeroconf-0.149.9}/tests/benchmarks/test_send.py +0 -0
  78. {zeroconf-0.149.7 → zeroconf-0.149.9}/tests/benchmarks/test_txt_properties.py +0 -0
  79. {zeroconf-0.149.7 → zeroconf-0.149.9}/tests/conftest.py +0 -0
  80. {zeroconf-0.149.7 → zeroconf-0.149.9}/tests/services/__init__.py +0 -0
  81. {zeroconf-0.149.7 → zeroconf-0.149.9}/tests/services/test_browser.py +0 -0
  82. {zeroconf-0.149.7 → zeroconf-0.149.9}/tests/services/test_info.py +0 -0
  83. {zeroconf-0.149.7 → zeroconf-0.149.9}/tests/services/test_registry.py +0 -0
  84. {zeroconf-0.149.7 → zeroconf-0.149.9}/tests/test_asyncio.py +0 -0
  85. {zeroconf-0.149.7 → zeroconf-0.149.9}/tests/test_cache.py +0 -0
  86. {zeroconf-0.149.7 → zeroconf-0.149.9}/tests/test_circular_imports.py +0 -0
  87. {zeroconf-0.149.7 → zeroconf-0.149.9}/tests/test_core.py +0 -0
  88. {zeroconf-0.149.7 → zeroconf-0.149.9}/tests/test_dns.py +0 -0
  89. {zeroconf-0.149.7 → zeroconf-0.149.9}/tests/test_engine.py +0 -0
  90. {zeroconf-0.149.7 → zeroconf-0.149.9}/tests/test_exceptions.py +0 -0
  91. {zeroconf-0.149.7 → zeroconf-0.149.9}/tests/test_init.py +0 -0
  92. {zeroconf-0.149.7 → zeroconf-0.149.9}/tests/test_listener.py +0 -0
  93. {zeroconf-0.149.7 → zeroconf-0.149.9}/tests/test_logger.py +0 -0
  94. {zeroconf-0.149.7 → zeroconf-0.149.9}/tests/test_services.py +0 -0
  95. {zeroconf-0.149.7 → zeroconf-0.149.9}/tests/test_updates.py +0 -0
  96. {zeroconf-0.149.7 → zeroconf-0.149.9}/tests/utils/__init__.py +0 -0
  97. {zeroconf-0.149.7 → zeroconf-0.149.9}/tests/utils/test_asyncio.py +0 -0
  98. {zeroconf-0.149.7 → zeroconf-0.149.9}/tests/utils/test_ipaddress.py +0 -0
  99. {zeroconf-0.149.7 → zeroconf-0.149.9}/tests/utils/test_name.py +0 -0
  100. {zeroconf-0.149.7 → zeroconf-0.149.9}/tests/utils/test_net.py +0 -0
@@ -2,6 +2,38 @@
2
2
 
3
3
  <!-- version list -->
4
4
 
5
+ ## v0.149.9 (2026-05-20)
6
+
7
+ ### Bug Fixes
8
+
9
+ - Bound QuestionHistory size to prevent LAN-driven OOM
10
+ ([#1733](https://github.com/python-zeroconf/python-zeroconf/pull/1733),
11
+ [`0e5e637`](https://github.com/python-zeroconf/python-zeroconf/commit/0e5e637172ab7991e8e1f13be7e4e5d228ce8b8b))
12
+
13
+
14
+ ## v0.149.8 (2026-05-19)
15
+
16
+ ### Bug Fixes
17
+
18
+ - Bound NSEC bitmap length against record end
19
+ ([#1731](https://github.com/python-zeroconf/python-zeroconf/pull/1731),
20
+ [`1d83550`](https://github.com/python-zeroconf/python-zeroconf/commit/1d83550c58ed0ea69b611a907cd4bdfcb2eef535))
21
+
22
+ ### Testing
23
+
24
+ - Give IPv6-only loopback find() its own timeout
25
+ ([#1721](https://github.com/python-zeroconf/python-zeroconf/pull/1721),
26
+ [`fcd1ffb`](https://github.com/python-zeroconf/python-zeroconf/commit/fcd1ffb4af9b3b26bc0ecae30641251a4c9a4ba6))
27
+
28
+ - Widen LOOPBACK_FIND_TIMEOUT under PyPy
29
+ ([#1742](https://github.com/python-zeroconf/python-zeroconf/pull/1742),
30
+ [`6924606`](https://github.com/python-zeroconf/python-zeroconf/commit/69246065085fa25f9baf20abbf0eaddcb4a4d88c))
31
+
32
+ - Widen safety margin in test_response_aggregation_timings_multiple
33
+ ([#1737](https://github.com/python-zeroconf/python-zeroconf/pull/1737),
34
+ [`228af17`](https://github.com/python-zeroconf/python-zeroconf/commit/228af178657a70dd2e76420a8187345de6ede46f))
35
+
36
+
5
37
  ## v0.149.7 (2026-05-18)
6
38
 
7
39
  ### Bug Fixes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: zeroconf
3
- Version: 0.149.7
3
+ Version: 0.149.9
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.7"
7
+ version = "0.149.9"
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"
@@ -84,13 +84,13 @@ python = "^3.10"
84
84
  ifaddr = ">=0.1.7"
85
85
 
86
86
  [tool.poetry.group.dev.dependencies]
87
- pytest = ">=7.2,<9.0"
87
+ pytest = ">=9.0.3,<10.0"
88
88
  pytest-cov = ">=4,<8"
89
- pytest-asyncio = ">=0.20.3,<1.3.0"
89
+ pytest-asyncio = ">=1.3.0,<1.4.0"
90
90
  cython = "^3.2.4"
91
91
  setuptools = ">=65.6.3,<83.0.0"
92
92
  pytest-timeout = "^2.1.0"
93
- pytest-codspeed = ">=4.5.0,<5.0"
93
+ pytest-codspeed = ">=5.0.2,<6.0"
94
94
 
95
95
  [tool.poetry.group.docs.dependencies]
96
96
  sphinx = "^7.4.7 || ^8.1.3"
@@ -19,7 +19,7 @@ install_requires = \
19
19
 
20
20
  setup_kwargs = {
21
21
  'name': 'zeroconf',
22
- 'version': '0.149.7',
22
+ 'version': '0.149.9',
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.7"
91
+ __version__ = "0.149.9"
92
92
  __license__ = "LGPL"
93
93
 
94
94
 
@@ -4,13 +4,17 @@ from ._dns cimport DNSQuestion
4
4
 
5
5
 
6
6
  cdef cython.double _DUPLICATE_QUESTION_INTERVAL
7
+ cdef unsigned int _MAX_QUESTION_HISTORY_ENTRIES
7
8
 
8
9
  cdef class QuestionHistory:
9
10
 
10
- cdef cython.dict _history
11
+ cdef public cython.dict _history
11
12
 
12
13
  cpdef void add_question_at_time(self, DNSQuestion question, double now, cython.set known_answers)
13
14
 
15
+ @cython.locals(oldest=DNSQuestion, oldest_entry=cython.tuple, oldest_than=double)
16
+ cdef void _evict_to_make_room(self, double now)
17
+
14
18
  @cython.locals(than=double, previous_question=cython.tuple, previous_known_answers=cython.set)
15
19
  cpdef bint suppresses(self, DNSQuestion question, double now, cython.set known_answers)
16
20
 
@@ -23,7 +23,7 @@ USA
23
23
  from __future__ import annotations
24
24
 
25
25
  from ._dns import DNSQuestion, DNSRecord
26
- from .const import _DUPLICATE_QUESTION_INTERVAL
26
+ from .const import _DUPLICATE_QUESTION_INTERVAL, _MAX_QUESTION_HISTORY_ENTRIES
27
27
 
28
28
  # The QuestionHistory is used to implement Duplicate Question Suppression
29
29
  # https://datatracker.ietf.org/doc/html/rfc6762#section-7.3
@@ -40,6 +40,8 @@ class QuestionHistory:
40
40
 
41
41
  def add_question_at_time(self, question: DNSQuestion, now: _float, known_answers: set[DNSRecord]) -> None:
42
42
  """Remember a question with known answers."""
43
+ if question not in self._history and len(self._history) >= _MAX_QUESTION_HISTORY_ENTRIES:
44
+ self._evict_to_make_room(now)
43
45
  self._history[question] = (now, known_answers)
44
46
 
45
47
  def suppresses(self, question: DNSQuestion, now: _float, known_answers: set[DNSRecord]) -> bool:
@@ -75,3 +77,19 @@ class QuestionHistory:
75
77
  def clear(self) -> None:
76
78
  """Clear the history."""
77
79
  self._history.clear()
80
+
81
+ def _evict_to_make_room(self, now: _float) -> None:
82
+ """Drop expired or oldest entries when the history is at cap.
83
+
84
+ Peeks at the oldest insertion (dict is ordered) — only runs the
85
+ full O(n) async_expire sweep if it could actually reclaim
86
+ something, else a sustained flood at cap turns each insert into
87
+ a wasted scan. Falls back to oldest-first eviction.
88
+ """
89
+ oldest = next(iter(self._history))
90
+ oldest_entry = self._history[oldest]
91
+ oldest_than = oldest_entry[0]
92
+ if now - oldest_than > _DUPLICATE_QUESTION_INTERVAL:
93
+ self.async_expire(now)
94
+ while len(self._history) >= _MAX_QUESTION_HISTORY_ENTRIES:
95
+ del self._history[next(iter(self._history))]
@@ -128,6 +128,7 @@ cdef class DNSIncoming:
128
128
  byte="unsigned int",
129
129
  i="unsigned int",
130
130
  bitmap_length="unsigned int",
131
+ bitmap_end="unsigned int",
131
132
  )
132
133
  cdef list _read_bitmap(self, unsigned int end)
133
134
 
@@ -388,9 +388,23 @@ class DNSIncoming:
388
388
  offset = self.offset
389
389
  offset_plus_one = offset + 1
390
390
  offset_plus_two = offset + 2
391
+ # RFC 4034 §4.1.2: each window block is window-number byte +
392
+ # bitmap-length byte (1..32) + bitmap. A bitmap_length that walks
393
+ # past the record's declared end would otherwise leave self.offset
394
+ # pointing inside (or past) the next record header, corrupting
395
+ # every subsequent record in the same packet.
396
+ if offset_plus_two > end:
397
+ raise IncomingDecodeError(
398
+ f"NSEC bitmap window header truncated at offset {offset} from {self.source}"
399
+ )
391
400
  window = view[offset]
392
401
  bitmap_length = view[offset_plus_one]
393
402
  bitmap_end = offset_plus_two + bitmap_length
403
+ if bitmap_length == 0 or bitmap_length > 32 or bitmap_end > end:
404
+ raise IncomingDecodeError(
405
+ f"NSEC bitmap length {bitmap_length} invalid or overruns record end "
406
+ f"at offset {offset} from {self.source}"
407
+ )
394
408
  for i, byte in enumerate(self.data[offset_plus_two:bitmap_end]):
395
409
  for bit in range(8):
396
410
  if byte & (0x80 >> bit):
@@ -65,6 +65,12 @@ _DNS_PTR_MIN_TTL = 1125
65
65
  # to retain by multicasting many unique-name records.
66
66
  _MAX_CACHE_RECORDS = 10000
67
67
 
68
+ # Upper bound on the number of entries QuestionHistory will hold between
69
+ # the periodic 10s cache-cleanup ticks. Bounds the memory a malicious LAN
70
+ # peer can force the duplicate-question-suppression history to retain by
71
+ # flooding distinct questions (RFC 6762 §7.3, defense-in-depth).
72
+ _MAX_QUESTION_HISTORY_ENTRIES = 10000
73
+
68
74
  _DNS_PACKET_HEADER_LEN = 12
69
75
 
70
76
  _MAX_MSG_TYPICAL = 1460 # unused
@@ -23,6 +23,7 @@ USA
23
23
  from __future__ import annotations
24
24
 
25
25
  import asyncio
26
+ import platform
26
27
  import socket
27
28
  import time
28
29
  from functools import cache
@@ -35,6 +36,8 @@ from zeroconf._history import QuestionHistory
35
36
 
36
37
  _MONOTONIC_RESOLUTION = time.get_clock_info("monotonic").resolution
37
38
 
39
+ _IS_PYPY = platform.python_implementation() == "PyPy"
40
+
38
41
  # get_service_info / async_request timeout for tests using the
39
42
  # `quick_request_timing` fixture. The fixture cuts the initial-query
40
43
  # delay to ~15ms (10ms _LISTENER_TIME + 1-5ms jitter), so 50ms is
@@ -49,7 +52,17 @@ QUICK_REQUEST_TIMEOUT_MS = 50
49
52
  # the `quick_timing` fixture, which shrinks the browser's first-query
50
53
  # delay from RFC 6762 §5.2's 20-120ms window to 1-5ms; with that shave
51
54
  # the registrar's response lands inside ~10ms and 75ms is ~7x headroom.
52
- LOOPBACK_FIND_TIMEOUT = 0.075
55
+ # PyPy's JIT is still warming up the first time this path runs early in
56
+ # the suite, so the round trip is too slow for 75ms; give it more room.
57
+ LOOPBACK_FIND_TIMEOUT = 0.3 if _IS_PYPY else 0.075
58
+
59
+ # IPv6-only `find()` on Linux GitHub runners can hit `[Errno 101] Network
60
+ # is unreachable` on the `::1` socket and falls back to the `fe80::` link-
61
+ # local interface, which adds latency the IPv4 loopback path never pays.
62
+ # PyPy widens that further with JIT warmup. The 75ms budget that works on
63
+ # IPv4 loopback is too tight for the V6Only path under those conditions
64
+ # — give it more headroom.
65
+ IPV6_LOOPBACK_FIND_TIMEOUT = 0.5
53
66
 
54
67
 
55
68
  class QuestionHistoryWithoutSuppression(QuestionHistory):
@@ -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 LOOPBACK_FIND_TIMEOUT, _clear_cache, has_working_ipv6
14
+ from .. import IPV6_LOOPBACK_FIND_TIMEOUT, LOOPBACK_FIND_TIMEOUT, _clear_cache, has_working_ipv6
15
15
 
16
16
  log = logging.getLogger("zeroconf")
17
17
  original_logging_level = logging.NOTSET
@@ -116,11 +116,11 @@ def test_integration_with_listener_ipv6(quick_timing, disable_duplicate_packet_s
116
116
  zeroconf_registrar.registry.async_add(info)
117
117
  try:
118
118
  service_types = ZeroconfServiceTypes.find(
119
- ip_version=r.IPVersion.V6Only, timeout=LOOPBACK_FIND_TIMEOUT
119
+ ip_version=r.IPVersion.V6Only, timeout=IPV6_LOOPBACK_FIND_TIMEOUT
120
120
  )
121
121
  assert type_ in service_types
122
122
  _clear_cache(zeroconf_registrar)
123
- service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=LOOPBACK_FIND_TIMEOUT)
123
+ service_types = ZeroconfServiceTypes.find(zc=zeroconf_registrar, timeout=IPV6_LOOPBACK_FIND_TIMEOUT)
124
124
  assert type_ in service_types
125
125
 
126
126
  finally:
@@ -1783,19 +1783,19 @@ async def test_response_aggregation_timings_multiple(run_isolated, disable_dupli
1783
1783
  protocol.last_time = 0 # manually reset the last time to avoid duplicate packet suppression
1784
1784
  protocol.datagram_received(query2.packets()[0], ("127.0.0.1", const._MDNS_PORT))
1785
1785
  protocol.last_time = 0 # manually reset the last time to avoid duplicate packet suppression
1786
- # The delay should increase with two packets and
1787
- # 900ms is beyond the maximum aggregation delay
1788
- # when there is no network protection delay
1789
- await asyncio.sleep(0.9)
1786
+ # The minimum protected send_after is 1000ms + 20ms random; sleep
1787
+ # well under that so coarse timers on slow runners cannot push the
1788
+ # send into this window and flake the assertion.
1789
+ await asyncio.sleep(0.5)
1790
1790
  calls = send_mock.mock_calls
1791
1791
  assert len(calls) == 0
1792
1792
 
1793
1793
  # 1000ms (1s network protection delays)
1794
- # - 900ms (already slept)
1794
+ # - 500ms (already slept)
1795
1795
  # + 120ms (maximum random delay)
1796
1796
  # + 200ms (maximum protected aggregation delay)
1797
1797
  # + 20ms (execution time)
1798
- await asyncio.sleep(millis_to_seconds(1000 - 900 + 120 + 200 + 20))
1798
+ await asyncio.sleep(millis_to_seconds(1000 - 500 + 120 + 200 + 20))
1799
1799
  calls = send_mock.mock_calls
1800
1800
  assert len(calls) == 1
1801
1801
  outgoing = send_mock.call_args[0][0]
@@ -78,3 +78,58 @@ def test_question_expire():
78
78
 
79
79
  # Verify the question not longer suppressed since the cache has expired
80
80
  assert not history.suppresses(question, now, other_known_answers)
81
+
82
+
83
+ def test_question_history_bounded():
84
+ """History keeps a hard cap so a LAN flood cannot grow it without bound."""
85
+ history = QuestionHistory()
86
+ now = r.current_time_millis()
87
+ answers: set[r.DNSRecord] = set()
88
+
89
+ cap = const._MAX_QUESTION_HISTORY_ENTRIES
90
+ for i in range(cap + 500):
91
+ q = r.DNSQuestion(f"_svc{i}._tcp.local.", const._TYPE_PTR, const._CLASS_IN)
92
+ history.add_question_at_time(q, now, answers)
93
+
94
+ assert len(history._history) <= cap
95
+
96
+
97
+ def test_question_history_evicts_oldest_first():
98
+ """When at cap, the oldest insertion is dropped first."""
99
+ history = QuestionHistory()
100
+ now = r.current_time_millis()
101
+ answers: set[r.DNSRecord] = set()
102
+
103
+ cap = const._MAX_QUESTION_HISTORY_ENTRIES
104
+ first = r.DNSQuestion("_first._tcp.local.", const._TYPE_PTR, const._CLASS_IN)
105
+ history.add_question_at_time(first, now, answers)
106
+
107
+ # Add `cap` more fresh, non-expired entries — one past the cap — so the
108
+ # final insertion forces oldest-first eviction of `first`.
109
+ for i in range(cap):
110
+ q = r.DNSQuestion(f"_svc{i}._tcp.local.", const._TYPE_PTR, const._CLASS_IN)
111
+ history.add_question_at_time(q, now, answers)
112
+
113
+ assert first not in history._history
114
+ assert len(history._history) <= cap
115
+
116
+
117
+ def test_question_history_opportunistic_expire():
118
+ """Adding past the cap first drops expired entries before evicting fresh ones."""
119
+ history = QuestionHistory()
120
+ old = r.current_time_millis()
121
+ answers: set[r.DNSRecord] = set()
122
+
123
+ cap = const._MAX_QUESTION_HISTORY_ENTRIES
124
+ for i in range(cap):
125
+ q = r.DNSQuestion(f"_stale{i}._tcp.local.", const._TYPE_PTR, const._CLASS_IN)
126
+ history.add_question_at_time(q, old, answers)
127
+
128
+ # All prior entries are now stale (>999ms old). Adding one more should
129
+ # trigger opportunistic expiry rather than evicting only the oldest one.
130
+ fresh_now = old + const._DUPLICATE_QUESTION_INTERVAL + 1
131
+ fresh = r.DNSQuestion("_fresh._tcp.local.", const._TYPE_PTR, const._CLASS_IN)
132
+ history.add_question_at_time(fresh, fresh_now, answers)
133
+
134
+ assert fresh in history._history
135
+ assert len(history._history) == 1
@@ -807,6 +807,85 @@ def test_parse_packet_with_nsec_record():
807
807
  assert nsec_record.next_name == "MyHome54 (2)._meshcop._udp.local."
808
808
 
809
809
 
810
+ def test_nsec_bitmap_length_overruns_record_end():
811
+ """Reject NSEC bitmap whose declared length runs past the record boundary."""
812
+ # 0 questions, 2 answers. Answer 1 is a malformed NSEC: rdlength=9, but the
813
+ # bitmap window claims length=255 — overrunning the record. Answer 2 is a
814
+ # PTR that must still parse because the offset for the next record stays
815
+ # pinned to the NSEC's declared end.
816
+ packet = (
817
+ b"\x00\x00\x84\x00\x00\x00\x00\x02\x00\x00\x00\x00"
818
+ b"\x04test\x05local\x00"
819
+ b"\x00\x2f\x80\x01"
820
+ b"\x00\x00\x11\x94"
821
+ b"\x00\x09"
822
+ b"\xc0\x0c"
823
+ b"\x00\xff"
824
+ b"\x80\x00\x00\x00\x00"
825
+ b"\xc0\x0c"
826
+ b"\x00\x0c\x00\x01"
827
+ b"\x00\x00\x11\x94"
828
+ b"\x00\x02"
829
+ b"\xc0\x0c"
830
+ )
831
+ parsed = r.DNSIncoming(packet)
832
+ answers = parsed.answers()
833
+ ptrs = [a for a in answers if isinstance(a, r.DNSPointer)]
834
+ assert len(ptrs) == 1
835
+ assert ptrs[0].alias == "test.local."
836
+ # The malformed NSEC must not surface — if it did, it would carry rdtypes
837
+ # synthesized from bytes past the record boundary.
838
+ assert not any(isinstance(a, r.DNSNsec) for a in answers)
839
+
840
+
841
+ def test_nsec_bitmap_zero_length_window_rejected():
842
+ """A bitmap window with length=0 violates RFC 4034 §4.1.2 and must be rejected."""
843
+ packet = (
844
+ b"\x00\x00\x84\x00\x00\x00\x00\x02\x00\x00\x00\x00"
845
+ b"\x04test\x05local\x00"
846
+ b"\x00\x2f\x80\x01"
847
+ b"\x00\x00\x11\x94"
848
+ b"\x00\x04"
849
+ b"\xc0\x0c"
850
+ b"\x00\x00"
851
+ b"\xc0\x0c"
852
+ b"\x00\x0c\x00\x01"
853
+ b"\x00\x00\x11\x94"
854
+ b"\x00\x02"
855
+ b"\xc0\x0c"
856
+ )
857
+ parsed = r.DNSIncoming(packet)
858
+ answers = parsed.answers()
859
+ ptrs = [a for a in answers if isinstance(a, r.DNSPointer)]
860
+ assert len(ptrs) == 1
861
+ assert not any(isinstance(a, r.DNSNsec) for a in answers)
862
+
863
+
864
+ def test_nsec_bitmap_truncated_window_header_rejected():
865
+ """Reject NSEC bitmap with a trailing byte too short to hold a window header."""
866
+ packet = (
867
+ b"\x00\x00\x84\x00\x00\x00\x00\x02\x00\x00\x00\x00"
868
+ b"\x04test\x05local\x00"
869
+ b"\x00\x2f\x80\x01"
870
+ b"\x00\x00\x11\x94"
871
+ b"\x00\x06"
872
+ b"\xc0\x0c"
873
+ b"\x00\x01\x80"
874
+ b"\xff"
875
+ b"\xc0\x0c"
876
+ b"\x00\x0c\x00\x01"
877
+ b"\x00\x00\x11\x94"
878
+ b"\x00\x02"
879
+ b"\xc0\x0c"
880
+ )
881
+ parsed = r.DNSIncoming(packet)
882
+ answers = parsed.answers()
883
+ ptrs = [a for a in answers if isinstance(a, r.DNSPointer)]
884
+ assert len(ptrs) == 1
885
+ assert ptrs[0].alias == "test.local."
886
+ assert not any(isinstance(a, r.DNSNsec) for a in answers)
887
+
888
+
810
889
  def test_records_same_packet_share_fate():
811
890
  """Test records in the same packet all have the same created time."""
812
891
  out = r.DNSOutgoing(const._FLAGS_QR_QUERY | const._FLAGS_AA)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes