From d15bcb79f19f5d6b336e253eb513e92936e9067d Mon Sep 17 00:00:00 2001 From: Denis Avvakumov Date: Wed, 3 Dec 2025 23:04:52 +0200 Subject: [PATCH] Performance improvements and test hardening --- .github/dependabot.yml | 12 -- .github/workflows/rust.yml | 17 +-- Cargo.lock | 27 ++-- Cargo.toml | 12 +- README.md | 7 +- benches/ip_bench.rs | 258 +++++++++++++++++++++---------------- src/iprange/mod.rs | 129 ++++++++----------- src/iprange/tests.rs | 64 +++++++++ src/ipv4/mod.rs | 170 +++++++++++++----------- src/ipv4/tests.rs | 5 + src/ipv6/mod.rs | 137 ++++++++++---------- src/ipv6/tests.rs | 58 ++++++++- 12 files changed, 523 insertions(+), 373 deletions(-) delete mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index adb68c2..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,12 +0,0 @@ -version: 2 -updates: - - package-ecosystem: "cargo" - directory: "/" - schedule: - interval: "weekly" - time: "08:00" - timezone: "UTC" - target-branch: "main" - open-pull-requests-limit: 10 - assignees: - - "deniskore" diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index d71980e..fb1b72e 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -11,14 +11,15 @@ jobs: - uses: dtolnay/rust-toolchain@1.80 - run: cargo check - test: - name: Tests - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - uses: dtolnay/rust-toolchain@1.80 - - run: cargo test - - run: cargo test --features serde + test: + name: Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: dtolnay/rust-toolchain@1.80 + - run: cargo test + - run: cargo test --features serde + - run: cargo test --doc fmt: name: Rustfmt diff --git a/Cargo.lock b/Cargo.lock index 6750011..89cf9d8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -122,12 +122,11 @@ checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763" [[package]] name = "iptools" -version = "0.4.0" +version = "0.4.1" dependencies = [ "divan", "ipnet", "ipnetwork", - "itoa", "js-sys", "lazy-regex", "once_cell", @@ -146,9 +145,9 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "js-sys" -version = "0.3.82" +version = "0.3.83" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b011eec8cc36da2aab2d5cff675ec18454fad408585853910a202391cf9f8e65" +checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8" dependencies = [ "once_cell", "wasm-bindgen", @@ -179,9 +178,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.177" +version = "0.2.178" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +checksum = "37c93d8daa9d8a012fd8ab92f088405fb202ea0b6ab73ee2482ae66af4f42091" [[package]] name = "linux-raw-sys" @@ -376,9 +375,9 @@ checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" [[package]] name = "wasm-bindgen" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da95793dfc411fbbd93f5be7715b0578ec61fe87cb1a42b12eb625caa5c5ea60" +checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd" dependencies = [ "cfg-if", "once_cell", @@ -389,9 +388,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04264334509e04a7bf8690f2384ef5265f05143a4bff3889ab7a3269adab59c2" +checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -399,9 +398,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "420bc339d9f322e562942d52e115d57e950d12d88983a14c79b86859ee6c7ebc" +checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40" dependencies = [ "bumpalo", "proc-macro2", @@ -412,9 +411,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.105" +version = "0.2.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76f218a38c84bcb33c25ec7059b07847d465ce0e0a76b995e134a45adcb6af76" +checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4" dependencies = [ "unicode-ident", ] diff --git a/Cargo.toml b/Cargo.toml index 3b97939..92ed05d 100755 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "iptools" -version = "0.4.0" +version = "0.4.1" authors = ["Denis Avvakumov"] license = "MIT" readme = "README.md" @@ -25,12 +25,14 @@ serde = ["dep:serde"] lazy-regex = "3.4.2" once_cell = { version = "1.21", default-features = false, features = ["alloc"] } tinyvec = { version = "1", features = ["alloc"] } -itoa = { version = "1", default-features = false } -serde = { version = "1", default-features = false, features = ["derive", "alloc"], optional = true } +serde = { version = "1", default-features = false, features = [ + "derive", + "alloc", +], optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] -wasm-bindgen = "0.2" -js-sys = "0.3" +wasm-bindgen = "0.2.106" +js-sys = "0.3.83" [dev-dependencies] pretty_assertions = "1.4.1" diff --git a/README.md b/README.md index 7af3aa9..4a58ff6 100644 --- a/README.md +++ b/README.md @@ -22,14 +22,14 @@ This is a port of package [iptools](https://github.com/bd808/python-iptools) fro Add the following dependency to your Cargo manifest: ```toml [dependencies] -iptools = "0.4.0" +iptools = "0.4.1" ``` ### `no_std` support To use `iptools` in a `no_std` environment, disable the default features: ```toml [dependencies] -iptools = { version = "0.4.0", default-features = false } +iptools = { version = "0.4.1", default-features = false } ``` ## Example of usage @@ -90,6 +90,9 @@ difference locally: python3 -c "from iptools import ipv6; print(ipv6.validate_ip(':::1'))" ``` +### Reversed range bounds difference +The Rust port rejects reversed bounds when constructing ranges (e.g. `"10.0.0.2"`, `"10.0.0.1"` returns an error). Python `iptools` normalizes and silently swaps those endpoints. + ## Supported Rust Versions Rust 1.78.0+ diff --git a/benches/ip_bench.rs b/benches/ip_bench.rs index 7dc9970..85513f5 100755 --- a/benches/ip_bench.rs +++ b/benches/ip_bench.rs @@ -228,119 +228,151 @@ fn ipnetwork_ipv4_size(bencher: Bencher) { bencher.bench_local(|| black_box(net.size())); } -#[divan::bench(sample_count = 10000)] -fn iptools_iprange_ipv4_iter_string_addr() -> usize { - IpRange::::new("10.0.0.1", "10.0.3.254") - .unwrap() - .fold(0usize, |count, ip| { - black_box(ip); - count + 1 - }) -} - -#[divan::bench(sample_count = 10000)] -fn iptools_iprange_ipv6_iter_string_addr() -> usize { - IpRange::::new("2001:db8::", "2001:db8::fff") - .unwrap() - .fold(0usize, |count, ip| { - black_box(ip); - count + 1 - }) -} - -#[divan::bench(sample_count = 10000)] -fn ipnet_ipv4_iter_addr() -> usize { - let net: IpNetIpv4 = "10.0.0.0/22".parse().unwrap(); - net.hosts().fold(0usize, |count, ip| { - black_box(ip); - count + 1 - }) -} - -#[divan::bench(sample_count = 10000)] -fn ipnet_ipv4_iter_string_addr() -> usize { - let net: IpNetIpv4 = "10.0.0.0/22".parse().unwrap(); - net.hosts().fold(0usize, |count, ip| { - black_box(ip.to_string()); - count + 1 - }) -} - -#[divan::bench(sample_count = 10000)] -fn ipnet_ipv6_iter_addr() -> usize { - let net: IpNetIpv6 = "2001:db8::/116".parse().unwrap(); - net.hosts().fold(0usize, |count, ip| { - black_box(ip); - count + 1 - }) -} - -#[divan::bench(sample_count = 10000)] -fn ipnet_ipv6_iter_string_addr() -> usize { - let net: IpNetIpv6 = "2001:db8::/116".parse().unwrap(); - net.hosts().fold(0usize, |count, ip| { - black_box(ip.to_string()); - count + 1 - }) -} - -#[divan::bench(sample_count = 10000)] -fn ipnetwork_ipv4_iter_addr() -> usize { - let net: IpNetworkIpv4 = "10.0.0.0/22".parse().unwrap(); - net.iter().fold(0usize, |count, ip| { - black_box(ip); - count + 1 - }) -} - -#[divan::bench(sample_count = 10000)] -fn ipnetwork_ipv4_iter_string_addr() -> usize { - let net: IpNetworkIpv4 = "10.0.0.0/22".parse().unwrap(); - net.iter().fold(0usize, |count, ip| { - black_box(ip.to_string()); - count + 1 - }) -} - -#[divan::bench(sample_count = 10000)] -fn ipnetwork_ipv6_iter_addr() -> usize { - let net: IpNetworkIpv6 = "2001:db8::/116".parse().unwrap(); - net.iter().fold(0usize, |count, ip| { - black_box(ip); - count + 1 - }) -} - -#[divan::bench(sample_count = 10000)] -fn ipnetwork_ipv6_iter_string_addr() -> usize { - let net: IpNetworkIpv6 = "2001:db8::/116".parse().unwrap(); - net.iter().fold(0usize, |count, ip| { - black_box(ip.to_string()); - count + 1 - }) -} - -#[divan::bench(sample_count = 10000)] -fn iptools_iprange_ipv4_iter_addr() -> usize { - IpRange::::new("10.0.0.1", "10.0.3.254") - .unwrap() - .addrs() - .fold(0usize, |count, addr| { - black_box(addr); - count + 1 - }) -} - -#[divan::bench(sample_count = 10000)] -fn iptools_iprange_ipv6_iter_addr() -> usize { - IpRange::::new("2001:db8::", "2001:db8::fff") - .unwrap() - .addrs() - .fold(0usize, |count, addr| { - black_box(addr); - count + 1 - }) -} +#[divan::bench(sample_count = 10000)] +fn iptools_iprange_ipv4_iter_string_addr(bencher: Bencher) { + let range = IpRange::::new("10.0.0.0/22", "").unwrap(); + bencher.bench_local(|| { + let count = range.clone().fold(0usize, |count, ip| { + black_box(ip); + count + 1 + }); + black_box(count); + }); +} + +#[divan::bench(sample_count = 10000)] +fn iptools_iprange_ipv6_iter_string_addr(bencher: Bencher) { + let range = IpRange::::new("2001:db8::", "2001:db8::fff").unwrap(); + bencher.bench_local(|| { + let count = range.clone().fold(0usize, |count, ip| { + black_box(ip); + count + 1 + }); + black_box(count); + }); +} + +#[divan::bench(sample_count = 10000)] +fn ipnet_ipv4_iter_addr(bencher: Bencher) { + let net: IpNetIpv4 = "10.0.0.0/22".parse().unwrap(); + bencher.bench_local(|| { + // hosts() skips network+broadcast, so two addresses are excluded + let count = net.hosts().fold(0usize, |count, ip| { + black_box(ip); + count + 1 + }); + black_box(count); + }); +} + +#[divan::bench(sample_count = 10000)] +fn ipnet_ipv4_iter_string_addr(bencher: Bencher) { + let net: IpNetIpv4 = "10.0.0.0/22".parse().unwrap(); + bencher.bench_local(|| { + // hosts() skips network+broadcast, so two addresses are excluded + let count = net.hosts().fold(0usize, |count, ip| { + black_box(ip.to_string()); + count + 1 + }); + black_box(count); + }); +} + +#[divan::bench(sample_count = 10000)] +fn ipnet_ipv6_iter_addr(bencher: Bencher) { + let net: IpNetIpv6 = "2001:db8::/116".parse().unwrap(); + bencher.bench_local(|| { + let count = net.hosts().fold(0usize, |count, ip| { + black_box(ip); + count + 1 + }); + black_box(count); + }); +} + +#[divan::bench(sample_count = 10000)] +fn ipnet_ipv6_iter_string_addr(bencher: Bencher) { + let net: IpNetIpv6 = "2001:db8::/116".parse().unwrap(); + bencher.bench_local(|| { + let count = net.hosts().fold(0usize, |count, ip| { + black_box(ip.to_string()); + count + 1 + }); + black_box(count); + }); +} + +#[divan::bench(sample_count = 10000)] +fn ipnetwork_ipv4_iter_addr(bencher: Bencher) { + let net: IpNetworkIpv4 = "10.0.0.0/22".parse().unwrap(); + bencher.bench_local(|| { + let count = net.iter().fold(0usize, |count, ip| { + black_box(ip); + count + 1 + }); + black_box(count); + }); +} + +#[divan::bench(sample_count = 10000)] +fn ipnetwork_ipv4_iter_string_addr(bencher: Bencher) { + let net: IpNetworkIpv4 = "10.0.0.0/22".parse().unwrap(); + bencher.bench_local(|| { + let count = net.iter().fold(0usize, |count, ip| { + black_box(ip.to_string()); + count + 1 + }); + black_box(count); + }); +} + +#[divan::bench(sample_count = 10000)] +fn ipnetwork_ipv6_iter_addr(bencher: Bencher) { + let net: IpNetworkIpv6 = "2001:db8::/116".parse().unwrap(); + bencher.bench_local(|| { + let count = net.iter().fold(0usize, |count, ip| { + black_box(ip); + count + 1 + }); + black_box(count); + }); +} + +#[divan::bench(sample_count = 10000)] +fn ipnetwork_ipv6_iter_string_addr(bencher: Bencher) { + let net: IpNetworkIpv6 = "2001:db8::/116".parse().unwrap(); + bencher.bench_local(|| { + let count = net.iter().fold(0usize, |count, ip| { + black_box(ip.to_string()); + count + 1 + }); + black_box(count); + }); +} + +#[divan::bench(sample_count = 10000)] +fn iptools_iprange_ipv4_iter_addr(bencher: Bencher) { + let range = IpRange::::new("10.0.0.0/22", "").unwrap(); + bencher.bench_local(|| { + let count = range.addrs().fold(0usize, |count, addr| { + black_box(addr); + count + 1 + }); + black_box(count); + }); +} + +#[divan::bench(sample_count = 10000)] +fn iptools_iprange_ipv6_iter_addr(bencher: Bencher) { + let range = IpRange::::new("2001:db8::", "2001:db8::fff").unwrap(); + bencher.bench_local(|| { + let count = range.addrs().fold(0usize, |count, addr| { + black_box(addr); + count + 1 + }); + black_box(count); + }); +} #[divan::bench(sample_count = 100000)] fn iptools_iprange_ipv6_contains(bencher: Bencher) { diff --git a/src/iprange/mod.rs b/src/iprange/mod.rs index d4f847d..2da09f6 100755 --- a/src/iprange/mod.rs +++ b/src/iprange/mod.rs @@ -4,6 +4,7 @@ use crate::error::{Error, Result}; use crate::ipv4; use crate::ipv6; use core::hash::{Hash, Hasher}; +use core::iter::FusedIterator; use core::marker::PhantomData; use once_cell::sync::Lazy; #[cfg(feature = "serde")] @@ -54,7 +55,8 @@ struct RangeState { start_ip: T, end_ip: T, len: T, - last_ip: Option, + next_ip: T, + remaining: T, } /// A generic IP address range. @@ -204,6 +206,9 @@ impl IpRange { /// masks from the second argument. If you have an address plus mask, convert it to CIDR or to /// the corresponding final IP before calling [`IpRange::new`]. /// + /// Note: Unlike the Python `iptools` library, reversed bounds are rejected + /// rather than normalized. + /// /// # Examples /// /// ``` @@ -226,17 +231,21 @@ impl IpRange { let start_ip = parse_endpoint::(start, true)?; let end_ip = parse_endpoint::(end_spec, false)?; + // Unlike Python iptools, reversed bounds are rejected. if start_ip > end_ip { return Err(Error::V4Subnet()); } - let len = T::len(start_ip, end_ip); + if len == T::zero() && start_ip != end_ip { + return Err(Error::V4Subnet()); + } Ok(IpRange { ip_range: RangeState { start_ip, end_ip, len, - last_ip: None, + next_ip: start_ip, + remaining: len, }, _marker: PhantomData, }) @@ -321,22 +330,7 @@ impl IpRange { /// # } /// ``` pub fn remaining(&self) -> T::Addr { - if let Some(last) = self.ip_range.last_ip { - T::remaining(self.ip_range.end_ip, last) - } else { - self.ip_range.len - } - } - - #[inline] - fn bounds_contain(&self, target_start: T::Addr, target_end: T::Addr, is_range: bool) -> bool { - if is_range { - self.ip_range.start_ip <= target_start - && target_start <= self.ip_range.end_ip - && target_end <= self.ip_range.end_ip - } else { - self.ip_range.start_ip <= target_start && target_start <= self.ip_range.end_ip - } + self.ip_range.remaining } #[inline] @@ -402,8 +396,8 @@ impl IpRange { /// bounds. `"10.0.0.1", "255.255.255.0"` spans the numeric space between those /// literal addresses, not the `/24` network the mask implies. When you obtain /// "IP + netmask" pairs, turn the mask into a prefix (e.g., via - /// [`ipv4::netmask2prefix`](crate::ipv4::netmask2prefix)) and build the range - /// from CIDR or by computing the actual closing IP before calling `contains`. + /// [`ipv4::netmask2prefix`]) and build the range from CIDR or by computing the + /// actual closing IP before calling `contains`. /// /// # Examples /// @@ -452,16 +446,18 @@ impl IpRange { } /// Checks whether a numeric address sits inside this range. + #[inline(always)] pub fn contains_addr(&self, addr: T::Addr) -> bool { - self.bounds_contain(addr, addr, false) + self.ip_range.start_ip <= addr && addr <= self.ip_range.end_ip } /// Checks whether the inclusive numeric bounds sit inside this range. + #[inline(always)] pub fn contains_range(&self, start: T::Addr, end: T::Addr) -> bool { if start > end { return false; } - self.bounds_contain(start, end, start != end) + self.ip_range.start_ip <= start && end <= self.ip_range.end_ip } /// Checks if an IP address or range falls within reserved IP blocks (e.g., loopback, private). @@ -489,46 +485,41 @@ impl IpRange { /// Iterator over raw IP addresses. pub struct AddrIterator { - ip_range: RangeState, + next: Option, + end: T::Addr, _marker: PhantomData, } impl Iterator for AddrIterator { type Item = T::Addr; + #[inline(always)] fn next(&mut self) -> Option { - match self.ip_range.last_ip { - None => { - self.ip_range.last_ip = Some(self.ip_range.start_ip); - Some(self.ip_range.start_ip) - } - Some(current) => { - if current >= self.ip_range.end_ip { - None - } else { - let next = T::wrapping_add_one(current); - self.ip_range.last_ip = Some(next); - Some(next) - } - } - } + let current = self.next?; + self.next = if current == self.end { + None + } else { + Some(T::wrapping_add_one(current)) + }; + Some(current) } + #[inline(always)] fn size_hint(&self) -> (usize, Option) { - match self.ip_range.last_ip { - None => T::size_hint(self.ip_range.len), + match self.next { + None => (0, Some(0)), Some(current) => { - if current >= self.ip_range.end_ip { - (0, Some(0)) - } else { - let remaining = T::remaining(self.ip_range.end_ip, current); - T::size_hint(remaining) - } + let remaining = T::remaining(self.end, current); + let inclusive = T::checked_add_one(remaining).unwrap_or(remaining); + T::size_hint(inclusive) } } } } +impl FusedIterator for AddrIterator {} +impl ExactSizeIterator for AddrIterator {} + impl IpRange { /// Returns an iterator over raw IP addresses. /// @@ -545,7 +536,8 @@ impl IpRange { /// ``` pub fn addrs(&self) -> AddrIterator { AddrIterator { - ip_range: self.ip_range.clone(), + next: Some(self.ip_range.start_ip), + end: self.ip_range.end_ip, _marker: PhantomData, } } @@ -554,39 +546,26 @@ impl IpRange { impl Iterator for IpRange { type Item = alloc::string::String; + #[inline(always)] fn next(&mut self) -> Option { - match self.ip_range.last_ip { - None => { - self.ip_range.last_ip = Some(self.ip_range.start_ip); - Some(T::format_addr(self.ip_range.start_ip)) - } - Some(current) => { - if current >= self.ip_range.end_ip { - None - } else { - let next = T::wrapping_add_one(current); - self.ip_range.last_ip = Some(next); - Some(T::format_addr(next)) - } - } + if self.ip_range.remaining == T::zero() { + return None; } + + let out = self.ip_range.next_ip; + self.ip_range.next_ip = T::wrapping_add_one(out); + self.ip_range.remaining = T::checked_sub_one(self.ip_range.remaining); + Some(T::format_addr(out)) } fn size_hint(&self) -> (usize, Option) { - match self.ip_range.last_ip { - None => T::size_hint(self.ip_range.len), - Some(current) => { - if current >= self.ip_range.end_ip { - (0, Some(0)) - } else { - let remaining = T::remaining(self.ip_range.end_ip, current); - T::size_hint(remaining) - } - } - } + T::size_hint(self.ip_range.remaining) } } +impl FusedIterator for IpRange {} +impl ExactSizeIterator for IpRange {} + impl PartialEq for IpRange { fn eq(&self, other: &Self) -> bool { self.ip_range.start_ip == other.ip_range.start_ip @@ -646,7 +625,7 @@ impl RangeFamily for IPv4 { } fn checked_sub_one(value: Self::Addr) -> Self::Addr { - value.checked_sub(1).unwrap_or(value) + value.saturating_sub(1) } fn len(start: Self::Addr, end: Self::Addr) -> Self::Addr { @@ -714,7 +693,7 @@ impl RangeFamily for IPv6 { } fn checked_sub_one(value: Self::Addr) -> Self::Addr { - value.checked_sub(1).unwrap_or(value) + value.saturating_sub(1) } fn len(start: Self::Addr, end: Self::Addr) -> Self::Addr { diff --git a/src/iprange/tests.rs b/src/iprange/tests.rs index 8d29cdd..26c9424 100644 --- a/src/iprange/tests.rs +++ b/src/iprange/tests.rs @@ -142,6 +142,70 @@ fn test_contains_std_helpers() { assert!(!ipv6_range.contains_ipaddr(IpAddr::V4(Ipv4Addr::LOCALHOST))); } +#[test] +fn test_addrs_iterator_ipv4() { + let range = IpRange::::new("10.0.0.1", "10.0.0.3").unwrap(); + let mut iter = range.addrs(); + assert_eq!(iter.size_hint(), (3, Some(3))); + let collected = iter.by_ref().collect::>(); + assert_eq!( + collected, + [ + ipv4::ip2long("10.0.0.1").unwrap(), + ipv4::ip2long("10.0.0.2").unwrap(), + ipv4::ip2long("10.0.0.3").unwrap() + ] + ); + + // FusedIterator behavior: once exhausted, it stays exhausted + assert_eq!(iter.next(), None); + assert_eq!(iter.next(), None); + + // addrs() should not advance the main iterator state + let mut range_again = range; + let _ = range_again.addrs().collect::>(); + assert_eq!(range_again.next().unwrap(), "10.0.0.1"); +} + +#[test] +fn test_addrs_iterator_ipv6_and_range_consistency() { + let range = IpRange::::new("2001:db8::", "2001:db8::3").unwrap(); + let addrs = range.addrs().collect::>(); + assert_eq!(addrs.len(), 4); + assert_eq!(range.len(), 4); + assert_eq!( + range.get_range(), + ("2001:db8::".to_string(), "2001:db8::3".to_string()) + ); + assert_eq!(ipv6::long2ip(addrs[0], false), "2001:db8::"); + assert_eq!(ipv6::long2ip(*addrs.last().unwrap(), false), "2001:db8::3"); +} + +#[test] +fn test_iterator_size_hint_and_remaining_updates() { + let mut range = IpRange::::new("10.0.0.1", "10.0.0.2").unwrap(); + assert_eq!(Iterator::size_hint(&range), (2, Some(2))); + assert_eq!(range.remaining(), 2); + + range.next(); + assert_eq!(Iterator::size_hint(&range), (1, Some(1))); + assert_eq!(range.remaining(), 1); + + range.next(); + assert_eq!(Iterator::size_hint(&range), (0, Some(0))); + assert_eq!(range.remaining(), 0); + assert_eq!(range.next(), None); + assert_eq!(range.next(), None); // fused behavior for IpRange iterator +} + +#[test] +fn test_is_empty_and_reversed_bounds_error() { + let range = IpRange::::new("10.0.0.1", "10.0.0.1").unwrap(); + assert!(!range.is_empty()); + assert!(IpRange::::new("10.0.0.2", "10.0.0.1").is_err()); + assert!(IpRange::::new("2001:db8::2", "2001:db8::1").is_err()); +} + #[cfg(feature = "serde")] #[test] fn test_serde_roundtrip_ipv4_range() { diff --git a/src/ipv4/mod.rs b/src/ipv4/mod.rs index 1f2196d..21d2b61 100755 --- a/src/ipv4/mod.rs +++ b/src/ipv4/mod.rs @@ -1,10 +1,8 @@ // Copyright (c) 2025 Denis Avvakumov // Licensed under the MIT license, https://opensource.org/licenses/MIT -use itoa::Buffer; use lazy_regex::regex; use once_cell::sync::Lazy; -use tinyvec::ArrayVec; use crate::error::Error; use crate::error::Result; @@ -225,30 +223,60 @@ pub fn validate_cidr_re(cidr: &str) -> bool { /// assert_eq!(validate_cidr("127.0.0.1"), false); /// ``` pub fn validate_cidr(cidr: &str) -> bool { - // Find the '/' separator - let Some(slash_pos) = cidr.bytes().position(|b| b == b'/') else { - return false; - }; + let bytes = cidr.as_bytes(); + let len = bytes.len(); + let mut idx = 0; - let ip_part = &cidr[..slash_pos]; - let mask_bytes = &cidr.as_bytes()[slash_pos + 1..]; + // Parse IPv4 part (1–4 octets, 0–255 each), stopping at '/' + let mut octet_count = 0; + let mut current: u16 = 0; + let mut has_digit = false; + while idx < len { + let b = bytes[idx]; + if b == b'/' { + break; + } else if b == b'.' { + if !has_digit || octet_count >= 3 { + return false; + } + octet_count += 1; + current = 0; + has_digit = false; + } else if b.is_ascii_digit() { + current = current * 10 + (b - b'0') as u16; + if current > 255 { + return false; + } + has_digit = true; + } else { + return false; + } + idx += 1; + } - // Early validation: mask must be 1-2 digits - if mask_bytes.is_empty() || mask_bytes.len() > 2 { + // Need a '/' and at least one digit in the last octet + if idx >= len || !has_digit { + return false; + } + + // Parse prefix (1–2 digits, 0–32) + idx += 1; // skip '/' + let remaining = len - idx; + if remaining == 0 || remaining > 2 { return false; } - // Parse prefix manually (faster than parse::()) let mut prefix: u8 = 0; - for &b in mask_bytes { + while idx < len { + let b = bytes[idx]; if !b.is_ascii_digit() { return false; } prefix = prefix * 10 + (b - b'0'); + idx += 1; } - // Validate prefix range (0-32) and IP (using ip2long for fast validation) - prefix <= 32 && ip2long(ip_part).is_ok() + prefix <= 32 } /// Validate that a dotted-quad ip address is a valid [netmask](https://en.wikipedia.org/wiki/Subnetwork) @@ -261,11 +289,7 @@ pub fn validate_cidr(cidr: &str) -> bool { /// assert_eq!(validate_netmask("128.0.0.1"), false); /// ``` pub fn validate_netmask(netmask: &str) -> bool { - validate_ip_re(netmask) - && ip2network(netmask).is_some_and(|ip| { - let mask = alloc::format!("{:0>32}", bin_u32(ip).trim_start_matches("0b")); - !mask.contains("01") - }) + parse_contiguous_netmask(netmask).is_some() } /// Validate a dotted-quad ip adress including a netmask @@ -287,20 +311,13 @@ pub fn validate_subnet(subnet: &str) -> bool { let mut parts = subnet.splitn(2, '/'); let start = parts.next().unwrap_or(""); let mask = parts.next().unwrap_or(""); - !mask.is_empty() && validate_ip_re(start) && validate_netmask(mask) + !mask.is_empty() && validate_ip(start) && validate_netmask(mask) } } -/// Convert a dotted-quad ip address to a network byte order 32 bit integer -/// -/// # Example -/// -/// ``` -/// use iptools::ipv4::ip2long; -/// assert_eq!(ip2long("127"), Ok(2130706432)); -/// assert_eq!(ip2long("127.0.0.256").is_err(), true); -/// ``` -pub fn ip2long(ip: &str) -> Result { +// Fast parser shared by IPv4 helpers. +#[inline(always)] +fn parse_ipv4_octets(ip: &str) -> Result<([u32; 4], usize)> { let mut octets = [0u32; 4]; let mut idx = 0; let mut current: u32 = 0; @@ -311,34 +328,39 @@ pub fn ip2long(ip: &str) -> Result { if !has_digit || idx >= 3 { return Err(Error::V4IP()); } - if current > 255 { - return Err(Error::V4IP()); - } octets[idx] = current; idx += 1; current = 0; has_digit = false; } else if b.is_ascii_digit() { - // Fast integer parsing: val * 10 + digit current = (current * 10) + (b - b'0') as u32; - // Early overflow check (u8 max is 255, so > 2550 is impossible loops, - // but we just check > 255 strictly) if current > 255 { return Err(Error::V4IP()); } has_digit = true; } else { - // Invalid character encountered return Err(Error::V4IP()); } } - // Handle the final segment logic if !has_digit { return Err(Error::V4IP()); } octets[idx] = current; - let count = idx + 1; + Ok((octets, idx + 1)) +} + +/// Convert a dotted-quad ip address to a network byte order 32 bit integer +/// +/// # Example +/// +/// ``` +/// use iptools::ipv4::ip2long; +/// assert_eq!(ip2long("127"), Ok(2130706432)); +/// assert_eq!(ip2long("127.0.0.256").is_err(), true); +/// ``` +pub fn ip2long(ip: &str) -> Result { + let (octets, count) = parse_ipv4_octets(ip)?; // Reconstruct based on your original shorthand logic: // 1 part ("10") -> 10.0.0.0 @@ -369,26 +391,14 @@ pub fn ip2long(ip: &str) -> Result { /// assert_eq!(ip2network("ravioli"), None); /// ``` pub fn ip2network(ip: &str) -> Option { - if !validate_ip_re(ip) { - return None; - } + let (octets, count) = parse_ipv4_octets(ip).ok()?; - let quads: ArrayVec<[u32; 4]> = ip - .split('.') - .filter_map(|w| w.parse().ok()) - .take(4) - .collect::>(); - - if quads.len() < 4 { - let mut netw: u32 = 0; - for i in 0..4 { - let val = quads.get(i).unwrap_or(&0); - netw = (netw << 8) | val; - } - Some(netw) - } else { - Some(((quads[0]) << 24) | ((quads[1]) << 16) | ((quads[2]) << 8) | (quads[3])) + let mut netw: u32 = 0; + for (i, &octet) in octets.iter().enumerate() { + let val = if i < count { octet } else { 0 }; + netw = (netw << 8) | val; } + Some(netw) } /// Convert a network byte order 32 bit integer to a dotted quad ip address @@ -400,8 +410,6 @@ pub fn ip2network(ip: &str) -> Option { /// assert_eq!(long2ip(2130706433), "127.0.0.1"); /// ``` pub fn long2ip(ip_dec: u32) -> alloc::string::String { - const MAX_IPV4_STRING_LEN: usize = 15; - let octets = [ ((ip_dec >> 24) & 0xFF) as u8, ((ip_dec >> 16) & 0xFF) as u8, @@ -409,17 +417,26 @@ pub fn long2ip(ip_dec: u32) -> alloc::string::String { (ip_dec & 0xFF) as u8, ]; - let mut result = alloc::string::String::with_capacity(MAX_IPV4_STRING_LEN); - let mut buf = Buffer::new(); - for (idx, oct) in octets.iter().enumerate() { - if idx > 0 { - result.push('.'); + let mut buf = alloc::vec::Vec::with_capacity(15); + + for (i, &octet) in octets.iter().enumerate() { + if i > 0 { + buf.push(b'.'); + } + + if octet >= 100 { + buf.push(b'0' + octet / 100); + buf.push(b'0' + (octet % 100) / 10); + buf.push(b'0' + octet % 10); + } else if octet >= 10 { + buf.push(b'0' + octet / 10); + buf.push(b'0' + octet % 10); + } else { + buf.push(b'0' + octet); } - let digits = buf.format(*oct); - result.push_str(digits); } - result + alloc::string::String::from_utf8(buf).unwrap() } /// /// # Example @@ -483,12 +500,21 @@ pub fn cidr2block(cidr: &str) -> Result<(alloc::string::String, alloc::string::S /// assert_eq!(netmask2prefix("255.128.0.0"), 9); /// ``` pub fn netmask2prefix(mask: &str) -> u32 { - if validate_netmask(mask) { - if let Some(result) = ip2network(mask) { - return bin_u32(result).matches('1').count() as u32; + parse_contiguous_netmask(mask) + .map(|value| value.count_ones()) + .unwrap_or(0) +} + +#[inline(always)] +fn parse_contiguous_netmask(mask: &str) -> Option { + ip2network(mask).and_then(|value| { + let inv = !value; + if (inv & inv.wrapping_add(1)) == 0 { + Some(value) + } else { + None } - } - 0 + }) } /// Convert a dotted-quad ip address including a netmask into a tuple containing the network block start and end addresses diff --git a/src/ipv4/tests.rs b/src/ipv4/tests.rs index 83fd0c3..8a55f07 100644 --- a/src/ipv4/tests.rs +++ b/src/ipv4/tests.rs @@ -21,6 +21,11 @@ fn test_validate_cidr() { assert_eq!(validate_cidr("127.0.0.0"), false); assert_eq!(validate_cidr(LOOPBACK), true); assert_eq!(validate_cidr("127.0.0.1/33"), false); + assert_eq!(validate_cidr(""), false); + assert_eq!(validate_cidr("127.0.0.1/"), false); + assert_eq!(validate_cidr("127.0.0.1/a"), false); + assert_eq!(validate_cidr("127.0.0.1/32/32"), false); + assert_eq!(validate_cidr(" 127.0.0.1/32"), false); } #[test] diff --git a/src/ipv6/mod.rs b/src/ipv6/mod.rs index 074438f..5574bb0 100755 --- a/src/ipv6/mod.rs +++ b/src/ipv6/mod.rs @@ -14,13 +14,11 @@ static HEX_RE: &lazy_regex::Lazy = static DOTTED_QUAD_RE: &lazy_regex::Lazy = regex!(r"^([0-9a-f]{0,4}:){2,6}(\d{1,3}\.){0,3}\d{1,3}$"); -// Regex for validating an IPv6 in hex notation +// Kept for compatibility with earlier public API +#[allow(dead_code)] static RE_RFC1924: &lazy_regex::Lazy = regex!(r"^[0-9A-Za-z!#$%&()*+-;<=>?@^_`{|}~]{20}$"); -// RFC 1924 reverse lookup -const _RFC1924_REV: bool = true; - /// Last ip pub const MAX_IP: u128 = u128::MAX; @@ -117,35 +115,24 @@ pub const MULTICAST_LOCAL_DHCP: &str = "ff02::1:2"; /// All DHCP servers and relay agents on the local site pub const MULTICAST_SITE_DHCP: &str = "ff05::1:3"; -// RFC 1924 alphabet -const _RFC1924_ALPHABET: &[char] = &[ - '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', - 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', 'a', 'b', - 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', - 'v', 'w', 'x', 'y', 'z', '!', '#', '$', '%', '&', '(', ')', '*', '+', '-', ';', '<', '=', '>', - '?', '@', '^', '_', '`', '{', '|', '}', '~', -]; +const RFC1924_ALPHABET_BYTES: &[u8; 85] = + b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz!#$%&()*+-;<=>?@^_`{|}~"; -static RFC1924_REV_TABLE: Lazy<[i8; 128]> = Lazy::new(|| { +const fn build_rfc1924_rev_table() -> [i8; 128] { let mut table = [-1i8; 128]; - for (i, &c) in _RFC1924_ALPHABET.iter().enumerate() { - if (c as usize) < 128 { - table[c as usize] = i as i8; + let mut i = 0; + while i < RFC1924_ALPHABET_BYTES.len() { + let byte = RFC1924_ALPHABET_BYTES[i] as usize; + if byte < 128 { + table[byte] = i as i8; } + i += 1; } table -}); - -fn rfc1924_rev_lookup(c: char) -> Option { - if (c as u32) < 128 { - let val = RFC1924_REV_TABLE[c as usize]; - if val >= 0 { - return Some(val as i32); - } - } - None } +const RFC1924_REV_TABLE: [i8; 128] = build_rfc1924_rev_table(); + /// Validate a hexidecimal IPV6 ip address using regex /// /// Note: This function uses regex matching. For better performance, @@ -440,9 +427,7 @@ pub fn long2ip(long_ip: u128, rfc1924: bool) -> alloc::string::String { } }; - // Use byte buffer for zero-allocation string building - let mut buf = [0u8; 39]; - let mut pos = 0; + let mut buf = alloc::vec::Vec::with_capacity(39); const HEX: &[u8; 16] = b"0123456789abcdef"; @@ -450,46 +435,42 @@ pub fn long2ip(long_ip: u128, rfc1924: bool) -> alloc::string::String { while i < 8 { // Handle compression if i == best_start { - buf[pos..pos + 2].copy_from_slice(b"::"); - pos += 2; + buf.extend_from_slice(b"::"); i = best_start + best_len; continue; } // Add separator - if pos > 0 && buf[pos - 1] != b':' { - buf[pos] = b':'; - pos += 1; + if let Some(&last) = buf.last() { + if last != b':' { + buf.push(b':'); + } } // Format hextet - optimized to avoid leading zeros let val = hextets[i]; if val >= 0x1000 { - buf[pos] = HEX[(val >> 12) as usize]; - buf[pos + 1] = HEX[((val >> 8) & 0xF) as usize]; - buf[pos + 2] = HEX[((val >> 4) & 0xF) as usize]; - buf[pos + 3] = HEX[(val & 0xF) as usize]; - pos += 4; + buf.push(HEX[(val >> 12) as usize]); + buf.push(HEX[((val >> 8) & 0xF) as usize]); + buf.push(HEX[((val >> 4) & 0xF) as usize]); + buf.push(HEX[(val & 0xF) as usize]); } else if val >= 0x100 { - buf[pos] = HEX[((val >> 8) & 0xF) as usize]; - buf[pos + 1] = HEX[((val >> 4) & 0xF) as usize]; - buf[pos + 2] = HEX[(val & 0xF) as usize]; - pos += 3; + buf.push(HEX[((val >> 8) & 0xF) as usize]); + buf.push(HEX[((val >> 4) & 0xF) as usize]); + buf.push(HEX[(val & 0xF) as usize]); } else if val >= 0x10 { - buf[pos] = HEX[((val >> 4) & 0xF) as usize]; - buf[pos + 1] = HEX[(val & 0xF) as usize]; - pos += 2; + buf.push(HEX[((val >> 4) & 0xF) as usize]); + buf.push(HEX[(val & 0xF) as usize]); } else { - buf[pos] = HEX[val as usize]; - pos += 1; + buf.push(HEX[val as usize]); } i += 1; } // Safe: buf contains only ASCII hex digits and colons, so this can never fail - alloc::string::String::from_utf8(buf[..pos].to_vec()).unwrap() + alloc::string::String::from_utf8(buf).unwrap() } /// Convert a network byte order 128 bit integer to an rfc1924 IPV6 address @@ -503,15 +484,19 @@ pub fn long2ip(long_ip: u128, rfc1924: bool) -> alloc::string::String { /// assert_eq!(long2rfc1924(ip2long("::").unwrap()), "00000000000000000000"); /// ``` pub fn long2rfc1924(long_ip: u128) -> alloc::string::String { - let mut o: ArrayVec<[char; 20]> = ArrayVec::new(); - let mut r = long_ip; - while r > 85 { - o.push(_RFC1924_ALPHABET[(r % 85) as usize]); - r /= 85; + let mut buf = [b'0'; 20]; + let mut idx = 20; + let mut value = long_ip; + + // Fill from the end to avoid reversing + while value > 0 { + let digit = (value % 85) as usize; + value /= 85; + idx -= 1; + buf[idx] = RFC1924_ALPHABET_BYTES[digit]; } - o.push(_RFC1924_ALPHABET[r as usize]); - o.reverse(); - alloc::format!("{:0>20}", o.into_iter().collect::()) + + alloc::string::String::from_utf8(buf.to_vec()).expect("alphabet is valid ASCII") } /// Convert an RFC1924 IPV6 address to a network byte order 128 bit integer @@ -525,19 +510,22 @@ pub fn long2rfc1924(long_ip: u128) -> alloc::string::String { /// assert_eq!(rfc19242long("pizza"), None); /// ``` pub fn rfc19242long(s: &str) -> Option { - if !RE_RFC1924.is_match(s) { + if s.len() != 20 { return None; } - let mut x = 0u128; - for c in s.chars() { - if let Some(mul_result) = x.checked_mul(85) { - let val = rfc1924_rev_lookup(c)?; - x = mul_result + val as u128; - } else { + + let mut acc = 0u128; + for b in s.bytes() { + if b >= 128 { + return None; + } + let val = RFC1924_REV_TABLE[b as usize]; + if val < 0 { return None; } + acc = acc.checked_mul(85)?.checked_add(val as u128)?; } - Some(x) + Some(acc) } /// Validate a CIDR notation ip address using regex @@ -596,30 +584,37 @@ pub fn validate_cidr_re(cidr: &str) -> bool { /// assert_eq!(validate_cidr("::/129"), false); /// ``` pub fn validate_cidr(cidr: &str) -> bool { + let bytes = cidr.as_bytes(); + let len = bytes.len(); + // Find the '/' separator - let Some(slash_pos) = cidr.bytes().position(|b| b == b'/') else { + let Some(slash_pos) = bytes.iter().position(|&b| b == b'/') else { return false; }; let ip_part = &cidr[..slash_pos]; - let mask_bytes = &cidr.as_bytes()[slash_pos + 1..]; // Early validation: mask must be 1-3 digits - if mask_bytes.is_empty() || mask_bytes.len() > 3 { + let mask_start = slash_pos + 1; + let mask_len = len.saturating_sub(mask_start); + if mask_len == 0 || mask_len > 3 { return false; } // Parse prefix manually (faster than parse::()) let mut prefix: u16 = 0; - for &b in mask_bytes { + for &b in &bytes[mask_start..] { if !b.is_ascii_digit() { return false; } prefix = prefix * 10 + (b - b'0') as u16; + if prefix > 128 { + return false; + } } - // Validate prefix range (0-128) and IP (using ip2long for fast validation) - prefix <= 128 && ip2long(ip_part).is_ok() + // Validate IP (using ip2long for fast validation) + ip2long(ip_part).is_ok() } /// Convert a CIDR notation ip address into a tuple containing the network block start and end addresses diff --git a/src/ipv6/tests.rs b/src/ipv6/tests.rs index cf7cbd7..db154d7 100644 --- a/src/ipv6/tests.rs +++ b/src/ipv6/tests.rs @@ -13,11 +13,13 @@ fn test_validate_ip() { "2001:db8::192.168.0.1", "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff", "::ffff:192.0.2.128", + "::ffff:0.0.0.0", + "::ffff:255.255.255.255", "1080:0:0:0:8:800:200c:417a", ] { assert!(validate_ip(good), "{good} rejected unexpectedly"); } - for bad in ["::ff::ff", "::fffff", "::ffff:192.0.2.300"] { + for bad in ["::ff::ff", "::fffff", "::ffff:192.0.2.300", ":", "1:::1"] { assert!(!validate_ip(bad), "{bad} accepted unexpectedly"); } } @@ -69,6 +71,52 @@ fn test_rfc19242long() { ); assert_eq!(rfc19242long("pizza"), None); assert_eq!(rfc19242long("=r54lj&NUUO~Hi%c2ym0"), Some(MAX_IP)); + assert_eq!( + rfc19242long("4)+k&C#VzJ4br>0wv%Yp"), + Some(ip2long("1080::8:800:200C:417A").unwrap()) + ); + assert_eq!( + rfc19242long("00000000000000000000"), + Some(ip2long("::").unwrap()) + ); + assert_eq!( + rfc19242long("=r54lj&NUUO~Hi%c2ym0"), + Some(ip2long("ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff").unwrap()) + ); + + // Overflow and invalid chars are rejected + assert!(rfc19242long("~~~~~~~~~~~~~~~~~~~~").is_none()); + assert!(rfc19242long("0000000000000000000\u{80}").is_none()); +} + +#[test] +fn test_rfc1924_roundtrip_sampled() { + let cases: &[u128] = &[ + 0, + 1, + 85, + 256, + 42_424, + 1_234_567_890, + 0x20010db8000000000000000000001234, + MAX_IP, + ]; + + for &value in cases { + let encoded = long2rfc1924(value); + let decoded = rfc19242long(&encoded); + assert_eq!( + decoded, + Some(value), + "roundtrip failed for {value} -> {encoded}" + ); + assert_eq!(encoded.len(), 20, "encoding length changed for {value}"); + } + + // Invalid length and charset coverage + assert!(rfc19242long("short").is_none()); + assert!(rfc19242long("!!!!!!!!!!!!!!!!!!!!!").is_none()); // 21 chars + assert!(rfc19242long("0000000000000000000/").is_none()); // bad character } #[test] @@ -98,6 +146,14 @@ fn test_validate_cidr() { } assert!(!validate_cidr("::")); assert!(!validate_cidr("::/129")); + assert!(validate_cidr("::/00")); // leading zeros are tolerated but mean the same value + assert!(validate_cidr("::/001")); + assert!(!validate_cidr("::/-1")); + assert!(!validate_cidr("")); + assert!(!validate_cidr("::/")); + assert!(!validate_cidr("::/a")); + assert!(!validate_cidr("::/128/128")); + assert!(!validate_cidr(" ::/128")); } #[test]