Skip to content

Align CIDR containment and bounds with CIDRv4 and CIDRv6 - #827

Open
vyncint wants to merge 2 commits into
apple:mainfrom
vyncint:fix/cidr-contains-masks-network
Open

Align CIDR containment and bounds with CIDRv4 and CIDRv6#827
vyncint wants to merge 2 commits into
apple:mainfrom
vyncint:fix/cidr-contains-masks-network

Conversation

@vyncint

@vyncint vyncint commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Fixes #826.

What

CIDR keeps the address exactly as parsed, so a block written from a host address keeps its host bits set. contains compared that unmasked stored value against the masked probe, so the wrapper reported that a block excluded its own members while the types it wraps answered the same query correctly.

expression before CIDRv4 / CIDRv6
192.168.1.100/24 contains 192.168.1.100 false true
192.168.1.100/24 contains 192.168.1.0 false true
10.1.2.3/16 contains 10.1.99.99 false true
2001:db8::1234/64 contains 2001:db8::1 false true

Masking both sides matches what CIDRv4.contains and CIDRv6.contains already do. A block written in network form was unaffected, which is why this went unnoticed: every containment case in TestCIDR.swift used a network address.

The same accessors carried a second defect. lower dropped the IPv6 zone while upper kept it, and because contains compares zones, a zoned block excluded its own lower bound:

2001:db8::5%lo0/126  ->  lower = 2001:db8::4       (zone lost)
                         upper = 2001:db8::7%lo0

lower now carries the address's zone the way upper already did, in both copies of the logic: the CIDR wrapper and CIDRv6.

One caller needed a matching change. VmnetNetwork.configurePrefixV6 renders lower into inet_pton, which rejects a zone suffix, so it was relying on lower stripping it. Per review, the vmnet API now leaves that to the caller: the helper renders lower as-is and checks the inet_pton result, rejecting a prefix it cannot parse rather than quietly accepting a zoned one. That check also closes a hole older than this PR — the result was discarded, so any address inet_pton refused left the buffer zeroed and configured ::. Every in-repo caller already passes a zoneless prefix, so no call site changes.

Verification

  • 9 new tests in TestCIDR.swift: containment across v4 and v6 host-address blocks, non-members still excluded, the wrapper agreeing with CIDRv4/CIDRv6 over a probe set, zoned bounds contained in their own block, zone rendering, and unzoned bounds still carrying no zone.
  • Negative control: reverting only CIDR.swift and CIDRv6.swift while keeping the tests fails 7 of the 9, with the wrong values visible in the output, for example (block → 2001:db8::5%lo0/126).contains(block.lower → 2001:db8::4) and (wrapper.contains(.v4(ip)) → false) == (concrete.contains(ip) → true). The 2 that still pass are the controls written to pass either way: non-members stay excluded, and unzoned bounds are untouched.
  • Full suite passes: 591 tests in 81 suites.
  • make containerization builds clean with the default WARNINGS_AS_ERRORS=true.
  • swift format lint --strict --recursive --configuration .swift-format-nolint Sources Tests exits 0, swift format leaves all four files unchanged, and make check-licenses reports no missing headers.
  • The vmnet helper itself has no unit coverage: it is #if os(macOS), private, and reachable only through VmnetNetwork.init, which creates a real vmnet network. It is exercised by the integration suite, which needs the vmnet entitlement.

The public shape of CIDR, CIDRv4 and CIDRv6 is untouched; only the values these accessors compute change.

`CIDR` keeps the address exactly as parsed, so a block written from a host
address keeps its host bits. `contains` compared that unmasked value against
the masked probe, so the block reported that it excluded its own members.
Mask both sides, as `CIDRv4` and `CIDRv6` already do.

`lower` also dropped the IPv6 zone while `upper` kept it, which put a zoned
block's lower bound outside the block because `contains` compares zones.

The vmnet prefix helper rendered `lower` into `inet_pton`, which rejects a
zone suffix, so it now renders the network address on its own.

@jglogan jglogan left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vyncint Great catch. Thanks for the fix.

For the VmnetNetwork API I'd prefer that we make the caller responsible for providing a zoneless address. Could you make that change?

Comment on lines +294 to +297
// `inet_pton` rejects a zone suffix, so render the network address on
// its own. A vmnet prefix is never link-scoped, so dropping the zone
// here loses nothing.
inet_pton(AF_INET6, IPv6Address(prefixV6.lower.value).description, &p)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vyncint

Suggested change
// `inet_pton` rejects a zone suffix, so render the network address on
// its own. A vmnet prefix is never link-scoped, so dropping the zone
// here loses nothing.
inet_pton(AF_INET6, IPv6Address(prefixV6.lower.value).description, &p)
// `inet_pton` rejects a zone suffix, so render the network address on
// its own. A vmnet prefix is never link-scoped, so dropping the zone
// here loses nothing.
guard inet_pton(AF_INET6, IPv6Address(prefixV6.lower.value).description, &p) == 0 else {
throw ContainerizationError(...)
}

I'd prefer we be strict here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 9b9907d. configurePrefixV6 no longer strips the zone: it renders prefixV6.lower as-is and guards on the inet_pton result, so a zoned prefix is rejected instead of quietly coerced, and supplying a zoneless prefix is the caller's job.

One deviation from the suggestion — inet_pton returns 1 on success and 0 when the string doesn't parse, so the guard reads == 1. As written (== 0) it would have thrown on every valid prefix.

Checking the result also closes something older than this PR: it was discarded, so any address inet_pton refused left p zeroed and configured ::.

No call sites needed changing — every in-repo caller already passes a zoneless prefix (CIDRv6("fd00::/64") in the integration suite).

Per review, the vmnet path no longer strips the zone itself. It renders
`lower` as-is and checks the `inet_pton` result, so a prefix it cannot parse
is rejected instead of quietly accepted, leaving the caller responsible for
supplying a zoneless prefix.

Checking the result also closes a pre-existing hole: it was discarded, so an
address `inet_pton` refused left the buffer zeroed and configured `::`.
@vyncint

vyncint commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

@jglogan pushed 9b9907d — the vmnet helper now leaves the zoneless prefix to the caller and checks inet_pton's result instead of stripping the zone itself. Details in the thread (one note: the guard is == 1, since inet_pton returns 1 on success).

Re-verified locally: make containerization clean with WARNINGS_AS_ERRORS=true, 591 tests in 81 suites pass, format lint and make check-licenses clean. Ready for another look.

@jglogan

jglogan commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Details in the thread (one note: the guard is == 1, since inet_pton returns 1 on success).

@vyncint Ah, thanks. I had a quick look at the manpage but just assumed 0 as success and negative as failure. Got it!

Looks like we've got GH actions again so it's building now.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: CIDR.contains excludes members of a block written from a host address

2 participants