ipnetx
IP address range, prefix, and set operations for IPv4 and IPv6.
What is ipnetx?
Working with IP addresses in network programming often means more than checking whether two addresses are equal. Real questions look like:
- "Is this IP inside the
10.0.0.0/8corporate network?" - "Do any of the ranges in this allow-list overlap with the ranges in this block-list?"
- "I have 50,000 IPs in my firewall rules. Can I reduce them to the minimal set of CIDR prefixes?"
ipnetx gives you three composable building blocks for answering those questions,
for both IPv4 and IPv6, using the address types already in std::net:
| Type | What it represents |
|---|---|
IpRange<A> |
A contiguous span of addresses: 10.0.0.0 – 10.255.255.255 |
IpPrefix<A> |
A CIDR block: 10.0.0.0/8 |
IpSet<A> |
An arbitrary collection of addresses built from any mix of ranges and prefixes |
Background: what is a CIDR prefix?
A CIDR prefix like
192.168.1.0/24is shorthand for "all addresses whose first 24 bits match192.168.1". The number after the/is the prefix length (also called the mask)./24means 24 fixed bits and 8 free bits, giving 2⁸ = 256 addresses./32(IPv4) or/128(IPv6) is a single host./0covers the entire address space.
Why ipnetx?
The Rust ecosystem already has solid building blocks for IP address work:
std::netgives youIpv4AddrandIpv6Addr— address types and nothing else.ipnetadds CIDR prefix and subnet handling and is the right choice if that is all you need.
ipnetx picks up where those leave off. The gap it fills is IpSet as a proper mathematical set type — something no mainstream Rust IP crate implements today. When your problem is not just "does this IP belong to this prefix?" but instead involves combining, subtracting, or inverting collections of addresses, you need set algebra:
| Question | Operation |
|---|---|
| Merge two ACLs without duplicating rules | union |
| Which IPs appear in both our network and this threat feed? | intersection |
| Everything in the allow-list that isn't also in the block-list | difference |
| Every IP not covered by this ruleset — for deny-by-default logic | complement |
These are the kinds of questions that come up in firewall tooling, BGP route analysis, threat intelligence ingestion, and network auditing. Without set algebra you end up writing nested loops and hoping the edge cases are right. ipnetx gives you correct, tested, O(m + n) implementations out of the box.
If you only need to check whether an address or prefix falls inside a CIDR block, ipnet is a perfectly fine choice and you may not need this crate. If you need to combine or compare collections of addresses, ipnetx is built for that.
Installation
Add this to your Cargo.toml:
[]
= "0.2"
Or: cargo add ipnetx
A note on error handling in examples
All code snippets below use
.unwrap()for brevity. In production code, handle errors explicitly — propagate with?, match onResult/Option, or use a crate likeanyhowfor ergonomic error context. The only fallible constructors in this crate areIpPrefix::new(rejects out-of-range masks) and.parse()onIpPrefix/IpRange(rejects malformed strings); the set operations themselves are infallible.
IpRange — working with address spans
An IpRange is the simplest primitive: a start address and an end address,
both inclusive. Any two addresses of the same family form a valid range as long
as start <= end.
use Ipv4Addr;
use IpRange;
// Create a range covering 10.0.0.0 through 10.0.0.255
let range = new;
assert!;
assert_eq!;
assert_eq!;
Parsing from a string
Use .parse() to create an IpRange from a string. The separator is ..
(two dots) — the same token that Display produces, so a round-trip always
reconstructs the original string. The separator is unambiguous for IPv6 because
no valid IPv6 address contains consecutive dots.
use ;
use IpRange;
// IPv4 — parse, then verify the round-trip
let r: = "10.0.0.0..10.0.0.255".parse.unwrap;
assert_eq!;
assert_eq!;
assert_eq!;
// IPv6 — works identically
let r6: = "2001:db8::1..2001:db8::ff".parse.unwrap;
assert!;
// Wrong separator → error
assert!;
// Bad address → error
assert!;
Containment
use Ipv4Addr;
use IpRange;
let range = new;
assert!; // inside
assert!; // start — inclusive
assert!; // end — inclusive
assert!; // just outside
Overlap
Two ranges overlap if they share at least one address. Ranges that merely touch at a single boundary point are still considered overlapping.
use Ipv4Addr;
use IpRange;
let a = new;
let b = new;
let c = new;
assert!; // share addresses 5–10
assert!; // completely separate
Converting a range to CIDR prefixes
A contiguous range of addresses can always be expressed as one or more CIDR prefixes. If the range happens to be exactly CIDR-aligned (power-of-two size, aligned start), it collapses to a single prefix.
use Ipv4Addr;
use IpRange;
// Aligned range → exactly one prefix
let aligned = new;
let prefixes = aligned.prefixes;
assert_eq!;
assert_eq!; // 192.168.1.0/24
// Unaligned range → multiple prefixes
let unaligned = new;
assert!;
Use prefix() (singular) when you only want to know whether the range is
already a single CIDR block, without allocating the full list:
use Ipv4Addr;
use IpRange;
let aligned = new;
let not_aligned = new;
assert!; // 10.0.0.0/24
assert!; // not CIDR-aligned
Invalid ranges
A range where start > end is considered invalid. IpRange::new always
succeeds — validity is checked lazily by is_valid(). Invalid ranges are
treated as empty by all operations.
use Ipv4Addr;
use IpRange;
let bad = new;
assert!;
assert!;
assert!;
IPv6
Every method works identically for IPv6. Swap Ipv4Addr for Ipv6Addr:
use Ipv6Addr;
use IpRange;
let range = new;
assert!;
assert!;
IpPrefix — working with CIDR blocks
An IpPrefix pairs an IP address with a prefix length. The constructor
validates the mask length but does not zero host bits — 192.168.1.100/24
is a valid prefix. Use masked() to get the canonical network address form.
use Ipv4Addr;
use IpPrefix;
// Ok: mask 24 is within [0, 32] for IPv4
let prefix = new.unwrap;
// Err: mask 33 exceeds 32 bits
assert!;
Parsing from a string
Use .parse() to create an IpPrefix from the standard "<address>/<length>"
notation. Host bits in the address are preserved just like IpPrefix::new —
"192.168.1.100/24" parses successfully. Use .masked() on the result if you
need the canonical network-address form.
use ;
use IpPrefix;
// IPv4 — parse, then verify the round-trip
let p: = "192.168.1.0/24".parse.unwrap;
assert_eq!;
assert_eq!;
assert_eq!;
// IPv6 — works identically
let p6: = "2001:db8::/32".parse.unwrap;
assert_eq!;
// Missing '/' → error
assert!;
// Mask out of range → error
assert!;
Containment
use Ipv4Addr;
use IpPrefix;
let prefix = new.unwrap;
assert!; // network address
assert!; // broadcast address
assert!; // somewhere in the middle
assert!; // different /24
Converting a prefix to a range
use Ipv4Addr;
use IpPrefix;
let prefix = new.unwrap;
let range = prefix.to_range;
assert_eq!;
assert_eq!;
Masking host bits
If you receive a prefix like 192.168.1.100/24 (a host address with a mask
attached) and want the canonical network address 192.168.1.0/24, use
masked():
use Ipv4Addr;
use IpPrefix;
let sloppy = new.unwrap;
let clean = sloppy.masked;
assert_eq!;
assert_eq!;
Checking for a single host
use Ipv4Addr;
use IpPrefix;
let host = new.unwrap;
let network = new.unwrap;
assert!;
assert!;
Overlap between prefixes
Two CIDR prefixes either nest (one is a sub-prefix of the other) or are completely disjoint — they cannot partially overlap the way arbitrary ranges can.
use Ipv4Addr;
use IpPrefix;
let slash8 = new.unwrap;
let slash24 = new.unwrap;
let other = new.unwrap;
assert!; // /24 is nested inside /8
assert!; // symmetric
assert!; // completely separate
IPv6
use Ipv6Addr;
use IpPrefix;
// 2001:db8::/32 — the documentation prefix (RFC 3849)
let prefix = new.unwrap;
assert!;
assert!;
IpSet — working with sets of addresses
IpSet is for when you have an arbitrary collection of ranges and prefixes and
need to answer membership or overlap questions efficiently. You build one through
IpSetBuilder, which accepts ranges and prefixes in any order, then call
build() to get a normalized, immutable IpSet.
Normalization means build() sorts all ranges by start address and merges
any that are adjacent or overlapping. The result is always a minimal list of
non-overlapping ranges, which makes binary-search lookups possible.
Building a set
use Ipv4Addr;
use IpPrefix;
use IpRange;
use IpSetBuilder;
let mut builder = new;
// Add a /8 block
builder.add_prefix;
// Add a separate /16
builder.add_prefix;
// Add an arbitrary range
builder.add_range;
let set = builder.build;
assert_eq!; // three disjoint ranges, stored in sorted order
Adding and removing individual IPs
use Ipv4Addr;
use IpPrefix;
use IpSetBuilder;
let mut builder = new;
builder.add_prefix;
// Punch a hole: remove a single IP from the middle of the range
builder.remove_ip;
let set = builder.build;
assert!; // removed
assert!; // still present
assert!; // still present
assert_eq!; // split into [0..99] and [101..255]
Combining and subtracting whole sets while building
add_ipset and remove_ipset let you merge or subtract an entire IpSet into
a builder in one call. This is useful when you have pre-built sets you want to
compose — for example, starting from a known set of prefixes and then carving
out reserved blocks.
use Ipv4Addr;
use IpPrefix;
use IpSetBuilder;
// Build the RFC 1918 private address space.
let mut b = new;
b.add_prefix;
b.add_prefix;
b.add_prefix;
let private = b.build;
// Reserve a /24 for infrastructure — build it as its own set.
let mut rb = new;
rb.add_prefix;
let reserved = rb.build;
// Compose: start from all private space, then carve out the reserved block.
let mut pool = new;
pool.add_ipset; // absorbs all three RFC 1918 ranges
pool.remove_ipset; // punches out 10.0.0.0/24
let usable = pool.build;
assert!; // reserved
assert!; // still usable
Membership queries
use Ipv4Addr;
use IpPrefix;
use IpRange;
use IpSetBuilder;
let mut builder = new;
builder.add_prefix;
let set = builder.build;
// Single IP lookup — O(log n)
assert!;
assert!;
// Range containment — the entire range must fit inside a single stored range
let inner = new;
let outer = new;
assert!; // fully inside 10/8
assert!; // starts before 10/8 — not contained
Overlap between two sets
use Ipv4Addr;
use IpPrefix;
use IpSetBuilder;
let mut allow = new;
allow.add_prefix;
let allow_set = allow.build;
let mut block = new;
block.add_prefix; // inside allow
let block_set = block.build;
let mut clean = new;
clean.add_prefix; // outside allow
let clean_set = clean.build;
assert!; // conflict — 10/24 is inside 10/8
assert!; // no conflict
Inspecting a set
use Ipv4Addr;
use IpPrefix;
use IpSetBuilder;
let mut builder = new;
builder.add_prefix;
builder.add_prefix;
let set = builder.build;
// Iterate over the stored ranges
for range in set.ranges
// Decompose back to CIDR prefixes
for prefix in set.prefixes
Iterating over a set
IpSet implements IntoIterator, so you can use it directly in for loops
and chain it with standard iterator adapters — no need to call .ranges() first.
use Ipv4Addr;
use IpRange;
use IpSetBuilder;
let mut builder = new;
builder.add_range;
builder.add_range;
let set = builder.build;
// Borrow — yields &IpRange<A>, set is still usable afterwards.
for range in &set
// Iterator adapters work directly on &set.
let starts: = .into_iter.map.collect;
assert_eq!;
assert_eq!;
// Consuming — moves the set, yields IpRange<A> by value.
let ranges: = set.into_iter.collect;
assert_eq!;
Overlapping inputs are merged automatically
You don't need to pre-sort or de-duplicate your inputs — build() handles it:
use Ipv4Addr;
use IpPrefix;
use IpSetBuilder;
let mut builder = new;
builder.add_prefix; // 10/8
builder.add_prefix; // 10.0.0/24 — inside 10/8
builder.add_prefix; // 10.0/16 — also inside 10/8
let set = builder.build;
assert_eq!; // all three collapse into a single 10/8 range
IPv6
use Ipv6Addr;
use IpPrefix;
use IpSetBuilder;
let mut builder = new;
builder.add_prefix;
let set = builder.build;
assert!;
assert!;
Set algebra
IpSet supports four mathematical set operations. All four return a new,
normalized IpSet and are infallible — no unwrap required on the result.
Union — every address in either set:
use Ipv4Addr;
use IpPrefix;
use IpSetBuilder;
let mut b1 = new;
b1.add_prefix;
let a = b1.build;
let mut b2 = new;
b2.add_prefix;
let b = b2.build;
let u = a.union;
assert!; // from a
assert!; // from b
assert_eq!; // two disjoint ranges
Intersection — only addresses present in both sets:
use Ipv4Addr;
use IpPrefix;
use IpSetBuilder;
let mut b1 = new;
b1.add_prefix;
let a = b1.build; // 10.0.0.0 – 10.255.255.255
let mut b2 = new;
b2.add_prefix;
let b = b2.build; // 10.0.0.0 – 10.0.0.255
let inter = a.intersection;
assert!; // in both
assert!; // only in a
Difference — addresses in a but not in b:
use Ipv4Addr;
use IpPrefix;
use IpSetBuilder;
let mut b1 = new;
b1.add_prefix;
let a = b1.build;
let mut b2 = new;
b2.add_prefix;
let b = b2.build;
let diff = a.difference;
assert!; // carved out by b
assert!; // still in a
Complement — every address not in the set. For IPv4 this covers the
entire 0.0.0.0/0 space minus self; for IPv6 it covers ::/0 minus self.
use Ipv4Addr;
use IpPrefix;
use IpSetBuilder;
let mut builder = new;
builder.add_prefix;
let a = builder.build;
let c = a.complement;
assert!; // was in a
assert!; // not in a
// a and its complement are disjoint and together cover the whole space
assert!;
assert_eq!; // merges into 0.0.0.0/0
Cardinality and subset tests
count() returns the total number of individual IP addresses in the set
as a u128 (large enough to represent an entire IPv6 address space exactly):
use Ipv4Addr;
use IpSetBuilder;
use IpRange;
let mut b = new;
b.add_range; // 10
b.add_range; // 5
let set = b.build;
assert_eq!;
assert_eq!; // two ranges, but 15 addresses total
is_subset_of and is_superset_of — test containment between sets.
An empty set is a subset of every set:
use Ipv4Addr;
use IpSetBuilder;
use IpRange;
let mut ba = new;
ba.add_range;
let a = ba.build; // wider range
let mut bb = new;
bb.add_range;
let b = bb.build; // narrower range, fully inside a
assert!; // b ⊆ a
assert!; // a ⊇ b
assert!; // a is strictly larger
Collecting from iterators
IpSetBuilder implements FromIterator for both IpRange and IpPrefix,
so you can build a set from any iterator using .collect():
use Ipv4Addr;
use IpSetBuilder;
use IpPrefix;
use IpRange;
// From a Vec of ranges
let ranges = vec!;
let set = ranges.into_iter..build;
assert_eq!;
// From a Vec of prefixes — useful when loading CIDR lists from config
let prefixes = vec!;
let set = prefixes.into_iter..build;
assert!;
assert!;
Performance
All operations are designed to scale predictably. The table below shows measured times across three set sizes so the scaling behaviour is visible, not just a single snapshot.
| Operation | Complexity | 100 ranges | 1 000 ranges | 10 000 ranges | Takeaway |
|---|---|---|---|---|---|
contains_ip |
O(log n) | ~901 ps | ~893 ps | ~890 ps | Effectively flat — log growth is invisible at these sizes |
build (adds only) |
O(n log n) | ~465 ns | ~2.65 µs | ~22.9 µs | ~5× slower per 10× more ranges, as expected |
build (with removals) |
O((n+k) log(n+k)) | ~809 ns | ~4.04 µs | ~35.1 µs | ~1.5× over adds-only for 50% removal rate; removals are O(1) at call time |
union |
O((m+n) log(m+n)) | ~40 ns | ~40 ns | ~40 ns | Allocation dominates at these sizes |
intersection |
O(m + n) | ~22 ns | ~22 ns | ~22 ns | Flat — data fits in L1 cache |
difference |
O(m + n) | ~22 ns | ~22 ns | ~22 ns | Same story as intersection |
complement |
O(n) | ~21 ns | ~22 ns | ~22 ns | Single pass over stored ranges |
count |
O(n) | ~1.14 ns | ~1.13 ns | ~1.13 ns | Compiler optimises the summation loop |
prefixes() — alignment sensitivity
prefixes() decomposes each stored range into the minimal covering set of CIDR
prefixes. An aligned range (power-of-two size, aligned start) produces exactly
one prefix; a maximally unaligned range like *.1..*.254 fans out to 14. That
fan-out makes alignment the dominant factor in cost:
| Case | 1 range | 100 ranges | 1 000 ranges | 10 000 ranges |
|---|---|---|---|---|
| aligned (1 prefix per range) | ~22 ns | ~71 ns | ~115 ns | ~111 ns |
| unaligned (14 prefixes per range) | ~138 ns | ~14.6 µs | ~142 µs | ~1.38 ms |
For well-aligned data (BGP tables, RFC 1918 blocks) prefixes() is fast at any
scale. For arbitrary firewall ranges, expect roughly 200× the cost of the
aligned case at 100 ranges, growing with the number of CIDR prefixes produced.
Measurements taken on a MacBook Pro M2 Max,
ipnetx v0.2.2, release profile. Times reflect data that fits comfortably in CPU cache — a realistic scenario for most workloads. At BGP-table scale (~900k routes) the bottleneck shifts to memory bandwidth rather than compute.
To run the benchmarks on your own hardware:
The full criterion report (including per-run history and regression detection)
lands at target/criterion/report/index.html. Benchmark source is in
benchmarks/ipset.rs.
Ecosystem
ipnetx reasons about regions of IP address space as mathematical sets —
union, intersection, difference, complement. Once you have a set, a natural
next question is: given a single IP address, which prefix in this set matches
it most specifically?
That is the job of routemap — an
in-memory longest-prefix-match table that uses IpPrefix<A> from ipnetx as
its key type. The two crates are designed to compose: use ipnetx to aggregate
and normalise your prefix data, then load the result into a RouteMap for
per-address classification at speed.
use Ipv4Addr;
use IpSetBuilder;
use IpPrefix;
use RouteMap;
// Use ipnetx to aggregate raw prefixes into a minimal covering set.
// The /16 is redundant — ipnetx deduplicates it during build().
let mut builder = new;
builder.add_prefix;
builder.add_prefix;
let set = builder.build;
assert_eq!; // collapsed to a single 10/8
// Load the aggregated prefixes into routemap for LPM classification.
let table: = set
.prefixes
.into_iter
.map
.collect;
assert_eq!;
API reference
Full documentation is available on docs.rs/ipnetx.
License
MIT — see LICENSE.