Skip to content

Commit 1071664

Browse files
authored
Merge branch 'master' into test/add-blockbuster
2 parents f71bc55 + 3e5ac4f commit 1071664

13 files changed

Lines changed: 262 additions & 52 deletions

File tree

CHANGELOG.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,27 @@
22

33
<!-- version list -->
44

5+
## v0.149.13 (2026-05-20)
6+
7+
### Bug Fixes
8+
9+
- Bound record payload reads against rdlength overrun
10+
([#1756](https://github.com/python-zeroconf/python-zeroconf/pull/1756),
11+
[`5444495`](https://github.com/python-zeroconf/python-zeroconf/commit/544449596e645fcaad3834fa0cb614a54f847a82))
12+
13+
### Documentation
14+
15+
- Clarify LGPL-2.1-or-later license in README
16+
([#1763](https://github.com/python-zeroconf/python-zeroconf/pull/1763),
17+
[`28bb01f`](https://github.com/python-zeroconf/python-zeroconf/commit/28bb01f23951f7883d8c3af66b6d537c34c516c7))
18+
19+
### Refactoring
20+
21+
- Extract loopback Zeroconf fixtures and mock_incoming_msg helper
22+
([#1758](https://github.com/python-zeroconf/python-zeroconf/pull/1758),
23+
[`cb0af4a`](https://github.com/python-zeroconf/python-zeroconf/commit/cb0af4a8f4cdaad3721a2851d9fa17709d39ae62))
24+
25+
526
## v0.149.12 (2026-05-20)
627

728
### Bug Fixes

README.rst

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,4 +151,10 @@ Changelog
151151
License
152152
=======
153153

154-
LGPL, see COPYING file for details.
154+
GNU Lesser General Public License v2.1 or later (LGPL-2.1-or-later).
155+
156+
The full text of LGPL 2.1 is included in the `COPYING <COPYING>`_ file.
157+
You may, at your option, use this library under the terms of any later
158+
version of the LGPL published by the Free Software Foundation. The
159+
canonical SPDX identifier for this project is ``LGPL-2.1-or-later``, as
160+
declared in ``pyproject.toml``.

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
44

55
[project]
66
name = "zeroconf"
7-
version = "0.149.12"
7+
version = "0.149.13"
88
license = "LGPL-2.1-or-later"
99
description = "A pure python implementation of multicast DNS service discovery"
1010
readme = "README.rst"

src/zeroconf/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@
8888

8989
__author__ = "Paul Scott-Murphy, William McBrine"
9090
__maintainer__ = "Jakub Stasiak <jakub@stasiak.at>"
91-
__version__ = "0.149.12"
91+
__version__ = "0.149.13"
9292
__license__ = "LGPL"
9393

9494

src/zeroconf/_core.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,13 @@
104104

105105
_REGISTER_BROADCASTS = 3
106106

107+
# RFC 6762 §8.1 thundering-herd avoidance: wait a random
108+
# 0-250ms before the first probe so simultaneously-started
109+
# responders don't collide. We default to 150-250ms to
110+
# preserve existing timing assumptions; tests on loopback
111+
# may patch this lower via the `quick_timing` fixture.
112+
_PROBE_RANDOM_DELAY_INTERVAL = (150, 250) # ms
113+
107114

108115
def async_send_with_transport(
109116
log_debug: bool,
@@ -561,7 +568,7 @@ async def async_check_service(
561568

562569
# Wait a random amount of time up avoid collisions and avoid
563570
# a thundering herd when multiple services are started on the network
564-
await self.async_wait(random.randint(150, 250)) # noqa: S311
571+
await self.async_wait(random.randint(*_PROBE_RANDOM_DELAY_INTERVAL)) # noqa: S311
565572

566573
next_instance_number = 2
567574
next_time = now = current_time_millis()

src/zeroconf/_protocol/incoming.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,12 +254,27 @@ def _read_character_string(self) -> str:
254254
"""Reads a character string from the packet"""
255255
length = self.view[self.offset]
256256
self.offset += 1
257+
# Python slicing silently truncates when indices exceed the buffer,
258+
# but self.offset still advances by the declared length below; without
259+
# this check a record with an inflated character-string length would
260+
# land in the cache carrying a payload shorter than the wire claimed
261+
# and leave the parser pointed past _data_len for the next record.
262+
if self.offset + length > self._data_len:
263+
raise IncomingDecodeError(
264+
f"Character string length {length} at offset {self.offset} overruns "
265+
f"packet of {self._data_len} bytes from {self.source}"
266+
)
257267
info = self.data[self.offset : self.offset + length].decode("utf-8", "replace")
258268
self.offset += length
259269
return info
260270

261271
def _read_string(self, length: _int) -> bytes:
262272
"""Reads a string of a given length from the packet"""
273+
if self.offset + length > self._data_len:
274+
raise IncomingDecodeError(
275+
f"String length {length} at offset {self.offset} overruns "
276+
f"packet of {self._data_len} bytes from {self.source}"
277+
)
263278
info = self.data[self.offset : self.offset + length]
264279
self.offset += length
265280
return info
@@ -297,6 +312,19 @@ def _read_others(self) -> None:
297312
self.data,
298313
exc_info=True,
299314
)
315+
if rec is not None and self.offset != end:
316+
# The decoded record consumed a different number of bytes than
317+
# rdlength advertised. The record is built from a slice that
318+
# straddles its rdata boundary, so drop it and resync to the
319+
# declared end so the next record header lands aligned.
320+
log.debug(
321+
"Record for %s with type %s did not consume exactly rdlength=%d; dropping",
322+
domain,
323+
_TYPES.get(type_, type_),
324+
length,
325+
)
326+
self.offset = end
327+
rec = None
300328
if rec is not None:
301329
self._answers.append(rec)
302330

tests/conftest.py

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -140,32 +140,66 @@ def quick_timing() -> Generator[None]:
140140
"""Shorten the probe/announce/goodbye/first-query intervals for tests on loopback.
141141
142142
The production values (_CHECK_TIME=500ms, _REGISTER_TIME=225ms,
143-
_UNREGISTER_TIME=125ms, _FIRST_QUERY_DELAY_RANDOM_INTERVAL=20-120ms)
144-
exist for RFC 6762 interop on real networks (§8.1 thundering-herd
145-
avoidance for probing, §5.2 for the initial-query delay). Tests on
146-
127.0.0.1 do not need them and pay 1-2s per register/unregister
147-
cycle and 20-120ms per ServiceBrowser startup without this fixture.
148-
Opt in by adding `quick_timing` to a test's argument list.
143+
_UNREGISTER_TIME=125ms, _PROBE_RANDOM_DELAY_INTERVAL=150-250ms,
144+
_FIRST_QUERY_DELAY_RANDOM_INTERVAL=20-120ms) exist for RFC 6762
145+
interop on real networks (§8.1 thundering-herd avoidance for
146+
probing, §5.2 for the initial-query delay). Tests on 127.0.0.1
147+
do not need them and pay 1-2s per register/unregister cycle,
148+
150-250ms per probe, and 20-120ms per ServiceBrowser startup
149+
without this fixture. Opt in either by adding `quick_timing`
150+
to a test's argument list or via
151+
`@pytest.mark.usefixtures("quick_timing")` on the test or
152+
its class.
149153
"""
150154
with (
151155
patch.object(_core, "_CHECK_TIME", 10),
152156
patch.object(_core, "_REGISTER_TIME", 10),
153157
patch.object(_core, "_UNREGISTER_TIME", 10),
158+
patch.object(_core, "_PROBE_RANDOM_DELAY_INTERVAL", (1, 5)),
154159
patch.object(service_browser, "_FIRST_QUERY_DELAY_RANDOM_INTERVAL", (1, 5)),
155160
):
156161
yield
157162

158163

164+
@pytest.fixture
165+
def quick_aggregation_timing() -> Generator[None]:
166+
"""Scale multicast aggregation / network-protection delays 10x for tests.
167+
168+
The aggregation tests in `tests/test_handlers.py` verify timing-
169+
dependent behaviour of `MulticastOutgoingQueue`: aggregation window,
170+
network protection (~1s), and protected aggregation. The behaviour
171+
under test is a ratio of these constants — the exact wall-clock
172+
values are not the contract — so scaling them down and the test
173+
sleeps in lock-step preserves what is tested while dropping each
174+
test from ~3s to ~0.3s.
175+
176+
The patches must be in place before `AsyncZeroconf(...)` is
177+
constructed because `MulticastOutgoingQueue` reads the constants at
178+
init time and stashes them on the instance. The per-queue
179+
`_multicast_delay_random_min` / `_max` jitter (1-5ms here) can
180+
still be set on the queue instance after construction by the test
181+
itself — those slots are `cdef public` in the .pxd.
182+
"""
183+
with (
184+
patch.object(_core, "_AGGREGATION_DELAY", 50),
185+
patch.object(_core, "_PROTECTED_AGGREGATION_DELAY", 20),
186+
patch.object(_core, "_ONE_SECOND", 100),
187+
):
188+
yield
189+
190+
159191
@pytest.fixture
160192
def quick_request_timing() -> Generator[None]:
161193
"""Shorten the initial-query delay used by AsyncServiceInfo.async_request.
162194
163195
The 200ms `_LISTENER_TIME` and 20-120ms random jitter (RFC 6762
164196
§5.2) help spread queries from multiple clients on real networks.
165197
On loopback they're pure overhead — get_service_info-style tests
166-
wait ~250ms before the first query even fires. Opt in by adding
167-
`quick_request_timing` to a test's argument list, then drop the
168-
test's own timeouts (which had to accommodate that delay).
198+
wait ~250ms before the first query even fires. Opt in either by
199+
adding `quick_request_timing` to a test's argument list or via
200+
`@pytest.mark.usefixtures("quick_request_timing")` on the test
201+
or its class, then drop the test's own timeouts (which had to
202+
accommodate that delay).
169203
"""
170204
with (
171205
patch.object(service_info, "_LISTENER_TIME", 10),

tests/services/test_info.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ def test_service_info_rejects_expired_records(self):
251251

252252
@unittest.skipIf(not has_working_ipv6(), "Requires IPv6")
253253
@unittest.skipIf(os.environ.get("SKIP_IPV6"), "IPv6 tests disabled")
254+
@pytest.mark.usefixtures("quick_request_timing")
254255
def test_get_info_partial(self):
255256
zc = r.Zeroconf(interfaces=["127.0.0.1"])
256257

tests/test_asyncio.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -608,7 +608,7 @@ async def test_async_wait_unblocks_on_update(quick_timing: None) -> None:
608608

609609

610610
@pytest.mark.asyncio
611-
async def test_service_info_async_request(quick_timing: None) -> None:
611+
async def test_service_info_async_request(quick_timing: None, quick_request_timing: None) -> None:
612612
"""Test registering services broadcasts and query with AsyncServceInfo.async_request."""
613613
if not has_working_ipv6() or os.environ.get("SKIP_IPV6"):
614614
pytest.skip("Requires IPv6")
@@ -710,13 +710,13 @@ async def test_service_info_async_request(quick_timing: None) -> None:
710710
aiozc.zeroconf.out_delay_queue.queue.clear()
711711
aiosinfo = AsyncServiceInfo(type_, registration_name)
712712
_clear_cache(aiozc.zeroconf)
713-
# Generating the race condition is almost impossible
714-
# without patching since its a TOCTOU race. 1500ms covers
715-
# the initial _LISTENER_TIME + random delay (200-320ms) and
716-
# leaves plenty of margin for the loopback response to land
713+
# Generating the race condition is almost impossible without
714+
# patching since it's a TOCTOU race. Under `quick_request_timing`
715+
# the first QU query fires at ~10ms and the QM follow-up at ~15ms;
716+
# 300ms leaves plenty of margin for the loopback response to land
717717
# before the loop times out.
718718
with patch("zeroconf.asyncio.AsyncServiceInfo._is_complete", False):
719-
await aiosinfo.async_request(aiozc.zeroconf, 1500)
719+
await aiosinfo.async_request(aiozc.zeroconf, 300)
720720
assert aiosinfo is not None
721721
assert aiosinfo.addresses == [socket.inet_aton("10.0.1.3")]
722722

0 commit comments

Comments
 (0)