[System.Net] Introduce CIDR IPNetwork (#82779)
authorMurat Duisenbayev <muratduisenbayev@gmail.com>
Mon, 27 Mar 2023 23:25:56 +0000 (01:25 +0200)
committerGitHub <noreply@github.com>
Mon, 27 Mar 2023 23:25:56 +0000 (01:25 +0200)
src/libraries/System.Net.Primitives/ref/System.Net.Primitives.cs
src/libraries/System.Net.Primitives/src/Resources/Strings.resx
src/libraries/System.Net.Primitives/src/System.Net.Primitives.csproj
src/libraries/System.Net.Primitives/src/System/Net/IPAddress.cs
src/libraries/System.Net.Primitives/src/System/Net/IPNetwork.cs [new file with mode: 0644]
src/libraries/System.Net.Primitives/tests/FunctionalTests/IPNetworkTest.cs [new file with mode: 0644]
src/libraries/System.Net.Primitives/tests/FunctionalTests/System.Net.Primitives.Functional.Tests.csproj

index 393c089..5e329e0 100644 (file)
@@ -287,6 +287,32 @@ namespace System.Net
         public static bool TryParse(System.ReadOnlySpan<char> s, [System.Diagnostics.CodeAnalysis.NotNullWhenAttribute(true)] out System.Net.IPEndPoint? result) { throw null; }
         public static bool TryParse(string s, [System.Diagnostics.CodeAnalysis.NotNullWhenAttribute(true)] out System.Net.IPEndPoint? result) { throw null; }
     }
+    public readonly partial struct IPNetwork : System.IEquatable<System.Net.IPNetwork>, System.IFormattable, System.IParsable<System.Net.IPNetwork>, System.ISpanFormattable, System.ISpanParsable<System.Net.IPNetwork>
+    {
+        private readonly object _dummy;
+        private readonly int _dummyPrimitive;
+        public IPNetwork(System.Net.IPAddress baseAddress, int prefixLength) { throw null; }
+        public System.Net.IPAddress BaseAddress { get { throw null; } }
+        public int PrefixLength { get { throw null; } }
+        public bool Contains(System.Net.IPAddress address) { throw null; }
+        public bool Equals(System.Net.IPNetwork other) { throw null; }
+        public override bool Equals([System.Diagnostics.CodeAnalysis.NotNullWhenAttribute(true)] object? obj) { throw null; }
+        public override int GetHashCode() { throw null; }
+        public static bool operator ==(System.Net.IPNetwork left, System.Net.IPNetwork right) { throw null; }
+        public static bool operator !=(System.Net.IPNetwork left, System.Net.IPNetwork right) { throw null; }
+        public static System.Net.IPNetwork Parse(System.ReadOnlySpan<char> s) { throw null; }
+        public static System.Net.IPNetwork Parse(string s) { throw null; }
+        string System.IFormattable.ToString(string? format, System.IFormatProvider? provider) { throw null; }
+        static System.Net.IPNetwork System.IParsable<System.Net.IPNetwork>.Parse([System.Diagnostics.CodeAnalysis.NotNullAttribute] string s, System.IFormatProvider? provider) { throw null; }
+        static bool System.IParsable<System.Net.IPNetwork>.TryParse([System.Diagnostics.CodeAnalysis.NotNullWhenAttribute(true)] string? s, System.IFormatProvider? provider, out System.Net.IPNetwork result) { throw null; }
+        bool System.ISpanFormattable.TryFormat(System.Span<char> destination, out int charsWritten, System.ReadOnlySpan<char> format, System.IFormatProvider? provider) { throw null; }
+        static System.Net.IPNetwork System.ISpanParsable<System.Net.IPNetwork>.Parse(System.ReadOnlySpan<char> s, System.IFormatProvider? provider) { throw null; }
+        static bool System.ISpanParsable<System.Net.IPNetwork>.TryParse(System.ReadOnlySpan<char> s, System.IFormatProvider? provider, out System.Net.IPNetwork result) { throw null; }
+        public override string ToString() { throw null; }
+        public bool TryFormat(System.Span<char> destination, out int charsWritten) { throw null; }
+        public static bool TryParse(System.ReadOnlySpan<char> s, out System.Net.IPNetwork result) { throw null; }
+        public static bool TryParse(string? s, out System.Net.IPNetwork result) { throw null; }
+    }
     public partial interface IWebProxy
     {
         System.Net.ICredentials? Credentials { get; set; }
index 5a99897..958a0e2 100644 (file)
   <data name="dns_bad_ip_address" xml:space="preserve">
     <value>An invalid IP address was specified.</value>
   </data>
+  <data name="net_bad_ip_network" xml:space="preserve">
+    <value>An invalid IP network was specified.</value>
+  </data>
+  <data name="net_bad_ip_network_invalid_baseaddress" xml:space="preserve">
+    <value>The specified baseAddress has non-zero bits after the network prefix.</value>
+  </data>
   <data name="net_container_add_cookie" xml:space="preserve">
     <value>An error occurred when adding a cookie to the container.</value>
   </data>
index 46fd9cc..47385e4 100644 (file)
@@ -28,6 +28,7 @@
     <Compile Include="System\Net\ICredentials.cs" />
     <Compile Include="System\Net\ICredentialsByHost.cs" />
     <Compile Include="System\Net\IPAddress.cs" />
+    <Compile Include="System\Net\IPNetwork.cs" />
     <Compile Include="System\Net\IPAddressParser.cs" />
     <Compile Include="System\Net\IPEndPoint.cs" />
     <Compile Include="$(CommonPath)System\Net\IPv4AddressHelper.Common.cs"
index 3985150..81b0851 100644 (file)
@@ -69,14 +69,14 @@ namespace System.Net
             get { return _numbers != null; }
         }
 
