Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/zeroconf/_services/browser.pxd
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ cdef bint TYPE_CHECKING
cdef object cached_possible_types
cdef cython.uint _EXPIRE_REFRESH_TIME_PERCENT, _MAX_MSG_TYPICAL, _DNS_PACKET_HEADER_LEN
cdef cython.uint _TYPE_PTR
cdef cython.uint _TYPE_NSEC
cdef object _CLASS_IN
cdef object SERVICE_STATE_CHANGE_ADDED, SERVICE_STATE_CHANGE_REMOVED, SERVICE_STATE_CHANGE_UPDATED
cdef cython.set _ADDRESS_RECORD_TYPES
Expand Down
8 changes: 7 additions & 1 deletion src/zeroconf/_services/browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
_MDNS_ADDR,
_MDNS_ADDR6,
_MDNS_PORT,
_TYPE_NSEC,
_TYPE_PTR,
)

Expand Down Expand Up @@ -678,7 +679,12 @@ def async_update_records(self, zc: Zeroconf, now: float_, records: list[RecordUp
old_record = record_update.old
record_type = record.type

if record_type is _TYPE_PTR:
# NSEC records assert non-existence of a record type
# (RFC 6762 §6.1); skip so we do not fire spurious updates.
if record_type == _TYPE_NSEC:
continue

if record_type == _TYPE_PTR:
if TYPE_CHECKING:
record = cast(DNSPointer, record)
pointer = record
Expand Down
67 changes: 67 additions & 0 deletions tests/services/test_browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -1085,6 +1085,73 @@ def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-de
zc.close()


def test_service_browser_nsec_record_does_not_trigger_update():
"""NSEC records assert non-existence and must not fire ServiceStateChange.Updated."""
zc = Zeroconf(interfaces=["127.0.0.1"])
type_ = "_hap._tcp.local."
registration_name = f"xxxyyy.{type_}"
callbacks: list[tuple[str, str, str]] = []
service_added = Event()

class MyServiceListener(r.ServiceListener):
def add_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def]
if name == registration_name:
callbacks.append(("add", type_, name))
service_added.set()

def remove_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def]
if name == registration_name:
callbacks.append(("remove", type_, name))

def update_service(self, zc, type_, name) -> None: # type: ignore[no-untyped-def]
if name == registration_name:
callbacks.append(("update", type_, name))

listener = MyServiceListener()
browser = r.ServiceBrowser(zc, type_, None, listener)
try:
desc = {"path": "/~paulsm/"}
address = socket.inet_aton("10.0.1.2")
info = ServiceInfo(type_, registration_name, 80, 0, 0, desc, "ash-2.local.", addresses=[address])

_inject_response(
zc,
mock_incoming_msg(
[
info.dns_pointer(),
info.dns_service(),
info.dns_text(),
*info.dns_addresses(),
]
),
)
assert service_added.wait(timeout=5), "add_service callback never fired"

# NSEC inject runs synchronously through the event loop; once
# _inject_response returns, async_update_records has already
# decided not to enqueue a callback for the NSEC record.
_inject_response(
zc,
mock_incoming_msg(
[
r.DNSNsec(
registration_name,
const._TYPE_NSEC,
const._CLASS_IN | const._CLASS_UNIQUE,
const._DNS_OTHER_TTL,
registration_name,
[const._TYPE_AAAA],
),
]
),
)

assert callbacks == [("add", type_, registration_name)]
finally:
browser.cancel()
zc.close()


def test_service_browser_uses_non_strict_names():
"""Verify we can look for technically invalid names as we cannot change what others do."""

Expand Down
Loading