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
3 changes: 3 additions & 0 deletions src/zeroconf/_services/info.pxd
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ cdef class ServiceInfo(RecordUpdateListener):
)
cdef bint _process_record_threadsafe(self, object zc, DNSRecord record, double now)

@cython.locals(existing_idx=int, existing=object)
cdef bint _upsert_ipv6_address(self, object ip_addr)

@cython.locals(cache=DNSCache)
cdef cython.list _get_address_records_from_cache_by_type(self, object zc, object _type)

Expand Down
85 changes: 71 additions & 14 deletions src/zeroconf/_services/info.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

import asyncio
import random
from collections.abc import Sequence
from typing import TYPE_CHECKING, cast

from .._cache import DNSCache
Expand Down Expand Up @@ -108,6 +109,36 @@
from .._core import Zeroconf


def _index_of_same_address(
addresses: Sequence[ZeroconfIPv4Address | ZeroconfIPv6Address],
ip_addr: ZeroconfIPv4Address | ZeroconfIPv6Address,
) -> int:
"""Return the index of an existing entry with the same packed bytes, or -1.

Match by ``zc_integer`` so IPv6 addresses that differ only in
scope_id (one observed without scope on an IPv4 socket, another
observed with scope on an IPv6 socket) collapse to a single entry.
"""
target = ip_addr.zc_integer
for idx, existing in enumerate(addresses):
if existing.zc_integer == target:
return idx
return -1


def _has_more_scope_info(
new_addr: ZeroconfIPv4Address | ZeroconfIPv6Address,
existing: ZeroconfIPv4Address | ZeroconfIPv6Address,
) -> bool:
"""True if ``new_addr`` carries a scope_id the ``existing`` entry lacks."""
if new_addr.version != 6:
return False
if TYPE_CHECKING:
assert isinstance(new_addr, ZeroconfIPv6Address)
assert isinstance(existing, ZeroconfIPv6Address)
return new_addr.scope_id is not None and existing.scope_id is None