-        private uint PrivateAddress
+        internal uint PrivateAddress
         {
             get
             {
                 Debug.Assert(IsIPv4);
                 return _addressOrScopeId;
             }
-            set
+            private set
             {
                 Debug.Assert(IsIPv4);
                 _toString = null;
diff --git a/src/libraries/System.Net.Primitives/src/System/Net/IPNetwork.cs b/src/libraries/System.Net.Primitives/src/System/Net/IPNetwork.cs
new file mode 100644 (file)
index 0000000..58f772c
--- /dev/null
@@ -0,0 +1,314 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Buffers.Binary;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
+using System.Net.Sockets;
+using System.Runtime.InteropServices;
+
+#pragma warning disable SA1648 // TODO: https://github.com/DotNetAnalyzers/StyleCopAnalyzers/issues/3595
+
+namespace System.Net
+{
+    /// <summary>
+    /// Represents an IP network with an <see cref="IPAddress"/> containing the network prefix and an <see cref="int"/> defining the prefix length.
+    /// </summary>
+    /// <remarks>
+    /// This type disallows arbitrary IP-address/prefix-length CIDR pairs. <see cref="BaseAddress"/> must be defined so that all bits after the network prefix are set to zero.
+    /// In other words, <see cref="BaseAddress"/> is always the first usable address of the network.
+    /// The constructor and the parsing methods will throw in case there are non-zero bits after the prefix.
+    /// </remarks>
+    public readonly struct IPNetwork : IEquatable<IPNetwork>, ISpanFormattable, ISpanParsable<IPNetwork>
+    {
+        private readonly IPAddress? _baseAddress;
+
+        /// <summary>
+        /// Gets the <see cref="IPAddress"/> that represents the prefix of the network.
+        /// </summary>
+        public IPAddress BaseAddress => _baseAddress ?? IPAddress.Any;
+
+        /// <summary>
+        /// Gets the length of the network prefix in bits.
+        /// </summary>
+        public int PrefixLength { get; }
+
+        /// <summary>
+        /// Initializes a new instance of the <see cref="IPNetwork"/> class with the specified <see cref="IPAddress"/> and prefix length.
+        /// </summary>
+        /// <param name="baseAddress">The <see cref="IPAddress"/> that represents the prefix of the network.</param>
+        /// <param name="prefixLength">The length of the prefix in bits.</param>
+        /// <exception cref="ArgumentNullException">The specified <paramref name="baseAddress"/> is <see langword="null"/>.</exception>
+        /// <exception cref="ArgumentOutOfRangeException">The specified <paramref name="prefixLength"/> is smaller than `0` or longer than maximum length of <paramref name="prefixLength"/>'s <see cref="AddressFamily"/>.</exception>
+        /// <exception cref="ArgumentException">The specified <paramref name="baseAddress"/> has non-zero bits after the network prefix.</exception>
+        public IPNetwork(IPAddress baseAddress, int prefixLength)
+        {
+            ArgumentNullException.ThrowIfNull(baseAddress);
+
+            if (prefixLength < 0 || prefixLength > GetMaxPrefixLength(baseAddress))
+            {
+                ThrowArgumentOutOfRangeException();
+            }
+
+            if (HasNonZeroBitsAfterNetworkPrefix(baseAddress, prefixLength))
+            {
+                ThrowInvalidBaseAddressException();
+            }
+
+            _baseAddress = baseAddress;
+            PrefixLength = prefixLength;
+
+            [DoesNotReturn]
+            static void ThrowArgumentOutOfRangeException() => throw new ArgumentOutOfRangeException(nameof(prefixLength));
+
+            [DoesNotReturn]
+            static void ThrowInvalidBaseAddressException() => throw new ArgumentException(SR.net_bad_ip_network_invalid_baseaddress, nameof(baseAddress));
+        }
+
+        // Non-validating ctor
+        private IPNetwork(IPAddress baseAddress, int prefixLength, bool _)
+        {
+            _baseAddress = baseAddress;
+            PrefixLength = prefixLength;
+        }
+
+        /// <summary>
+        /// Determines whether a given <see cref="IPAddress"/> is part of the network.
+        /// </summary>
+        /// <param name="address">The <see cref="IPAddress"/> to check.</param>
+        /// <returns><see langword="true"/> if the <see cref="IPAddress"/> is part of the network; otherwise, <see langword="false"/>.</returns>
+        /// <exception cref="ArgumentNullException">The specified <paramref name="address"/> is <see langword="null"/>.</exception>
+        public bool Contains(IPAddress address)
+        {
+            ArgumentNullException.ThrowIfNull(address);
+
+            if (address.AddressFamily != BaseAddress.AddressFamily)
+            {
+                return false;
+            }
+
+            // This prevents the 'uint.MaxValue << 32' and the 'UInt128.MaxValue << 128' special cases in the code below.
+            if (PrefixLength == 0)
+            {
+                return true;
+            }
+
+            if (address.AddressFamily == AddressFamily.InterNetwork)
+            {
+                uint mask = uint.MaxValue << (32 - PrefixLength);
+                if (BitConverter.IsLittleEndian)
+                {
+                    mask = BinaryPrimitives.ReverseEndianness(mask);
+                }
+
+                return BaseAddress.PrivateAddress == (address.PrivateAddress & mask);
+            }
+            else
+            {
+                UInt128 baseAddressValue = default;
+                UInt128 otherAddressValue = default;
+
+                BaseAddress.TryWriteBytes(MemoryMarshal.AsBytes(new Span<UInt128>(ref baseAddressValue)), out int bytesWritten);
+                Debug.Assert(bytesWritten == IPAddressParserStatics.IPv6AddressBytes);
+                address.TryWriteBytes(MemoryMarshal.AsBytes(new Span<UInt128>(ref otherAddressValue)), out bytesWritten);
+                Debug.Assert(bytesWritten == IPAddressParserStatics.IPv6AddressBytes);
+
+                UInt128 mask = UInt128.MaxValue << (128 - PrefixLength);
+                if (BitConverter.IsLittleEndian)
+                {
+                    mask = BinaryPrimitives.ReverseEndianness(mask);
+                }
+
+                return baseAddressValue == (otherAddressValue & mask);
+            }
+        }
+
+        /// <summary>
+        /// Converts a CIDR <see cref="string"/> to an <see cref="IPNetwork"/> instance.
+        /// </summary>
+        /// <param name="s">A <see cref="string"/> that defines an IP network in CIDR notation.</param>
+        /// <returns>An <see cref="IPNetwork"/> instance.</returns>
+        /// <exception cref="ArgumentNullException">The specified string is <see langword="null"/>.</exception>
+        /// <exception cref="FormatException"><paramref name="s"/> is not a valid CIDR network string, or the address contains non-zero bits after the network prefix.</exception>
+        public static IPNetwork Parse(string s)
+        {
+            ArgumentNullException.ThrowIfNull(s);
+            return Parse(s.AsSpan());
+        }
+
+        /// <summary>
+        /// Converts a CIDR character span to an <see cref="IPNetwork"/> instance.
+        /// </summary>
+        /// <param name="s">A character span that defines an IP network in CIDR notation.</param>
+        /// <returns>An <see cref="IPNetwork"/> instance.</returns>
+        /// <exception cref="FormatException"><paramref name="s"/> is not a valid CIDR network string, or the address contains non-zero bits after the network prefix.</exception>
+        public static IPNetwork Parse(ReadOnlySpan<char> s)
+        {
+            if (!TryParse(s, out IPNetwork result))
+            {
+                throw new FormatException(SR.net_bad_ip_network);
+            }
+
+            return result;
+        }
+
+        /// <summary>
+        /// Converts the specified CIDR string to an <see cref="IPNetwork"/> instance and returns a value indicating whether the conversion succeeded.
+        /// </summary>
+        /// <param name="s">A <see cref="string"/> that defines an IP network in CIDR notation.</param>
+        /// <param name="result">When the method returns, contains an <see cref="IPNetwork"/> instance if the conversion succeeds.</param>
+        /// <returns><see langword="true"/> if the conversion was succesful; otherwise, <see langword="false"/>.</returns>
+        public static bool TryParse(string? s, out IPNetwork result)
+        {
+            if (s == null)
+            {
+                result = default;
+                return false;
+            }
+
+            return TryParse(s.AsSpan(), out result);
+        }
+
+        /// <summary>
+        /// Converts the specified CIDR character span to an <see cref="IPNetwork"/> instance and returns a value indicating whether the conversion succeeded.
+        /// </summary>
+        /// <param name="s">A <see cref="string"/> that defines an IP network in CIDR notation.</param>
+        /// <param name="result">When the method returns, contains an <see cref="IPNetwork"/> instance if the conversion succeeds.</param>
+        /// <returns><see langword="true"/> if the conversion was succesful; otherwise, <see langword="false"/>.</returns>
+        public static bool TryParse(ReadOnlySpan<char> s, out IPNetwork result)
+        {
+            int separatorIndex = s.LastIndexOf('/');
+            if (separatorIndex >= 0)
+            {
+                ReadOnlySpan<char> ipAddressSpan = s.Slice(0, separatorIndex);
+                ReadOnlySpan<char> prefixLengthSpan = s.Slice(separatorIndex + 1);
+
+                if (IPAddress.TryParse(ipAddressSpan, out IPAddress? address) &&
+                    int.TryParse(prefixLengthSpan, NumberStyles.None, CultureInfo.InvariantCulture, out int prefixLength) &&
+                    prefixLength <= GetMaxPrefixLength(address) &&
+                    !HasNonZeroBitsAfterNetworkPrefix(address, prefixLength))
+                {
+                    Debug.Assert(prefixLength >= 0); // Parsing with NumberStyles.None should ensure that prefixLength is always non-negative.
+                    result = new IPNetwork(address, prefixLength, false);
+                    return true;
+                }
+            }
+
+            result = default;
+            return false;
+        }
+
+        private static int GetMaxPrefixLength(IPAddress baseAddress) => baseAddress.AddressFamily == AddressFamily.InterNetwork ? 32 : 128;
+
+        private static bool HasNonZeroBitsAfterNetworkPrefix(IPAddress baseAddress, int prefixLength)
+        {
+            if (baseAddress.AddressFamily == AddressFamily.InterNetwork)
+            {
+                // The cast to long ensures that the mask becomes 0 for the case where 'prefixLength == 0'.
+                uint mask = (uint)((long)uint.MaxValue << (32 - prefixLength));
+                if (BitConverter.IsLittleEndian)
+                {
+                    mask = BinaryPrimitives.ReverseEndianness(mask);
+                }
+
+                return (baseAddress.PrivateAddress & mask) != baseAddress.PrivateAddress;
+            }
+            else
+            {
+                UInt128 value = default;
+                baseAddress.TryWriteBytes(MemoryMarshal.AsBytes(new Span<UInt128>(ref value)), out int bytesWritten);
+                Debug.Assert(bytesWritten == IPAddressParserStatics.IPv6AddressBytes);
+                if (prefixLength == 0)
+                {
+                    return value != UInt128.Zero;
+                }
+
+                UInt128 mask = UInt128.MaxValue << (128 - prefixLength);
+                if (BitConverter.IsLittleEndian)
+                {
+                    mask = BinaryPrimitives.ReverseEndianness(mask);
+                }
+
+                return (value & mask) != value;
+            }
+        }
+
+        /// <summary>
+        /// Converts the instance to a string containing the <see cref="IPNetwork"/>'s CIDR notation.
+        /// </summary>
+        /// <returns>The <see cref="string"/> containing the <see cref="IPNetwork"/>'s CIDR notation.</returns>
+        public override string ToString() =>
+            string.Create(CultureInfo.InvariantCulture, stackalloc char[128], $"{BaseAddress}/{(uint)PrefixLength}");
+
+        /// <summary>
+        /// Attempts to write the <see cref="IPNetwork"/>'s CIDR notation to the given <paramref name="destination"/> span and returns a value indicating whether the operation succeeded.
+        /// </summary>
+        /// <param name="destination">The destination span of characters.</param>
+        /// <param name="charsWritten">When this method returns, contains the number of characters that were written to <paramref name="destination"/>.</param>
+        /// <returns><see langword="true"/> if the formatting was succesful; otherwise <see langword="false"/>.</returns>
+        public bool TryFormat(Span<char> destination, out int charsWritten) =>
+            destination.TryWrite(CultureInfo.InvariantCulture, $"{BaseAddress}/{(uint)PrefixLength}", out charsWritten);
+
+        /// <summary>
+        /// Determines whether two <see cref="IPNetwork"/> instances are equal.
+        /// </summary>
+        /// <param name="other">The <see cref="IPNetwork"/> instance to compare to this instance.</param>
+        /// <returns><see langword="true"/> if the networks are equal; otherwise <see langword="false"/>.</returns>
+        /// <exception cref="InvalidOperationException">Uninitialized <see cref="IPNetwork"/> instance.</exception>
+        public bool Equals(IPNetwork other) =>
+            PrefixLength == other.PrefixLength &&
+            BaseAddress.Equals(other.BaseAddress);
+
+        /// <summary>
+        /// Determines whether two <see cref="IPNetwork"/> instances are equal.
+        /// </summary>
+        /// <param name="obj">The <see cref="IPNetwork"/> instance to compare to this instance.</param>
+        /// <returns><see langword="true"/> if <paramref name="obj"/> is an <see cref="IPNetwork"/> instance and the networks are equal; otherwise <see langword="false"/>.</returns>
+        /// <exception cref="InvalidOperationException">Uninitialized <see cref="IPNetwork"/> instance.</exception>
+        public override bool Equals([NotNullWhen(true)] object? obj) =>
+            obj is IPNetwork other &&
+            Equals(other);
+
+        /// <summary>
+        /// Determines whether the specified instances of <see cref="IPNetwork"/> are equal.
+        /// </summary>
+        /// <param name="left"></param>
+        /// <param name="right"></param>
+        /// <returns><see langword="true"/> if the networks are equal; otherwise <see langword="false"/>.</returns>
+        public static bool operator ==(IPNetwork left, IPNetwork right) => left.Equals(right);
+
+        /// <summary>
+        /// Determines whether the specified instances of <see cref="IPNetwork"/> are not equal.
+        /// </summary>
+        /// <param name="left"></param>
+        /// <param name="right"></param>
+        /// <returns><see langword="true"/> if the networks are not equal; otherwise <see langword="false"/>.</returns>
+        public static bool operator !=(IPNetwork left, IPNetwork right) => !(left == right);
+
+        /// <summary>
+        /// Returns the hash code for this instance.
+        /// </summary>
+        /// <returns>An integer hash value.</returns>
+        public override int GetHashCode() => HashCode.Combine(BaseAddress, PrefixLength);
+
+        /// <inheritdoc />
+        string IFormattable.ToString(string? format, IFormatProvider? provider) => ToString();
+
+        /// <inheritdoc />
+        bool ISpanFormattable.TryFormat(Span<char> destination, out int charsWritten, ReadOnlySpan<char> format, IFormatProvider? provider) =>
+            TryFormat(destination, out charsWritten);
+
+        /// <inheritdoc />
+        static IPNetwork IParsable<IPNetwork>.Parse([NotNull] string s, IFormatProvider? provider) => Parse(s);
+
+        /// <inheritdoc />
+        static bool IParsable<IPNetwork>.TryParse([NotNullWhen(true)] string? s, IFormatProvider? provider, out IPNetwork result) => TryParse(s, out result);
+
+        /// <inheritdoc />
+        static IPNetwork ISpanParsable<IPNetwork>.Parse(ReadOnlySpan<char> s, IFormatProvider? provider) => Parse(s);
+
+        /// <inheritdoc />
+        static bool ISpanParsable<IPNetwork>.TryParse(ReadOnlySpan<char> s, IFormatProvider? provider, out IPNetwork result) => TryParse(s, out result);
+    }
+}
diff --git a/src/libraries/System.Net.Primitives/tests/FunctionalTests/IPNetworkTest.cs b/src/libraries/System.Net.Primitives/tests/FunctionalTests/IPNetworkTest.cs
new file mode 100644 (file)
index 0000000..5643473
--- /dev/null
@@ -0,0 +1,276 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using Xunit;
+
+namespace System.Net.Primitives.Functional.Tests
+{
+    public class IPNetworkTest
+    {
+        public static TheoryData<string> IncorrectFormatData = new TheoryData<string>()
+        {
+            { "127.0.0.1" },
+            { "A.B.C.D/24" },
+            { "127.0.0.1/AB" },
+            { "127.0.0.1/-1" },
+            { "127.0.0.1/+1" },
+            { "2a01:110:8012::/f" },
+            { "" },
+        };
+
+        public static TheoryData<string> InvalidNetworkNotationData = new TheoryData<string>()
+        {
+            { "127.0.0.1/33" }, // PrefixLength max is 32 for IPv4
+            { "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff/129" }, // PrefixLength max is 128 for IPv6
+            { "127.0.0.1/31" }, // Bits exceed the prefix length of 31 (32nd bit is on)
+            { "198.51.255.0/23" }, // Bits exceed the prefix length of 23
+            { "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff/127" }, // Bits exceed the prefix length of 31
+            { "2a01:110:8012::/45" }, // Bits exceed the prefix length of 45 (47th bit is on)
+        };
+
+        public static TheoryData<string> ValidIPNetworkData = new TheoryData<string>()
+        {
+            { "0.0.0.0/32" }, // the whole IPv4 space
+            { "0.0.0.0/0" },
+            { "128.0.0.0/1" },
+            { "::/128" }, // the whole IPv6 space
+            { "255.255.255.255/32" },
+            { "198.51.254.0/23" },
+            { "42.42.128.0/17" },
+            { "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff/128" },
+            { "2a01:110:8012::/47" },
+            { "2a01:110:8012::/100" },
+        };
+
+        [Theory]
+        [MemberData(nameof(ValidIPNetworkData))]
+        public void Constructor_Valid_Succeeds(string input)
+        {
+            string[] splitInput = input.Split('/');
+            IPAddress address = IPAddress.Parse(splitInput[0]);
+            int prefixLegth = int.Parse(splitInput[1]);
+
+            IPNetwork network = new IPNetwork(address, prefixLegth);
+
+            Assert.Equal(address, network.BaseAddress);
+            Assert.Equal(prefixLegth, network.PrefixLength);
+        }
+
+        [Fact]
+        public void Constructor_NullIPAddress_ThrowsArgumentNullException()
+        {
+            Assert.Throws<ArgumentNullException>(() => new IPNetwork(null, 1));
+        }
+
+        [Theory]
+        [InlineData("192.168.0.1", -1)]
+        [InlineData("192.168.0.1", 33)]
+        [InlineData("::", -1)]
+        [InlineData("ffff::", 129)]
+        public void Constructor_PrefixLenghtOutOfRange_ThrowsArgumentOutOfRangeException(string ipStr, int prefixLength)
+        {
+            IPAddress address = IPAddress.Parse(ipStr);
+            Assert.Throws<ArgumentOutOfRangeException>(() => new IPNetwork(address, prefixLength));
+        }
+
+        [Theory]
+        [InlineData("192.168.0.1", 31)]
+        [InlineData("42.42.192.0", 17)]
+        [InlineData("128.0.0.0", 0)]
+        [InlineData("2a01:110:8012::", 46)]
+        public void Constructor_NonZeroBitsAfterNetworkPrefix_ThrowsArgumentException(string ipStr, int prefixLength)
+        {
+            IPAddress address = IPAddress.Parse(ipStr);
+            Assert.Throws<ArgumentException>(() => new IPNetwork(address, prefixLength));
+        }
+
+        [Theory]
+        [MemberData(nameof(IncorrectFormatData))]
+        public void Parse_IncorrectFormat_ThrowsFormatException(string input)
+        {
+            Assert.Throws<FormatException>(() => IPNetwork.Parse(input));
+        }
+
+        [Theory]
+        [MemberData(nameof(IncorrectFormatData))]
+        public void TryParse_IncorrectFormat_ReturnsFalse(string input)
+        {
+            Assert.False(IPNetwork.TryParse(input, out _));
+        }
+
+        [Theory]
+        [MemberData(nameof(InvalidNetworkNotationData))]
+        public void Parse_InvalidNetworkNotation_ThrowsFormatException(string input)
+        {
+            Assert.Throws<FormatException>(() => IPNetwork.Parse(input));
+        }
+
+        [Theory]
+        [MemberData(nameof(InvalidNetworkNotationData))]
+        public void TryParse_InvalidNetworkNotation_ReturnsFalse(string input)
+        {
+            Assert.False(IPNetwork.TryParse(input, out _));
+        }
+
+        [Theory]
+        [MemberData(nameof(ValidIPNetworkData))]
+        public void Parse_ValidNetworkNotation_Succeeds(string input)
+        {
+            var network = IPNetwork.Parse(input);
+            Assert.Equal(input, network.ToString());
+        }
+
+        [Theory]
+        [MemberData(nameof(ValidIPNetworkData))]
+        public void TryParse_ValidNetworkNotation_Succeeds(string input)
+        {
+            Assert.True(IPNetwork.TryParse(input, out IPNetwork network));
+            Assert.Equal(input, network.ToString());
+        }
+
+        [Fact]
+        public void Contains_Null_ThrowsArgumentNullException()
+        {
+            IPNetwork v4 = IPNetwork.Parse("127.0.0.0/8");
+            IPNetwork v6 = IPNetwork.Parse("::1/128");
+
+            Assert.Throws<ArgumentNullException>(() => v4.Contains(null));
+            Assert.Throws<ArgumentNullException>(() => v6.Contains(null));
+        }
+
+        [Fact]
+        public void Contains_DifferentAddressFamily_ReturnsFalse()
+        {
+            IPNetwork network = IPNetwork.Parse("ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff/128");
+            Assert.False(network.Contains(IPAddress.Loopback));
+        }
+
+        [Theory]
+        [InlineData("0.0.0.0/0", "0.0.0.0", "127.127.127.127", "255.255.255.255")] // the whole IPv4 space
+        [InlineData("::/0", "::", "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff")] // the whole IPv6 space
+        [InlineData("255.255.255.255/32", "255.255.255.255")] // single IPv4 address
+        [InlineData("ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff/128", "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff")] // single IPv6 address
+        [InlineData("255.255.255.0/24", "255.255.255.0", "255.255.255.255")]
+        [InlineData("198.51.248.0/22", "198.51.248.0", "198.51.250.42", "198.51.251.255")]
+        [InlineData("255.255.255.128/25", "255.255.255.128", "255.255.255.129", "255.255.255.255")]
+        [InlineData("2a00::/13", "2a00::", "2a00::1", "2a01::", "2a07::", "2a07:ffff:ffff:ffff:ffff:ffff:ffff:ffff")]
+        [InlineData("2a01:110:8012::/47", "2a01:110:8012::", "2a01:110:8012:42::", "2a01:110:8013::", "2a01:110:8013:ffff:ffff:ffff:ffff:ffff")]
+        [InlineData("2a01:110:8012:1012:314f:2a00::/87", "2a01:110:8012:1012:314f:2a00::", "2a01:110:8012:1012:314f:2a00::1", "2a01:110:8012:1012:314f:2a00:abcd:4242", "2a01:110:8012:1012:314f:2bff:ffff:ffff")]
+        [InlineData("2a01:110:8012:1010:914e:2451:1700:0/105", "2a01:110:8012:1010:914e:2451:1700:0", "2a01:110:8012:1010:914e:2451:1742:4242", "2a01:110:8012:1010:914e:2451:177f:ffff")]
+        public void Contains_WhenInNework_ReturnsTrue(string networkString, params string[] addresses)
+        {
+            var network = IPNetwork.Parse(networkString);
+
+            foreach (string address in addresses)
+            {
+                Assert.True(network.Contains(IPAddress.Parse(address)));
+            }
+        }
+
+        [Theory]
+        [InlineData("255.255.255.255/32", "255.255.255.254")] // single IPv4 address
+        [InlineData("ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff/128", "ffff:ffff:ffff:ffff:ffff:ffff:ffff:fffe")] // single IPv6 address
+        [InlineData("255.255.255.0/24", "255.255.254.0")]
+        [InlineData("198.51.248.0/22", "198.50.248.1", "198.52.248.1", "198.51.247.1", "198.51.252.1")]
+        [InlineData("255.255.255.128/25", "255.255.255.127")]
+        [InlineData("2a00::/13", "2900:ffff:ffff:ffff:ffff:ffff:ffff:ffff", "2a08::", "2a10::", "3a00::", "2b00::")]
+        [InlineData("2a01:110:8012::/47", "2a01:110:8011:1::", "2a01:110:8014::", "2a00:110:8012::1", "2a01:111:8012::")]
+        [InlineData("2a01:110:8012:1012:314f:2a00::/87", "2a01:110:8012:1012:314f:2c00::", "2a01:110:8012:1012:314f:2900::", "2a01:110:8012:1012:324f:2aff:ffff:ffff")]
+        [InlineData("2a01:110:8012:1010:914e:2451:1700:0/105", "2a01:110:8012:1010:914e:2451:16ff:ffff", "2a01:110:8012:1010:914e:2451:1780:0", "2a01:110:8013:1010:914e:2451:1700:0")]
+        public void Contains_WhenNotInNetwork_ReturnsFalse(string networkString, params string[] addresses)
+        {
+            var network = IPNetwork.Parse(networkString);
+
+            foreach (string address in addresses)
+            {
+                Assert.False(network.Contains(IPAddress.Parse(address)));
+            }
+        }
+
+        [Theory]
+        [InlineData(false)]
+        [InlineData(true)]
+        public void Equals_WhenDifferent_ReturnsFalse(bool testOperator)
+        {
+            var network = IPNetwork.Parse("127.0.0.0/24");
+
+            var rangeWithDifferentPrefix = IPNetwork.Parse("127.0.1.0/24");
+            var rangeWithDifferentPrefixLength = IPNetwork.Parse("127.0.0.0/25");
+
+            if (testOperator)
+            {
+                Assert.False(network == rangeWithDifferentPrefix);
+                Assert.False(network == rangeWithDifferentPrefixLength);
+                Assert.True(network != rangeWithDifferentPrefix);
+                Assert.True(network != rangeWithDifferentPrefixLength);
+            }
+            else
+            {
+                Assert.False(network.Equals(rangeWithDifferentPrefix));
+                Assert.False(network.Equals(rangeWithDifferentPrefixLength));
+
+                Assert.False(network.Equals((object)rangeWithDifferentPrefix));
+                Assert.False(network.Equals((object)rangeWithDifferentPrefixLength));
+            }
+        }
+
+        [Theory]
+        [InlineData("127.0.0.0/24")]
+        [InlineData("2a01:110:8012::/47")]
+        public void EqualiyMethods_WhenEqual(string input)
+        {
+            var a = IPNetwork.Parse(input);
+            var b = IPNetwork.Parse(input);
+
+            Assert.True(a.Equals(b));
+            Assert.True(a.Equals((object)b));
+            Assert.True(a == b);
+            Assert.False(a != b);
+            Assert.Equal(a.GetHashCode(), b.GetHashCode());
+        }
+
+        [Fact]
+        public void Equals_WhenNull_ReturnsFalse()
+        {
+            var network = IPNetwork.Parse("127.0.0.0/24");
+
+            Assert.False(network.Equals(null));
+        }
+
+        [Fact]
+        public void TryFormatSpan_EnoughLength_Succeeds()
+        {
+            var input = "127.0.0.0/24";
+            var network = IPNetwork.Parse(input);
+
+            Span<char> span = stackalloc char[15]; // IPAddress.TryFormat requires a size of 15
+
+            Assert.True(network.TryFormat(span, out int charsWritten));
+            Assert.Equal(input.Length, charsWritten);
+            Assert.Equal(input, span.Slice(0, charsWritten).ToString());
+        }
+
+        [Theory]
+        [InlineData("127.127.127.127/32", 15)]
+        [InlineData("127.127.127.127/32", 0)]
+        [InlineData("127.127.127.127/32", 1)]
+        public void TryFormatSpan_NotEnoughLength_ReturnsFalse(string input, int spanLengthToTest)
+        {
+            var network = IPNetwork.Parse(input);
+
+            Span<char> span = stackalloc char[spanLengthToTest];
+
+            Assert.False(network.TryFormat(span, out int charsWritten));
+        }
+
+        [Fact]
+        public void DefaultInstance_IsValid()
+        {
+            IPNetwork network = default;
+            Assert.Equal(IPAddress.Any, network.BaseAddress);
+            Assert.Equal(default, network);
+            Assert.NotEqual(IPNetwork.Parse("10.20.30.0/24"), network);
+            Assert.True(network.Contains(IPAddress.Parse("10.11.12.13")));
+        }
+    }
+}
index 628da5b..86fc255 100644 (file)
@@ -17,6 +17,7 @@
     <Compile Include="IPAddressParsing.cs" />
     <Compile Include="IPAddressSpanTest.cs" />
     <Compile Include="IPAddressTest.cs" />
+    <Compile Include="IPNetworkTest.cs" />
     <Compile Include="IPEndPointParsing.cs" />
     <Compile Include="IPEndPointTest.cs" />
     <Compile Include="NetworkCredentialTest.cs" />