def instance_name_from_service_info(info: ServiceInfo, strict: bool = True) -> str:
"""Calculate the instance name from the ServiceInfo."""
# This is kind of funky because of the subtype based tests
Expand Down Expand Up @@ -453,11 +484,49 @@ def _get_ip_addresses_from_cache_lifo(
if record.is_expired(now):
continue
ip_addr = get_ip_address_object_from_record(record)
if ip_addr is not None and ip_addr not in address_list:
if ip_addr is None:
continue
# The cache keeps scoped and unscoped link-local AAAA
# records as separate entries because DNSAddress equality
# includes scope_id. Collapse them here so each address
# appears once; the scoped variant wins so callers of
# parsed_scoped_addresses() get a %<interface_index>-
# qualified link-local address when one was observed.
existing_idx = _index_of_same_address(address_list, ip_addr)
if existing_idx == -1:
address_list.append(ip_addr)
continue
# Move the re-seen address to the end so the later observation
# wins both in value (scope) and in LIFO position after reverse.
existing = address_list.pop(existing_idx)
address_list.append(ip_addr if _has_more_scope_info(ip_addr, existing) else existing)
address_list.reverse() # Reverse to get LIFO order
return address_list

def _upsert_ipv6_address(self, ip_addr: ZeroconfIPv6Address) -> bool:
"""Insert or update an IPv6 address in LIFO order.

Compares by integer (not IPv6Address equality, which respects
scope_id) so the same link-local address received first without
scope (IPv4 socket) and then with scope (IPv6 socket) collapses
to one entry. The scoped variant wins so parsed_scoped_addresses()
can return a qualified address.
"""
ipv6_addresses = self._ipv6_addresses
existing_idx = _index_of_same_address(ipv6_addresses, ip_addr)
if existing_idx == -1:
ipv6_addresses.insert(0, ip_addr)
return True
existing = ipv6_addresses[existing_idx]
if _has_more_scope_info(ip_addr, existing):
ipv6_addresses.pop(existing_idx)
ipv6_addresses.insert(0, ip_addr)
return True
if existing_idx != 0:
ipv6_addresses.pop(existing_idx)
ipv6_addresses.insert(0, existing)
return False

def _set_ipv6_addresses_from_cache(self, zc: Zeroconf, now: float_) -> None:
"""Set IPv6 addresses from the cache."""
if TYPE_CHECKING:
Expand Down Expand Up @@ -532,19 +601,7 @@ def _process_record_threadsafe(self, zc: Zeroconf, record: DNSRecord, now: float

if TYPE_CHECKING:
assert isinstance(ip_addr, ZeroconfIPv6Address)
ipv6_addresses = self._ipv6_addresses
if ip_addr not in self._ipv6_addresses:
ipv6_addresses.insert(0, ip_addr)
return True
# Use int() to compare the addresses as integers
# since by default IPv6Address.__eq__ compares the
# the addresses on version and int which more than
# we need here since we know the version is 6.
if ip_addr.zc_integer != self._ipv6_addresses[0].zc_integer:
ipv6_addresses.remove(ip_addr)
ipv6_addresses.insert(0, ip_addr)

return False
return self._upsert_ipv6_address(ip_addr)

if record_key != self.key:
return False
Expand Down
219 changes: 218 additions & 1 deletion tests/services/test_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@
from zeroconf import DNSAddress, RecordUpdate, const
from zeroconf._protocol.outgoing import DNSOutgoing
from zeroconf._services import info
from zeroconf._services.info import ServiceInfo
from zeroconf._services.info import ServiceInfo, _has_more_scope_info
from zeroconf._utils.ipaddress import ZeroconfIPv4Address
from zeroconf._utils.net import IPVersion
from zeroconf.asyncio import AsyncZeroconf

Expand Down Expand Up @@ -790,6 +791,222 @@ def test_scoped_addresses_from_cache():
zeroconf.close()


def test_scoped_address_preferred_when_unscoped_arrives_first_in_cache():
"""A scoped AAAA in the cache wins over an earlier unscoped copy of the same address."""
type_ = "_http._tcp.local."
registration_name = f"scoped-first.{type_}"
zeroconf = r.Zeroconf(interfaces=["127.0.0.1"])
host = "scoped-first.local."
packed = socket.inet_pton(socket.AF_INET6, "fe80::52e:c2f2:bc5f:e9c6")

zeroconf.cache.async_add_records(
[
r.DNSPointer(
type_,
const._TYPE_PTR,
const._CLASS_IN | const._CLASS_UNIQUE,
120,
registration_name,
),
r.DNSService(
registration_name,
const._TYPE_SRV,
const._CLASS_IN | const._CLASS_UNIQUE,
120,
0,
0,
80,
host,
),
r.DNSAddress(
host,
const._TYPE_AAAA,
const._CLASS_IN | const._CLASS_UNIQUE,
120,
packed,
scope_id=None,
),
r.DNSAddress(
host,
const._TYPE_AAAA,
const._CLASS_IN | const._CLASS_UNIQUE,
120,
packed,
scope_id=7,
),
]
)

info = ServiceInfo(type_, registration_name)
info.load_from_cache(zeroconf)
assert info.parsed_scoped_addresses() == ["fe80::52e:c2f2:bc5f:e9c6%7"]
assert info.ip_addresses_by_version(r.IPVersion.V6Only) == [ip_address("fe80::52e:c2f2:bc5f:e9c6%7")]
zeroconf.close()


@pytest.mark.asyncio
async def test_scoped_address_replaces_unscoped_in_live_update():
"""A late-arriving scoped AAAA replaces a previously-stored unscoped variant."""
type_ = "_http._tcp.local."
registration_name = f"scoped-live.{type_}"
aiozc = AsyncZeroconf(interfaces=["127.0.0.1"])
host = "scoped-live.local."
packed = socket.inet_pton(socket.AF_INET6, "fe80::52e:c2f2:bc5f:e9c6")

info = ServiceInfo(type_, registration_name, server=host)
now = r.current_time_millis()
unscoped = r.DNSAddress(
host,
const._TYPE_AAAA,
const._CLASS_IN | const._CLASS_UNIQUE,
120,
packed,
scope_id=None,
)
scoped = r.DNSAddress(
host,
const._TYPE_AAAA,
const._CLASS_IN | const._CLASS_UNIQUE,
120,
packed,
scope_id=9,
)
info.async_update_records(aiozc.zeroconf, now, [RecordUpdate(unscoped, None)])
assert info.parsed_scoped_addresses() == ["fe80::52e:c2f2:bc5f:e9c6"]
info.async_update_records(aiozc.zeroconf, now, [RecordUpdate(scoped, unscoped)])
assert info.parsed_scoped_addresses() == ["fe80::52e:c2f2:bc5f:e9c6%9"]
await aiozc.async_close()


def test_scoped_address_kept_when_unscoped_arrives_after_in_cache():
"""Scoped AAAA seen first in iteration keeps its scope when an unscoped duplicate follows."""
type_ = "_http._tcp.local."
registration_name = f"scoped-after.{type_}"
zeroconf = r.Zeroconf(interfaces=["127.0.0.1"])
host = "scoped-after.local."
packed = socket.inet_pton(socket.AF_INET6, "fe80::52e:c2f2:bc5f:e9c6")

zeroconf.cache.async_add_records(
[
r.DNSPointer(
type_,
const._TYPE_PTR,
const._CLASS_IN | const._CLASS_UNIQUE,
120,
registration_name,
),
r.DNSService(
registration_name,
const._TYPE_SRV,
const._CLASS_IN | const._CLASS_UNIQUE,
120,
0,
0,
80,
host,
),
r.DNSAddress(
host,
const._TYPE_AAAA,
const._CLASS_IN | const._CLASS_UNIQUE,
120,
packed,
scope_id=5,
),
r.DNSAddress(
host,
const._TYPE_AAAA,
const._CLASS_IN | const._CLASS_UNIQUE,
120,
packed,
scope_id=None,
),
]
)

info = ServiceInfo(type_, registration_name)
info.load_from_cache(zeroconf)
assert info.parsed_scoped_addresses() == ["fe80::52e:c2f2:bc5f:e9c6%5"]
assert info.ip_addresses_by_version(r.IPVersion.V6Only) == [ip_address("fe80::52e:c2f2:bc5f:e9c6%5")]
zeroconf.close()


def test_has_more_scope_info_returns_false_for_ipv4():
"""The scope_id helper short-circuits for IPv4 since A records carry no scope."""
ip4 = ZeroconfIPv4Address("192.0.2.1")
assert _has_more_scope_info(ip4, ip4) is False


def test_scope_upgrade_preserves_lifo_recency_order():
"""A scoped AAAA that upgrades an earlier entry becomes the most recent in LIFO order."""
type_ = "_http._tcp.local."
registration_name = f"reorder.{type_}"
zeroconf = r.Zeroconf(interfaces=["127.0.0.1"])
host = "reorder.local."
link_local = socket.inet_pton(socket.AF_INET6, "fe80::52e:c2f2:bc5f:e9c6")
ula = socket.inet_pton(socket.AF_INET6, "fdc8:d776:7cca:46ed::2")

zeroconf.cache.async_add_records(
[
r.DNSPointer(
type_,
const._TYPE_PTR,
const._CLASS_IN | const._CLASS_UNIQUE,
120,
registration_name,
),
r.DNSService(
registration_name,
const._TYPE_SRV,
const._CLASS_IN | const._CLASS_UNIQUE,
120,
0,
0,
80,
host,
),
r.DNSAddress(
host,
const._TYPE_AAAA,
const._CLASS_IN | const._CLASS_UNIQUE,
120,
link_local,
scope_id=None,
),
r.DNSAddress(
host,
const._TYPE_AAAA,
const._CLASS_IN | const._CLASS_UNIQUE,
120,
ula,
scope_id=None,
),
r.DNSAddress(
host,
const._TYPE_AAAA,
const._CLASS_IN | const._CLASS_UNIQUE,
120,
link_local,
scope_id=11,
),
]
)

info = ServiceInfo(type_, registration_name)
info.load_from_cache(zeroconf)
# The scoped link-local upgrade is the most recent observation, so it
# has to come first in LIFO order, ahead of the earlier unrelated ULA.
assert info.ip_addresses_by_version(r.IPVersion.V6Only) == [
ip_address("fe80::52e:c2f2:bc5f:e9c6%11"),
ip_address("fdc8:d776:7cca:46ed::2"),
]
assert info.parsed_scoped_addresses() == [
"fe80::52e:c2f2:bc5f:e9c6%11",
"fdc8:d776:7cca:46ed::2",
]
zeroconf.close()


# This test uses asyncio because it needs to access the cache directly
# which is not threadsafe
@pytest.mark.asyncio
Expand Down
Loading