From 4452d0f25761f0e738e4e7a5dc526a1384a3a09b Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 5 Dec 2025 10:43:10 +0200 Subject: [PATCH 01/74] Added error handling cases to functions --- TechnitiumLibrary/Base32.cs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/TechnitiumLibrary/Base32.cs b/TechnitiumLibrary/Base32.cs index e5d6b8b2..ba0a994c 100644 --- a/TechnitiumLibrary/Base32.cs +++ b/TechnitiumLibrary/Base32.cs @@ -61,6 +61,11 @@ static Base32() private static string Encode(Span data, char[] map, bool skipPadding) { + if (data == null) + { + throw new ArgumentNullException("data"); + } + StringBuilder sb = new StringBuilder(); int r = data.Length % 5; @@ -154,6 +159,19 @@ private static string Encode(Span data, char[] map, bool skipPadding) private static byte[] Decode(string data, int[] rmap) { + if (data is null) + { + throw new ArgumentNullException(nameof(data)); + } + if (data == string.Empty) + { + return Array.Empty(); + } + if (data.Contains(' ')) + { + throw new ArgumentException(nameof(data)); + } + byte[] buffer; int paddingCount = 0; @@ -317,4 +335,4 @@ public static byte[] FromBase32HexString(string data) #endregion } -} +} \ No newline at end of file From 63db9b4a1e2df9dc90d39cc8ab10d69dd875d794 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 5 Dec 2025 10:43:35 +0200 Subject: [PATCH 02/74] Added first unit tests --- TechnitiumLibrary.Tests/Base32Tests.cs | 236 ++++++++++++++++++ TechnitiumLibrary.Tests/MSTestSettings.cs | 3 + .../TechnitiumLibrary.Tests.csproj | 15 ++ TechnitiumLibrary.sln | 10 +- 4 files changed, 262 insertions(+), 2 deletions(-) create mode 100644 TechnitiumLibrary.Tests/Base32Tests.cs create mode 100644 TechnitiumLibrary.Tests/MSTestSettings.cs create mode 100644 TechnitiumLibrary.Tests/TechnitiumLibrary.Tests.csproj diff --git a/TechnitiumLibrary.Tests/Base32Tests.cs b/TechnitiumLibrary.Tests/Base32Tests.cs new file mode 100644 index 00000000..3a5c0e22 --- /dev/null +++ b/TechnitiumLibrary.Tests/Base32Tests.cs @@ -0,0 +1,236 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Text; +using System; +using TechnitiumLibrary; + +namespace TechnitiumLibrary.Tests +{ + [TestClass] + public class Base32Tests + { + // RFC vectors for Base32 + private static readonly (string clear, string enc)[] RfcVectors = + { + ("f", "MY======"), + ("fo", "MZXQ===="), + ("foo", "MZXW6==="), + ("foob", "MZXW6YQ="), + ("fooba", "MZXW6YTB"), + ("foobar", "MZXW6YTBOI======") + }; + + // Values that must decode and encode back identically + private static readonly string[] RoundTripValues = + { + "", "10", "test130", "test", "8", "0", "=", "foobar" + }; + + // Arbitrary real-world binary sample from PHP tests + private static readonly byte[] RandomBytes = + System.Convert.FromBase64String("HgxBl1kJ4souh+ELRIHm/x8yTc/cgjDmiCNyJR/NJfs="); + + + // -------------------- RFC vectors -------------------- + + [TestMethod] + public void ToBase32String_RfcVectors_ProduceExpectedOutput() + { + foreach (var (clear, encoded) in RfcVectors) + { + // Arrange + var data = Encoding.ASCII.GetBytes(clear); + + // Act + var result = Base32.ToBase32String(data); + + // Assert + Assert.AreEqual(encoded, result, "Base32 encoding must match RFC vectors."); + } + } + + [TestMethod] + public void FromBase32String_RfcVectors_DecodeCorrectly() + { + foreach (var (clear, encoded) in RfcVectors) + { + // Arrange + var expected = Encoding.ASCII.GetBytes(clear); + + // Act + var result = Base32.FromBase32String(encoded); + + // Assert + CollectionAssert.AreEqual(expected, result, "Decoding must invert RFC vectors."); + } + } + + + // -------------------- RandomBytes encoding/decoding -------------------- + + [TestMethod] + public void ToBase32String_RandomBytes_MatchesExpectedEncoding() + { + // Given test fixture from PHP + var expected = "DYGEDF2ZBHRMULUH4EFUJAPG74PTETOP3SBDBZUIENZCKH6NEX5Q===="; + + // Act + var actual = Base32.ToBase32String(RandomBytes); + + Assert.AreEqual(expected, actual, "Binary encoding must be stable and deterministic."); + } + + [TestMethod] + public void FromBase32String_RandomBytes_ReturnsOriginalInput() + { + // Arrange + var encoded = "DYGEDF2ZBHRMULUH4EFUJAPG74PTETOP3SBDBZUIENZCKH6NEX5Q===="; + + // Act + var decoded = Base32.FromBase32String(encoded); + + // Assert + CollectionAssert.AreEqual(RandomBytes, decoded); + } + + + // -------------------- General encode/decode identity tests -------------------- + + [TestMethod] + public void EncodeDecode_RoundTrip_GivenKnownClearInputs_ReturnsOriginalValues() + { + foreach (var clear in RoundTripValues) + { + // Arrange + var bytes = Encoding.UTF8.GetBytes(clear); + + // Act + var encoded = Base32.ToBase32String(bytes); + var decoded = Base32.FromBase32String(encoded); + + // Assert + var decodedText = Encoding.UTF8.GetString(decoded); + Assert.AreEqual(clear, decodedText, "Encode + decode must round-trip."); + } + } + + + // -------------------- Explicit edge case tests -------------------- + + [TestMethod] + public void FromBase32String_GivenEmptyString_ReturnsEmptyArray() + { + var result = Base32.FromBase32String(""); + Assert.IsEmpty(result); + } + + [TestMethod] + public void ToBase32String_GivenEmptyBytes_ReturnsEmptyString() + { + var result = Base32.ToBase32String(Array.Empty()); + Assert.IsEmpty(result); + } + } + + + + + [TestClass] + public class Base32HexTests + { + private static readonly (string clear, string enc)[] RfcVectors = + { + ("f", "CO======"), + ("fo", "CPNG===="), + ("foo", "CPNMU==="), + ("foob", "CPNMUOG="), + ("fooba", "CPNMUOJ1"), + ("foobar", "CPNMUOJ1E8======"), + }; + + private static readonly string[] RoundTripValues = + { + "", "10", "test130", "test", "8", "0", "=", "foobar" + }; + + private static readonly byte[] RandomBytes = + System.Convert.FromBase64String("HgxBl1kJ4souh+ELRIHm/x8yTc/cgjDmiCNyJR/NJfs="); + + + // ---------------- RFC vectors ---------------- + + [TestMethod] + public void ToBase32HexString_RfcVectors_ProduceExpectedOutput() + { + foreach (var (clear, encoded) in RfcVectors) + { + var data = Encoding.ASCII.GetBytes(clear); + var result = Base32.ToBase32HexString(data); + Assert.AreEqual(encoded, result, "Hex encoding must match RFC vectors."); + } + } + + [TestMethod] + public void FromBase32HexString_RfcVectors_DecodeCorrectly() + { + foreach (var (clear, encoded) in RfcVectors) + { + var expected = Encoding.ASCII.GetBytes(clear); + var result = Base32.FromBase32HexString(encoded); + CollectionAssert.AreEqual(expected, result); + } + } + + + // ---------------- Known binary test ---------------- + + [TestMethod] + public void ToBase32HexString_RandomBytes_MatchesExpectedEncoding() + { + var expected = "3O6435QP17HCKBK7S45K90F6VSFJ4JEFRI131PK84DP2A7UD4NTG===="; + var result = Base32.ToBase32HexString(RandomBytes); + Assert.AreEqual(expected, result); + } + + [TestMethod] + public void FromBase32HexString_RandomBytes_ReturnsOriginalInput() + { + var encoded = "3O6435QP17HCKBK7S45K90F6VSFJ4JEFRI131PK84DP2A7UD4NTG===="; + var decoded = Base32.FromBase32HexString(encoded); + CollectionAssert.AreEqual(RandomBytes, decoded); + } + + + // ---------------- Roundtrip tests ---------------- + + [TestMethod] + public void EncodeDecode_RoundTrip_GivenKnownClearInputs_ReturnsOriginal() + { + foreach (var clear in RoundTripValues) + { + var bytes = Encoding.UTF8.GetBytes(clear); + var encoded = Base32.ToBase32HexString(bytes); + var decodedBytes = Base32.FromBase32HexString(encoded); + var decoded = Encoding.UTF8.GetString(decodedBytes); + + Assert.AreEqual(clear, decoded); + } + } + + + // ---------------- Explicit empty edge cases ---------------- + + [TestMethod] + public void FromBase32HexString_GivenEmpty_ReturnsEmptyArray() + { + var result = Base32.FromBase32HexString(""); + Assert.IsEmpty(result); + } + + [TestMethod] + public void ToBase32HexString_GivenEmptyBytes_ReturnsEmptyString() + { + var result = Base32.ToBase32HexString(Array.Empty()); + Assert.IsEmpty(result); + } + } +} diff --git a/TechnitiumLibrary.Tests/MSTestSettings.cs b/TechnitiumLibrary.Tests/MSTestSettings.cs new file mode 100644 index 00000000..e466aa12 --- /dev/null +++ b/TechnitiumLibrary.Tests/MSTestSettings.cs @@ -0,0 +1,3 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; + +[assembly: Parallelize(Scope = ExecutionScope.MethodLevel)] diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Tests.csproj b/TechnitiumLibrary.Tests/TechnitiumLibrary.Tests.csproj new file mode 100644 index 00000000..a1f080ec --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Tests.csproj @@ -0,0 +1,15 @@ + + + + net10.0 + latest + disable + enable + true + + + + + + + diff --git a/TechnitiumLibrary.sln b/TechnitiumLibrary.sln index 9cbcda3e..3f279abd 100644 --- a/TechnitiumLibrary.sln +++ b/TechnitiumLibrary.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31912.275 +# Visual Studio Version 18 +VisualStudioVersion = 18.0.11222.15 d18.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TechnitiumLibrary.IO", "TechnitiumLibrary.IO\TechnitiumLibrary.IO.csproj", "{E0BA5456-FEAA-4380-92BB-6B1C4BC3DC70}" EndProject @@ -25,6 +25,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TechnitiumLibrary", "Techni EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TechnitiumLibrary.Security.OTP", "TechnitiumLibrary.Security.OTP\TechnitiumLibrary.Security.OTP.csproj", "{72AF4EB6-EB81-4655-9998-8BF24B304614}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TechnitiumLibrary.Tests", "TechnitiumLibrary.Tests\TechnitiumLibrary.Tests.csproj", "{FD16DA2F-1446-45BA-929A-89D4E233F4C2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -75,6 +77,10 @@ Global {72AF4EB6-EB81-4655-9998-8BF24B304614}.Debug|Any CPU.Build.0 = Debug|Any CPU {72AF4EB6-EB81-4655-9998-8BF24B304614}.Release|Any CPU.ActiveCfg = Release|Any CPU {72AF4EB6-EB81-4655-9998-8BF24B304614}.Release|Any CPU.Build.0 = Release|Any CPU + {FD16DA2F-1446-45BA-929A-89D4E233F4C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FD16DA2F-1446-45BA-929A-89D4E233F4C2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FD16DA2F-1446-45BA-929A-89D4E233F4C2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FD16DA2F-1446-45BA-929A-89D4E233F4C2}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE From aa0dab086d26375dc4d8462654a941bb64a639a2 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 5 Dec 2025 11:07:11 +0200 Subject: [PATCH 03/74] Added BinaryNumber unit tests --- TechnitiumLibrary.Tests/BinaryNumberTests.cs | 261 +++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 TechnitiumLibrary.Tests/BinaryNumberTests.cs diff --git a/TechnitiumLibrary.Tests/BinaryNumberTests.cs b/TechnitiumLibrary.Tests/BinaryNumberTests.cs new file mode 100644 index 00000000..f26b795a --- /dev/null +++ b/TechnitiumLibrary.Tests/BinaryNumberTests.cs @@ -0,0 +1,261 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using TechnitiumLibrary; + +namespace TechnitiumLibrary.Tests +{ + [TestClass] + public class BinaryNumberTests + { + [TestMethod] + public void Constructor_ShouldStoreValue_WhenValidBytesProvided() + { + // GIVEN + var data = new byte[] { 0x01, 0x02, 0xFF }; + + // WHEN + var bn = new BinaryNumber(data); + + // THEN + CollectionAssert.AreEqual(data, bn.Value); + } + + [TestMethod] + public void Clone_ShouldReturnDeepCopy() + { + // GIVEN + var bn = new BinaryNumber(new byte[] { 0xAA, 0xBB }); + + // WHEN + var clone = bn.Clone(); + + // THEN + Assert.AreNotSame(bn.Value, clone.Value); + CollectionAssert.AreEqual(bn.Value, clone.Value); + } + + [TestMethod] + public void Parse_ShouldReturnCorrectBytes() + { + // GIVEN + var hex = "aabbcc"; + + // WHEN + var bn = BinaryNumber.Parse(hex); + + // THEN + CollectionAssert.AreEqual(new byte[] { 0xAA, 0xBB, 0xCC }, bn.Value); + } + + [TestMethod] + public void GenerateRandomNumber160_ShouldReturn20ByteArray() + { + // GIVEN + WHEN + var bn = BinaryNumber.GenerateRandomNumber160(); + + // THEN + Assert.AreEqual(20, bn.Value.Length, "Expected 160-bit random number"); + } + + [TestMethod] + public void GenerateRandomNumber256_ShouldReturn32ByteArray() + { + // GIVEN + WHEN + var bn = BinaryNumber.GenerateRandomNumber256(); + + // THEN + Assert.AreEqual(32, bn.Value.Length, "Expected 256-bit random number"); + } + + [TestMethod] + public void Equals_ShouldReturnTrue_WhenValuesMatch() + { + // GIVEN + var b1 = new BinaryNumber(new byte[] { 1, 2, 3 }); + var b2 = new BinaryNumber(new byte[] { 1, 2, 3 }); + + // WHEN + var result = b1.Equals(b2); + + // THEN + Assert.IsTrue(result); + } + + [TestMethod] + public void Equals_ShouldReturnFalse_WhenValuesDiffer() + { + // GIVEN + var b1 = new BinaryNumber(new byte[] { 1, 2, 3 }); + var b2 = new BinaryNumber(new byte[] { 9, 9, 9 }); + + // WHEN + THEN + Assert.IsFalse(b1.Equals(b2)); + } + + [TestMethod] + public void CompareTo_ShouldReturnZero_WhenEqual() + { + // GIVEN + var a = new BinaryNumber(new byte[] { 0x11, 0x22 }); + var b = new BinaryNumber(new byte[] { 0x11, 0x22 }); + + // WHEN + THEN + Assert.AreEqual(0, a.CompareTo(b)); + } + + [TestMethod] + public void CompareTo_ShouldReturnPositive_WhenGreater() + { + // GIVEN + var a = new BinaryNumber(new byte[] { 0xFF, 0x00 }); + var b = new BinaryNumber(new byte[] { 0x01, 0x00 }); + + // WHEN + THEN + Assert.AreEqual(1, a.CompareTo(b)); + } + + [TestMethod] + public void CompareTo_ShouldReturnNegative_WhenLess() + { + // GIVEN + var a = new BinaryNumber(new byte[] { 0x01, 0x00 }); + var b = new BinaryNumber(new byte[] { 0xFF, 0x00 }); + + // WHEN + THEN + Assert.AreEqual(-1, a.CompareTo(b)); + } + + [TestMethod] + public void CompareTo_ShouldThrow_WhenLengthsDiffer() + { + // GIVEN + var a = new BinaryNumber(new byte[] { 0x01 }); + var b = new BinaryNumber(new byte[] { 0x01, 0x02 }); + + // WHEN + THEN + Assert.ThrowsExactly(() => a.CompareTo(b)); + } + + [TestMethod] + public void BitwiseOr_ShouldReturnExpectedResult() + { + // GIVEN + var b1 = new BinaryNumber(new byte[] { 0x0F, 0x00 }); + var b2 = new BinaryNumber(new byte[] { 0xF0, 0xFF }); + + // WHEN + var result = b1 | b2; + + // THEN + CollectionAssert.AreEqual(new byte[] { 0xFF, 0xFF }, result.Value); + } + + [TestMethod] + public void BitwiseAnd_ShouldReturnExpectedResult() + { + // GIVEN + var b1 = new BinaryNumber(new byte[] { 0x0F, 0x55 }); + var b2 = new BinaryNumber(new byte[] { 0xF0, 0x0F }); + + // WHEN + var result = b1 & b2; + + // THEN + CollectionAssert.AreEqual(new byte[] { 0x00, 0x05 }, result.Value); + } + + [TestMethod] + public void BitwiseXor_ShouldReturnExpectedResult() + { + // GIVEN + var b1 = new BinaryNumber(new byte[] { 0x0F, 0xAA }); + var b2 = new BinaryNumber(new byte[] { 0xFF, 0x55 }); + + // WHEN + var result = b1 ^ b2; + + // THEN + CollectionAssert.AreEqual(new byte[] { 0xF0, 0xFF }, result.Value); + } + + [TestMethod] + public void ShiftLeft_ShouldMoveBitsCorrectly() + { + // GIVEN + var source = new BinaryNumber(new byte[] { 0b00000001, 0b00000000 }); + + // WHEN + var shifted = source << 1; + + // THEN + CollectionAssert.AreEqual( + new byte[] { 0b00000010, 0b00000000 }, + shifted.Value); + } + + [TestMethod] + public void ShiftRight_ShouldMoveBitsCorrectly() + { + // GIVEN + var source = new BinaryNumber(new byte[] { 0b00000100, 0b00000000 }); + + // WHEN + var shifted = source >> 2; + + // THEN + CollectionAssert.AreEqual( + new byte[] { 0b00000001, 0b00000000 }, + shifted.Value); + } + + [TestMethod] + public void UnaryNot_ShouldFlipBits() + { + // GIVEN + var value = new BinaryNumber(new byte[] { 0x00, 0xFF }); + + // WHEN + var inverted = ~value; + + // THEN + CollectionAssert.AreEqual(new byte[] { 0xFF, 0x00 }, inverted.Value); + } + + [TestMethod] + public void WriteTo_ShouldWriteWithLengthPrefix() + { + // GIVEN + var data = new byte[] { 0x11, 0x22, 0x33 }; + var bn = new BinaryNumber(data); + using MemoryStream ms = new(); + + // WHEN + bn.WriteTo(ms); + + // THEN + var bytes = ms.ToArray(); + Assert.AreEqual(4, bytes.Length); + Assert.AreEqual(3, bytes[0]); + CollectionAssert.AreEqual(new byte[] { 0x11, 0x22, 0x33 }, bytes[1..]); + } + + [TestMethod] + public void Constructor_ShouldReadFromBinaryReader() + { + // GIVEN + using MemoryStream ms = new(); + using var writer = new BinaryWriter(ms); + writer.Write7BitEncodedInt(2); + writer.Write(new byte[] { 0xDE, 0xAD }); + ms.Position = 0; + + // WHEN + using var br = new BinaryReader(ms); + var bn = new BinaryNumber(br); + + // THEN + CollectionAssert.AreEqual(new byte[] { 0xDE, 0xAD }, bn.Value); + } + } +} From f7ec3c46d9ffb71639389b795a96ac6774c704cb Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 5 Dec 2025 20:52:54 +0200 Subject: [PATCH 04/74] Added size check to BinaryNumber ctor --- TechnitiumLibrary/BinaryNumber.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/TechnitiumLibrary/BinaryNumber.cs b/TechnitiumLibrary/BinaryNumber.cs index 747a20b0..833c0ece 100644 --- a/TechnitiumLibrary/BinaryNumber.cs +++ b/TechnitiumLibrary/BinaryNumber.cs @@ -44,7 +44,13 @@ public BinaryNumber(Stream s) public BinaryNumber(BinaryReader bR) { - _value = bR.ReadBytes(bR.Read7BitEncodedInt()); + var length = bR.Read7BitEncodedInt(); + var data = bR.ReadBytes(length); + + if (data.Length != length) + throw new EndOfStreamException("Not enough bytes in stream to build BinaryNumber."); + + _value = data; } #endregion From c8fff7a505d6b333b09df99dd53e576ecf75b37a Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 5 Dec 2025 20:54:06 +0200 Subject: [PATCH 05/74] Added unit tests for BinaryNumber --- TechnitiumLibrary.Tests/BinaryNumberTests.cs | 373 +++++++++++++------ 1 file changed, 258 insertions(+), 115 deletions(-) diff --git a/TechnitiumLibrary.Tests/BinaryNumberTests.cs b/TechnitiumLibrary.Tests/BinaryNumberTests.cs index f26b795a..8184adfd 100644 --- a/TechnitiumLibrary.Tests/BinaryNumberTests.cs +++ b/TechnitiumLibrary.Tests/BinaryNumberTests.cs @@ -8,24 +8,82 @@ namespace TechnitiumLibrary.Tests [TestClass] public class BinaryNumberTests { + private static byte[] Bytes(params byte[] v) => v; + + // --------------------------------------------------------------------- + // Constructor tests + // --------------------------------------------------------------------- + + [TestMethod] + public void Constructor_ShouldStoreReferenceValue() + { + // GIVEN + var raw = Bytes(0xAA, 0xBB); + + // WHEN + var bn = new BinaryNumber(raw); + + // THEN + CollectionAssert.AreEqual(raw, bn.Value); + } + [TestMethod] - public void Constructor_ShouldStoreValue_WhenValidBytesProvided() + public void Constructor_ShouldCreateFromBinaryReader_WhenValidLengthIsGiven() { // GIVEN - var data = new byte[] { 0x01, 0x02, 0xFF }; + using MemoryStream ms = new(); + using BinaryWriter bw = new(ms); + bw.Write7BitEncodedInt(3); + bw.Write(Bytes(0x11, 0x22, 0x33)); + ms.Position = 0; // WHEN - var bn = new BinaryNumber(data); + using BinaryReader br = new(ms); + var bn = new BinaryNumber(br); // THEN - CollectionAssert.AreEqual(data, bn.Value); + CollectionAssert.AreEqual(Bytes(0x11, 0x22, 0x33), bn.Value); + } + + [TestMethod] + public void Constructor_ShouldThrow_WhenStreamHasInsufficientBytes() + { + // GIVEN + using MemoryStream ms = new(); + using BinaryWriter bw = new(ms); + bw.Write7BitEncodedInt(5); // claims 5 bytes exist + bw.Write(Bytes(0xAA)); // only 1 byte actually written + ms.Position = 0; + + using BinaryReader br = new(ms); + + // WHEN + THEN + Assert.ThrowsExactly(() => new BinaryNumber(br)); + } + + [TestMethod] + public void Constructor_ShouldThrow_WhenStreamIsUnreadable() + { + // GIVEN + var unreadableStream = new UnreadableStream(); + + // WHEN + THEN + Assert.ThrowsExactly(() => + { + var reader = new BinaryReader(unreadableStream); + _ = new BinaryNumber(reader); // will not be reached + }); } + // --------------------------------------------------------------------- + // Clone tests + // --------------------------------------------------------------------- + [TestMethod] - public void Clone_ShouldReturnDeepCopy() + public void Clone_ShouldReturnNewInstanceWithSameBytes() { // GIVEN - var bn = new BinaryNumber(new byte[] { 0xAA, 0xBB }); + var bn = new BinaryNumber(Bytes(0x10, 0x20)); // WHEN var clone = bn.Clone(); @@ -35,227 +93,312 @@ public void Clone_ShouldReturnDeepCopy() CollectionAssert.AreEqual(bn.Value, clone.Value); } + // --------------------------------------------------------------------- + // Parse tests + // --------------------------------------------------------------------- + [TestMethod] - public void Parse_ShouldReturnCorrectBytes() + public void Parse_ShouldDecodeHexString() { // GIVEN - var hex = "aabbcc"; + var hex = "A1B2C3"; // WHEN var bn = BinaryNumber.Parse(hex); // THEN - CollectionAssert.AreEqual(new byte[] { 0xAA, 0xBB, 0xCC }, bn.Value); + CollectionAssert.AreEqual(Bytes(0xA1, 0xB2, 0xC3), bn.Value); } [TestMethod] - public void GenerateRandomNumber160_ShouldReturn20ByteArray() + public void Parse_ShouldThrow_WhenStringContainsInvalidHex() { - // GIVEN + WHEN - var bn = BinaryNumber.GenerateRandomNumber160(); + // GIVEN + var badHex = "XYZ123"; - // THEN - Assert.AreEqual(20, bn.Value.Length, "Expected 160-bit random number"); + // WHEN + THEN + Assert.ThrowsExactly(() => BinaryNumber.Parse(badHex)); } [TestMethod] - public void GenerateRandomNumber256_ShouldReturn32ByteArray() + public void Parse_ShouldThrow_WhenInputIsNull() { - // GIVEN + WHEN - var bn = BinaryNumber.GenerateRandomNumber256(); + // GIVEN + string input = null; // THEN - Assert.AreEqual(32, bn.Value.Length, "Expected 256-bit random number"); + Assert.ThrowsExactly(() => BinaryNumber.Parse(input)); } + // --------------------------------------------------------------------- + // Static Equals(byte[], byte[]) tests + // --------------------------------------------------------------------- + [TestMethod] - public void Equals_ShouldReturnTrue_WhenValuesMatch() + public void StaticEquals_ShouldReturnTrue_WhenBothNull() { // GIVEN - var b1 = new BinaryNumber(new byte[] { 1, 2, 3 }); - var b2 = new BinaryNumber(new byte[] { 1, 2, 3 }); + byte[] a = null; + byte[] b = null; - // WHEN - var result = b1.Equals(b2); + // WHEN + THEN + Assert.IsTrue(BinaryNumber.Equals(a, b)); + } - // THEN - Assert.IsTrue(result); + [TestMethod] + public void StaticEquals_ShouldReturnFalse_WhenOneSideIsNull() + { + // GIVEN + byte[] a = Bytes(1, 2, 3); + byte[] b = null; + + // WHEN + THEN + Assert.IsFalse(BinaryNumber.Equals(a, b)); } [TestMethod] - public void Equals_ShouldReturnFalse_WhenValuesDiffer() + public void StaticEquals_ShouldReturnFalse_WhenLengthsDiffer() { // GIVEN - var b1 = new BinaryNumber(new byte[] { 1, 2, 3 }); - var b2 = new BinaryNumber(new byte[] { 9, 9, 9 }); + byte[] a = Bytes(1, 2); + byte[] b = Bytes(1, 2, 3); // WHEN + THEN - Assert.IsFalse(b1.Equals(b2)); + Assert.IsFalse(BinaryNumber.Equals(a, b)); } [TestMethod] - public void CompareTo_ShouldReturnZero_WhenEqual() + public void StaticEquals_ShouldReturnFalse_WhenContentDiffers() { // GIVEN - var a = new BinaryNumber(new byte[] { 0x11, 0x22 }); - var b = new BinaryNumber(new byte[] { 0x11, 0x22 }); + byte[] a = Bytes(1, 2, 3); + byte[] b = Bytes(1, 9, 3); // WHEN + THEN - Assert.AreEqual(0, a.CompareTo(b)); + Assert.IsFalse(BinaryNumber.Equals(a, b)); } + // --------------------------------------------------------------------- + // IEquatable tests + // --------------------------------------------------------------------- + [TestMethod] - public void CompareTo_ShouldReturnPositive_WhenGreater() + public void Equals_ShouldReturnFalse_WhenOtherIsNull() { // GIVEN - var a = new BinaryNumber(new byte[] { 0xFF, 0x00 }); - var b = new BinaryNumber(new byte[] { 0x01, 0x00 }); + var bn = new BinaryNumber(Bytes(1, 2)); // WHEN + THEN - Assert.AreEqual(1, a.CompareTo(b)); + Assert.IsFalse(bn.Equals(null)); } [TestMethod] - public void CompareTo_ShouldReturnNegative_WhenLess() + public void EqualsObject_ShouldReturnFalse_ForIncorrectType() { // GIVEN - var a = new BinaryNumber(new byte[] { 0x01, 0x00 }); - var b = new BinaryNumber(new byte[] { 0xFF, 0x00 }); + var bn = new BinaryNumber(Bytes(1)); // WHEN + THEN - Assert.AreEqual(-1, a.CompareTo(b)); + Assert.IsFalse(bn.Equals(new object())); } + [TestMethod] + public void Equals_ShouldReturnTrue_ForIdenticalValues() + { + var a = new BinaryNumber(Bytes(0xAA, 0xBB)); + var b = new BinaryNumber(Bytes(0xAA, 0xBB)); + + Assert.IsTrue(a.Equals(b)); + } + + // --------------------------------------------------------------------- + // CompareTo tests + // --------------------------------------------------------------------- + [TestMethod] public void CompareTo_ShouldThrow_WhenLengthsDiffer() { // GIVEN - var a = new BinaryNumber(new byte[] { 0x01 }); - var b = new BinaryNumber(new byte[] { 0x01, 0x02 }); + var a = new BinaryNumber(Bytes(0x01)); + var b = new BinaryNumber(Bytes(0x02, 0x03)); // WHEN + THEN Assert.ThrowsExactly(() => a.CompareTo(b)); } [TestMethod] - public void BitwiseOr_ShouldReturnExpectedResult() + public void CompareTo_ShouldReturnZero_WhenValuesMatch() { - // GIVEN - var b1 = new BinaryNumber(new byte[] { 0x0F, 0x00 }); - var b2 = new BinaryNumber(new byte[] { 0xF0, 0xFF }); + var a = new BinaryNumber(Bytes(1, 2)); + var b = new BinaryNumber(Bytes(1, 2)); - // WHEN - var result = b1 | b2; + Assert.AreEqual(0, a.CompareTo(b)); + } - // THEN - CollectionAssert.AreEqual(new byte[] { 0xFF, 0xFF }, result.Value); + [TestMethod] + public void CompareTo_ShouldReturnPositive_WhenAIsGreater() + { + var a = new BinaryNumber(Bytes(0xFF)); + var b = new BinaryNumber(Bytes(0x00)); + + Assert.AreEqual(1, a.CompareTo(b)); } [TestMethod] - public void BitwiseAnd_ShouldReturnExpectedResult() + public void CompareTo_ShouldReturnNegative_WhenAIsSmaller() { - // GIVEN - var b1 = new BinaryNumber(new byte[] { 0x0F, 0x55 }); - var b2 = new BinaryNumber(new byte[] { 0xF0, 0x0F }); + var a = new BinaryNumber(Bytes(0x00)); + var b = new BinaryNumber(Bytes(0xFF)); - // WHEN - var result = b1 & b2; + Assert.AreEqual(-1, a.CompareTo(b)); + } - // THEN - CollectionAssert.AreEqual(new byte[] { 0x00, 0x05 }, result.Value); + // --------------------------------------------------------------------- + // Operator tests + // --------------------------------------------------------------------- + + [TestMethod] + public void OperatorEquality_ShouldBeTrue_ForSameReference() + { + var bn = new BinaryNumber(Bytes(1, 2)); + Assert.IsTrue(bn == bn); } [TestMethod] - public void BitwiseXor_ShouldReturnExpectedResult() + public void OperatorInequality_ShouldBeTrue_WhenValuesDiffer() { - // GIVEN - var b1 = new BinaryNumber(new byte[] { 0x0F, 0xAA }); - var b2 = new BinaryNumber(new byte[] { 0xFF, 0x55 }); + var a = new BinaryNumber(Bytes(1, 2)); + var b = new BinaryNumber(Bytes(9, 9)); + Assert.IsTrue(a != b); + } - // WHEN - var result = b1 ^ b2; + [TestMethod] + public void OperatorOr_ShouldThrow_WhenLengthsDiffer() + { + var a = new BinaryNumber(Bytes(1)); + var b = new BinaryNumber(Bytes(1, 2)); - // THEN - CollectionAssert.AreEqual(new byte[] { 0xF0, 0xFF }, result.Value); + Assert.ThrowsExactly(() => _ = a | b); } [TestMethod] - public void ShiftLeft_ShouldMoveBitsCorrectly() + public void OperatorOr_ShouldReturnCorrectValue() { - // GIVEN - var source = new BinaryNumber(new byte[] { 0b00000001, 0b00000000 }); + var a = new BinaryNumber(Bytes(0b00001111)); + var b = new BinaryNumber(Bytes(0b11110000)); - // WHEN - var shifted = source << 1; + var result = a | b; - // THEN - CollectionAssert.AreEqual( - new byte[] { 0b00000010, 0b00000000 }, - shifted.Value); + CollectionAssert.AreEqual(Bytes(0b11111111), result.Value); } [TestMethod] - public void ShiftRight_ShouldMoveBitsCorrectly() + public void OperatorAnd_ShouldReturnCorrectValue() { - // GIVEN - var source = new BinaryNumber(new byte[] { 0b00000100, 0b00000000 }); + var a = new BinaryNumber(Bytes(0x0F)); + var b = new BinaryNumber(Bytes(0xF0)); - // WHEN - var shifted = source >> 2; + var result = a & b; - // THEN - CollectionAssert.AreEqual( - new byte[] { 0b00000001, 0b00000000 }, - shifted.Value); + CollectionAssert.AreEqual(Bytes(0x00), result.Value); } [TestMethod] - public void UnaryNot_ShouldFlipBits() + public void OperatorXor_ShouldReturnCorrectValue() { - // GIVEN - var value = new BinaryNumber(new byte[] { 0x00, 0xFF }); + var a = new BinaryNumber(Bytes(0xAA)); + var b = new BinaryNumber(Bytes(0xFF)); - // WHEN - var inverted = ~value; + var result = a ^ b; - // THEN - CollectionAssert.AreEqual(new byte[] { 0xFF, 0x00 }, inverted.Value); + CollectionAssert.AreEqual(Bytes(0x55), result.Value); } [TestMethod] - public void WriteTo_ShouldWriteWithLengthPrefix() + public void OperatorShiftLeft_ShouldShiftBits() { - // GIVEN - var data = new byte[] { 0x11, 0x22, 0x33 }; - var bn = new BinaryNumber(data); - using MemoryStream ms = new(); + var src = new BinaryNumber(Bytes(0b00000001, 0b00000000)); + var shifted = src << 1; - // WHEN - bn.WriteTo(ms); + CollectionAssert.AreEqual(Bytes(0b00000010, 0b00000000), shifted.Value); + } - // THEN - var bytes = ms.ToArray(); - Assert.AreEqual(4, bytes.Length); - Assert.AreEqual(3, bytes[0]); - CollectionAssert.AreEqual(new byte[] { 0x11, 0x22, 0x33 }, bytes[1..]); + [TestMethod] + public void OperatorShiftRight_ShouldShiftBits() + { + var src = new BinaryNumber(Bytes(0b00000100, 0b00000000)); + var shifted = src >> 2; + + CollectionAssert.AreEqual(Bytes(0b00000001, 0b00000000), shifted.Value); } [TestMethod] - public void Constructor_ShouldReadFromBinaryReader() + public void OperatorNot_ShouldInvertBits() { - // GIVEN + var src = new BinaryNumber(Bytes(0x00, 0xFF)); + var inv = ~src; + + CollectionAssert.AreEqual(Bytes(0xFF, 0x00), inv.Value); + } + + [TestMethod] + public void ComparisonOperators_ShouldHonorLexicographicOrder() + { + var a = new BinaryNumber(Bytes(1, 2)); + var b = new BinaryNumber(Bytes(9, 9)); + + Assert.IsTrue(a < b); + Assert.IsTrue(b > a); + Assert.IsTrue(a <= b); + Assert.IsTrue(b >= a); + } + + [TestMethod] + public void ComparisonOperators_ShouldThrow_WhenLengthsDiffer() + { + var a = new BinaryNumber(Bytes(1)); + var b = new BinaryNumber(Bytes(1, 2)); + + Assert.ThrowsExactly(() => _ = a < b); + Assert.ThrowsExactly(() => _ = a > b); + Assert.ThrowsExactly(() => _ = a <= b); + Assert.ThrowsExactly(() => _ = a >= b); + } + + // --------------------------------------------------------------------- + // WriteTo tests + // --------------------------------------------------------------------- + + [TestMethod] + public void WriteTo_ShouldWritePrefixAndBytes() + { + var bn = new BinaryNumber(Bytes(0x11, 0x22)); using MemoryStream ms = new(); - using var writer = new BinaryWriter(ms); - writer.Write7BitEncodedInt(2); - writer.Write(new byte[] { 0xDE, 0xAD }); - ms.Position = 0; - // WHEN - using var br = new BinaryReader(ms); - var bn = new BinaryNumber(br); + bn.WriteTo(ms); - // THEN - CollectionAssert.AreEqual(new byte[] { 0xDE, 0xAD }, bn.Value); + var result = ms.ToArray(); + Assert.AreEqual(3, result.Length); + Assert.AreEqual(2, result[0]); // length prefix + CollectionAssert.AreEqual(Bytes(0x11, 0x22), result[1..]); + } + + // --------------------------------------------------------------------- + // Supporting Test Doubles + // --------------------------------------------------------------------- + + private class UnreadableStream : Stream + { + public override bool CanRead => false; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public override void Flush() => throw new NotSupportedException(); + public override int Read(byte[] buffer, int offset, int count) => throw new IOException("Unreadable"); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); } } } From bc05ec30151206742b0d5a58449649e9f438b492 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 5 Dec 2025 21:35:53 +0200 Subject: [PATCH 06/74] Added null-check to TryQueueTask --- TechnitiumLibrary/TaskPool.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/TechnitiumLibrary/TaskPool.cs b/TechnitiumLibrary/TaskPool.cs index bf1b4796..c2fd8ae5 100644 --- a/TechnitiumLibrary/TaskPool.cs +++ b/TechnitiumLibrary/TaskPool.cs @@ -102,6 +102,10 @@ public bool TryQueueTask(Func task) public bool TryQueueTask(Func task, object state) { + if (task is null) + { + throw new ArgumentNullException(nameof(task)); + } return _channelWriter.TryWrite((task, state)); } From 042f83680b5142f83bd84bfd4f7252a35727597c Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 5 Dec 2025 21:36:23 +0200 Subject: [PATCH 07/74] Unit tests for TaskPool --- TechnitiumLibrary.Tests/TaskPoolTests.cs | 155 +++++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 TechnitiumLibrary.Tests/TaskPoolTests.cs diff --git a/TechnitiumLibrary.Tests/TaskPoolTests.cs b/TechnitiumLibrary.Tests/TaskPoolTests.cs new file mode 100644 index 00000000..7ed6d54f --- /dev/null +++ b/TechnitiumLibrary.Tests/TaskPoolTests.cs @@ -0,0 +1,155 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Collections.Concurrent; +using System.Threading.Tasks; +using TechnitiumLibrary; +using System.Threading; + +namespace TechnitiumLibrary.Tests +{ + [TestClass] + public sealed class TaskPoolTests + { + [TestMethod] + public async Task TryQueueTask_ShouldExecuteQueuedTask() + { + // GIVEN + var pool = new TaskPool(queueSize: 10, maximumConcurrencyLevel: 2); + var completer = new TaskCompletionSource(); + + // WHEN + var queued = pool.TryQueueTask(_ => + { + completer.SetResult(true); + return Task.CompletedTask; + }); + + // THEN + Assert.IsTrue(queued, "Task should be accepted into queue."); + Assert.IsTrue(await completer.Task, "Task must execute."); + } + + [TestMethod] + public async Task ShouldProcessMultipleTasksConcurrently_WhenAllowed() + { + // GIVEN + var parallelism = Environment.ProcessorCount; + var pool = new TaskPool(queueSize: 64, maximumConcurrencyLevel: parallelism); + + var counter = 0; + var completion = new TaskCompletionSource(); + var lockObj = new object(); + + int total = parallelism; + + // WHEN + for (int i = 0; i < total; i++) + { + pool.TryQueueTask(_ => + { + lock (lockObj) + counter++; + + if (counter == total) + completion.SetResult(true); + + return Task.CompletedTask; + }); + } + + // THEN + Assert.IsTrue(await completion.Task, "All tasks must execute."); + Assert.AreEqual(total, counter, "All queued tasks must run."); + } + + [TestMethod] + public async Task TasksShouldStopAfterDispose() + { + // GIVEN + var pool = new TaskPool(queueSize: 10, maximumConcurrencyLevel: 1); + + var executedBeforeDispose = new TaskCompletionSource(); + var wasExecutedAfterDispose = false; + + pool.TryQueueTask(_ => + { + executedBeforeDispose.SetResult(true); + return Task.CompletedTask; + }); + + await executedBeforeDispose.Task; + + // WHEN + pool.Dispose(); + var acceptedPostDispose = pool.TryQueueTask(_ => + { + wasExecutedAfterDispose = true; + return Task.CompletedTask; + }); + + // THEN + Assert.IsFalse(acceptedPostDispose, "After disposal, queue must reject writes."); + Assert.IsFalse(wasExecutedAfterDispose, "Tasks queued after Dispose must not run."); + } + + [TestMethod] + public void Ctor_ShouldUseDefaultConcurrency_WhenValueIsLessThanOne() + { + // GIVEN + WHEN + var pool = new TaskPool(queueSize: 10, maximumConcurrencyLevel: -1); + + // THEN + Assert.IsTrue(pool.MaximumConcurrencyLevel >= 1, + "Concurrency must fallback to processor count."); + } + + [TestMethod] + public void TryQueueTask_ShouldReturnFalse_WhenTaskIsNull() + { + // GIVEN + var pool = new TaskPool(); + + // WHEN + THEN + Assert.ThrowsExactly(() => pool.TryQueueTask(null)); + } + + [TestMethod] + public async Task TaskShouldReceiveStateObject() + { + // GIVEN + var pool = new TaskPool(); + var completion = new TaskCompletionSource(); + + var expectedState = "STATE"; + var capturedState = default(string); + + // WHEN + pool.TryQueueTask(obj => + { + capturedState = obj as string; + completion.SetResult(true); + return Task.CompletedTask; + }, expectedState); + + await completion.Task; + + // THEN + Assert.AreEqual(expectedState, capturedState, "State parameter must propagate through execution."); + } + + [TestMethod] + public void DisposeMustBeIdempotent() + { + // GIVEN + var pool = new TaskPool(); + + // WHEN + pool.Dispose(); + pool.Dispose(); + pool.Dispose(); + + // THEN + Assert.IsTrue(true, "Dispose must not throw."); + } + } +} From a6dc0a9f6bb954d53c58d39e479bb22f83e4e07f Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 5 Dec 2025 21:57:53 +0200 Subject: [PATCH 08/74] Added tests for IndependentTaskScheduler class --- .../IndependentTaskSchedulerTests.cs | 146 ++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 TechnitiumLibrary.Tests/IndependentTaskSchedulerTests.cs diff --git a/TechnitiumLibrary.Tests/IndependentTaskSchedulerTests.cs b/TechnitiumLibrary.Tests/IndependentTaskSchedulerTests.cs new file mode 100644 index 00000000..1d364c5a --- /dev/null +++ b/TechnitiumLibrary.Tests/IndependentTaskSchedulerTests.cs @@ -0,0 +1,146 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace TechnitiumLibrary.Tests +{ + [TestClass] + public sealed class IndependentTaskSchedulerTests + { + [TestMethod] + public async Task Task_ShouldExecute_WhenQueued() + { + // GIVEN + using var scheduler = new IndependentTaskScheduler(maximumConcurrencyLevel: 1); + var completion = new TaskCompletionSource(); + + // WHEN + var t = new Task(_ => completion.SetResult(true), null); + t.Start(scheduler); + + // THEN + Assert.IsTrue(await completion.Task); + } + + [TestMethod] + public void MaximumConcurrencyLevel_ShouldMatchRequested() + { + // GIVEN + using var scheduler = new IndependentTaskScheduler(3); + + // WHEN + var level = scheduler.MaximumConcurrencyLevel; + + // THEN + Assert.AreEqual(3, level); + } + + [TestMethod] + public async Task Tasks_ShouldRunInParallel_WhenConcurrencyGreaterThanOne() + { + // GIVEN + using var scheduler = new IndependentTaskScheduler(maximumConcurrencyLevel: 2); + var parallelStarted = new TaskCompletionSource(); + var runningCount = 0; + + Task Body() => + Task.Run(() => + { + if (Interlocked.Increment(ref runningCount) == 2) + { + parallelStarted.SetResult(true); + } + Thread.Sleep(40); + }); + + // WHEN + var t1 = Task.Factory.StartNew(() => Body(), CancellationToken.None, TaskCreationOptions.None, scheduler).Unwrap(); + var t2 = Task.Factory.StartNew(() => Body(), CancellationToken.None, TaskCreationOptions.None, scheduler).Unwrap(); + + // THEN + Assert.IsTrue(await parallelStarted.Task); + } + + [TestMethod] + public void LongRunningOption_ShouldExecuteOnDedicatedThread() + { + // GIVEN + using var scheduler = new IndependentTaskScheduler(1); + var factoryThreadId = Thread.CurrentThread.ManagedThreadId; + var schedulerThreadId = -1; + + // WHEN + var task = new Task( + _ => schedulerThreadId = Thread.CurrentThread.ManagedThreadId, + null, + TaskCreationOptions.LongRunning); + + task.Start(scheduler); + task.Wait(); + + // THEN + Assert.AreNotEqual(factoryThreadId, schedulerThreadId); + } + + [TestMethod] + public async Task InlineExecution_ShouldRun_WhenCalledInsideSchedulerThread() + { + // GIVEN + using var scheduler = new IndependentTaskScheduler(1); + + var executedInline = new TaskCompletionSource(); + + // WHEN + var driver = new Task(() => + { + // Attempt inline execution from scheduler thread + var child = new Task(() => executedInline.SetResult(true)); + // This will execute inline because we are already inside scheduler thread + child.RunSynchronously(TaskScheduler.Current); + }); + + // Run the driver task inside scheduler + driver.Start(scheduler); + await driver; + + // THEN + Assert.IsTrue(await executedInline.Task, "Task must execute inline in scheduler thread."); + } + + [TestMethod] + public void Dispose_ShouldPreventFutureExecution() + { + // GIVEN + var scheduler = new IndependentTaskScheduler(1); + scheduler.Dispose(); + var task = new Task(() => { }); + + // WHEN + var continuation = Task.Factory.StartNew( + () => task.Start(scheduler), + CancellationToken.None, + TaskCreationOptions.None, + TaskScheduler.Default); + + continuation.Wait(); + + // THEN + Assert.IsFalse(task.IsCompleted); + } + + [TestMethod] + public void Dispose_CanBeCalledMultipleTimes_Safely() + { + // GIVEN + var scheduler = new IndependentTaskScheduler(); + + // WHEN + scheduler.Dispose(); + scheduler.Dispose(); + + // THEN + Assert.IsTrue(true); // simply must not throw + } + } +} From f1af5385ea555991cb9aa920becd4f2cc73a1856 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 5 Dec 2025 22:04:11 +0200 Subject: [PATCH 09/74] USed .NET 9.0 --- TechnitiumLibrary.Tests/TechnitiumLibrary.Tests.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Tests.csproj b/TechnitiumLibrary.Tests/TechnitiumLibrary.Tests.csproj index a1f080ec..81e42102 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary.Tests.csproj +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Tests.csproj @@ -1,7 +1,7 @@  - net10.0 + net9.0 latest disable enable From ac2b92a91f235081510609f8879e839cc076bb22 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 5 Dec 2025 22:24:38 +0200 Subject: [PATCH 10/74] Added null checks for collection extensions --- TechnitiumLibrary/CollectionExtensions.cs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/TechnitiumLibrary/CollectionExtensions.cs b/TechnitiumLibrary/CollectionExtensions.cs index 91a04cd1..6def9818 100644 --- a/TechnitiumLibrary/CollectionExtensions.cs +++ b/TechnitiumLibrary/CollectionExtensions.cs @@ -40,8 +40,13 @@ public static void Shuffle(this IList array) public static IReadOnlyList Convert(this IReadOnlyList array, Func convert) { - T2[] newArray = new T2[array.Count]; + if (array is null) + throw new ArgumentNullException(nameof(array)); + + if (convert is null) + throw new ArgumentNullException(nameof(convert)); + T2[] newArray = new T2[array.Count]; for (int i = 0; i < array.Count; i++) newArray[i] = convert(array[i]); @@ -50,9 +55,14 @@ public static IReadOnlyList Convert(this IReadOnlyList array, Fu public static IReadOnlyCollection Convert(this IReadOnlyCollection collection, Func convert) { + if (collection is null) + throw new ArgumentNullException(nameof(collection)); + + if (convert is null) + throw new ArgumentNullException(nameof(convert)); + T2[] newArray = new T2[collection.Count]; int i = 0; - foreach (T1 item in collection) newArray[i++] = convert(item); From b7d779a95eef3e7aec702718d6571f119c83d873 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 5 Dec 2025 22:25:58 +0200 Subject: [PATCH 11/74] Added tests for CollectionExtensions --- .../CollectionExtensionsTests.cs | 238 ++++++++++++++++++ 1 file changed, 238 insertions(+) create mode 100644 TechnitiumLibrary.Tests/CollectionExtensionsTests.cs diff --git a/TechnitiumLibrary.Tests/CollectionExtensionsTests.cs b/TechnitiumLibrary.Tests/CollectionExtensionsTests.cs new file mode 100644 index 00000000..e09fc824 --- /dev/null +++ b/TechnitiumLibrary.Tests/CollectionExtensionsTests.cs @@ -0,0 +1,238 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; + +namespace TechnitiumLibrary.Tests +{ + [TestClass] + public sealed class CollectionExtensionsTests + { + // ------------------------------------------------------------- + // Shuffle + // ------------------------------------------------------------- + + [TestMethod] + public void Shuffle_ShouldRearrangeItems_WhenListHasMultipleElements() + { + // GIVEN + var input = new[] { 1, 2, 3, 4, 5 }; + var original = input.ToArray(); + + // WHEN + input.Shuffle(); + + // THEN + Assert.HasCount(original.Length, input, "Shuffle must not remove items."); + Assert.IsTrue(input.All(original.Contains), "Shuffle must retain all original items."); + } + + [TestMethod] + public void Shuffle_ShouldNotChangeSingleElementList() + { + // GIVEN + var input = new[] { 42 }; + + // WHEN + input.Shuffle(); + + // THEN + Assert.AreEqual(42, input[0]); + } + + [TestMethod] + public void Shuffle_ShouldNotThrow_WhenEmpty() + { + // GIVEN + var input = new int[] { }; + + // WHEN + input.Shuffle(); + + // THEN + Assert.IsEmpty(input); + } + + // ------------------------------------------------------------- + // Convert (IReadOnlyList) + // ------------------------------------------------------------- + + [TestMethod] + public void Convert_List_ShouldTransformElements() + { + // GIVEN + IReadOnlyList input = new ReadOnlyCollection(new[] { 1, 2, 3 }); + + // WHEN + var result = input.Convert(x => x * 10); + + // THEN + Assert.HasCount(3, result); + Assert.AreEqual(10, result[0]); + Assert.AreEqual(20, result[1]); + Assert.AreEqual(30, result[2]); + } + + [TestMethod] + public void Convert_List_ShouldThrow_WhenConverterIsNull() + { + // GIVEN + IReadOnlyList input = Array.Empty(); + + // WHEN + THEN + Assert.ThrowsExactly( + () => input.Convert(null) + ); + } + + // ------------------------------------------------------------- + // Convert (IReadOnlyCollection) + // ------------------------------------------------------------- + + [TestMethod] + public void Convert_Collection_ShouldPreserveCount() + { + // GIVEN + IReadOnlyCollection input = new[] { "A", "BB", "CCC" }; + + // WHEN + var result = input.Convert(str => str.Length); + + // THEN + Assert.HasCount(3, result); + } + + [TestMethod] + public void Convert_Collection_ShouldThrow_WhenConverterIsNull() + { + // GIVEN + IReadOnlyCollection input = new[] { 1, 2 }; + + // WHEN + THEN + Assert.ThrowsExactly( + () => input.Convert(null) + ); + } + + // ------------------------------------------------------------- + // ListEquals + // ------------------------------------------------------------- + + [TestMethod] + public void ListEquals_ShouldReturnTrue_WhenSequencesMatchExactly() + { + // GIVEN + var a = new[] { 1, 2, 3 }; + var b = new[] { 1, 2, 3 }; + + // WHEN + var equal = a.ListEquals(b); + + // THEN + Assert.IsTrue(equal); + } + + [TestMethod] + public void ListEquals_ShouldReturnFalse_WhenLengthDiffers() + { + // GIVEN + var a = new[] { 1, 2 }; + var b = new[] { 1, 2, 3 }; + + // WHEN + var equal = a.ListEquals(b); + + // THEN + Assert.IsFalse(equal); + } + + [TestMethod] + public void ListEquals_ShouldReturnFalse_WhenElementDiffers() + { + // GIVEN + var a = new[] { 1, 2, 3 }; + var b = new[] { 1, 9, 3 }; + + // WHEN + var equal = a.ListEquals(b); + + // THEN + Assert.IsFalse(equal); + } + + [TestMethod] + public void ListEquals_ShouldReturnFalse_WhenSecondIsNull() + { + // GIVEN + var a = new[] { "X" }; + + // WHEN + var equal = a.ListEquals(null); + + // THEN + Assert.IsFalse(equal); + } + + // ------------------------------------------------------------- + // HasSameItems + // ------------------------------------------------------------- + + [TestMethod] + public void HasSameItems_ShouldReturnTrue_WhenSameElementsUnordered() + { + // GIVEN + var a = new[] { 3, 1, 2 }; + var b = new[] { 2, 3, 1 }; + + // WHEN + var equal = a.HasSameItems(b); + + // THEN + Assert.IsTrue(equal); + } + + [TestMethod] + public void HasSameItems_ShouldReturnFalse_WhenDifferentItemsPresent() + { + // GIVEN + var a = new[] { 1, 2, 3 }; + var b = new[] { 1, 2, 4 }; + + // WHEN + var equal = a.HasSameItems(b); + + // THEN + Assert.IsFalse(equal); + } + + // ------------------------------------------------------------- + // GetArrayHashCode + // ------------------------------------------------------------- + + [TestMethod] + public void GetArrayHashCode_ShouldReturnZero_WhenNull() + { + // WHEN + var hash = CollectionExtensions.GetArrayHashCode(null); + + // THEN + Assert.AreEqual(0, hash); + } + + [TestMethod] + public void GetArrayHashCode_ShouldMatchRegardlessOfOrder() + { + // GIVEN + var a = new[] { 10, 20, 30 }; + var b = new[] { 30, 10, 20 }; + + // WHEN + var hashA = a.GetArrayHashCode(); + var hashB = b.GetArrayHashCode(); + + // THEN + Assert.AreEqual(hashA, hashB, "XOR hash should not depend on order."); + } + } +} From 237e5d09683d552ae3ffacb39c50df61bebd8c98 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 5 Dec 2025 22:26:37 +0200 Subject: [PATCH 12/74] Minor improvement an supressions --- TechnitiumLibrary.Tests/BinaryNumberTests.cs | 4 +++- TechnitiumLibrary.Tests/IndependentTaskSchedulerTests.cs | 1 + TechnitiumLibrary.Tests/TaskPoolTests.cs | 5 +++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/TechnitiumLibrary.Tests/BinaryNumberTests.cs b/TechnitiumLibrary.Tests/BinaryNumberTests.cs index 8184adfd..944fddf2 100644 --- a/TechnitiumLibrary.Tests/BinaryNumberTests.cs +++ b/TechnitiumLibrary.Tests/BinaryNumberTests.cs @@ -261,7 +261,9 @@ public void CompareTo_ShouldReturnNegative_WhenAIsSmaller() public void OperatorEquality_ShouldBeTrue_ForSameReference() { var bn = new BinaryNumber(Bytes(1, 2)); +#pragma warning disable CS1718 // Comparison made to same variable Assert.IsTrue(bn == bn); +#pragma warning restore CS1718 // Comparison made to same variable } [TestMethod] @@ -378,7 +380,7 @@ public void WriteTo_ShouldWritePrefixAndBytes() bn.WriteTo(ms); var result = ms.ToArray(); - Assert.AreEqual(3, result.Length); + Assert.HasCount(3, result); Assert.AreEqual(2, result[0]); // length prefix CollectionAssert.AreEqual(Bytes(0x11, 0x22), result[1..]); } diff --git a/TechnitiumLibrary.Tests/IndependentTaskSchedulerTests.cs b/TechnitiumLibrary.Tests/IndependentTaskSchedulerTests.cs index 1d364c5a..339b5f92 100644 --- a/TechnitiumLibrary.Tests/IndependentTaskSchedulerTests.cs +++ b/TechnitiumLibrary.Tests/IndependentTaskSchedulerTests.cs @@ -130,6 +130,7 @@ public void Dispose_ShouldPreventFutureExecution() } [TestMethod] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "MSTEST0032:Assertion condition is always true", Justification = "Double Dispose must not throw")] public void Dispose_CanBeCalledMultipleTimes_Safely() { // GIVEN diff --git a/TechnitiumLibrary.Tests/TaskPoolTests.cs b/TechnitiumLibrary.Tests/TaskPoolTests.cs index 7ed6d54f..7d41b720 100644 --- a/TechnitiumLibrary.Tests/TaskPoolTests.cs +++ b/TechnitiumLibrary.Tests/TaskPoolTests.cs @@ -99,8 +99,8 @@ public void Ctor_ShouldUseDefaultConcurrency_WhenValueIsLessThanOne() var pool = new TaskPool(queueSize: 10, maximumConcurrencyLevel: -1); // THEN - Assert.IsTrue(pool.MaximumConcurrencyLevel >= 1, - "Concurrency must fallback to processor count."); + Assert.IsGreaterThanOrEqualTo(1, +pool.MaximumConcurrencyLevel, "Concurrency must fallback to processor count."); } [TestMethod] @@ -138,6 +138,7 @@ public async Task TaskShouldReceiveStateObject() } [TestMethod] + [System.Diagnostics.CodeAnalysis.SuppressMessage("Design", "MSTEST0032:Assertion condition is always true", Justification = "Multiple Dispose must not throw")] public void DisposeMustBeIdempotent() { // GIVEN From 9b8120c789040b282e11e088e6505372fd2b43ed Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 5 Dec 2025 22:52:03 +0200 Subject: [PATCH 13/74] Moved tests under project-specific folder for cleaner structure. --- .../{ => TechnitiumLibrary}/Base32Tests.cs | 7 +++---- .../{ => TechnitiumLibrary}/BinaryNumberTests.cs | 3 +-- .../{ => TechnitiumLibrary}/CollectionExtensionsTests.cs | 2 +- .../IndependentTaskSchedulerTests.cs | 2 +- .../{ => TechnitiumLibrary}/TaskPoolTests.cs | 3 +-- 5 files changed, 7 insertions(+), 10 deletions(-) rename TechnitiumLibrary.Tests/{ => TechnitiumLibrary}/Base32Tests.cs (96%) rename TechnitiumLibrary.Tests/{ => TechnitiumLibrary}/BinaryNumberTests.cs (99%) rename TechnitiumLibrary.Tests/{ => TechnitiumLibrary}/CollectionExtensionsTests.cs (99%) rename TechnitiumLibrary.Tests/{ => TechnitiumLibrary}/IndependentTaskSchedulerTests.cs (98%) rename TechnitiumLibrary.Tests/{ => TechnitiumLibrary}/TaskPoolTests.cs (98%) diff --git a/TechnitiumLibrary.Tests/Base32Tests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary/Base32Tests.cs similarity index 96% rename from TechnitiumLibrary.Tests/Base32Tests.cs rename to TechnitiumLibrary.Tests/TechnitiumLibrary/Base32Tests.cs index 3a5c0e22..bc30fd2d 100644 --- a/TechnitiumLibrary.Tests/Base32Tests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary/Base32Tests.cs @@ -1,9 +1,8 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using System.Text; using System; -using TechnitiumLibrary; -namespace TechnitiumLibrary.Tests +namespace TechnitiumLibrary.Tests.TechnitiumLibrary { [TestClass] public class Base32Tests @@ -27,7 +26,7 @@ private static readonly (string clear, string enc)[] RfcVectors = // Arbitrary real-world binary sample from PHP tests private static readonly byte[] RandomBytes = - System.Convert.FromBase64String("HgxBl1kJ4souh+ELRIHm/x8yTc/cgjDmiCNyJR/NJfs="); + Convert.FromBase64String("HgxBl1kJ4souh+ELRIHm/x8yTc/cgjDmiCNyJR/NJfs="); // -------------------- RFC vectors -------------------- @@ -153,7 +152,7 @@ private static readonly (string clear, string enc)[] RfcVectors = }; private static readonly byte[] RandomBytes = - System.Convert.FromBase64String("HgxBl1kJ4souh+ELRIHm/x8yTc/cgjDmiCNyJR/NJfs="); + Convert.FromBase64String("HgxBl1kJ4souh+ELRIHm/x8yTc/cgjDmiCNyJR/NJfs="); // ---------------- RFC vectors ---------------- diff --git a/TechnitiumLibrary.Tests/BinaryNumberTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary/BinaryNumberTests.cs similarity index 99% rename from TechnitiumLibrary.Tests/BinaryNumberTests.cs rename to TechnitiumLibrary.Tests/TechnitiumLibrary/BinaryNumberTests.cs index 944fddf2..d4d9414c 100644 --- a/TechnitiumLibrary.Tests/BinaryNumberTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary/BinaryNumberTests.cs @@ -1,9 +1,8 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using System.IO; -using TechnitiumLibrary; -namespace TechnitiumLibrary.Tests +namespace TechnitiumLibrary.Tests.TechnitiumLibrary { [TestClass] public class BinaryNumberTests diff --git a/TechnitiumLibrary.Tests/CollectionExtensionsTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary/CollectionExtensionsTests.cs similarity index 99% rename from TechnitiumLibrary.Tests/CollectionExtensionsTests.cs rename to TechnitiumLibrary.Tests/TechnitiumLibrary/CollectionExtensionsTests.cs index e09fc824..f4e792e2 100644 --- a/TechnitiumLibrary.Tests/CollectionExtensionsTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary/CollectionExtensionsTests.cs @@ -4,7 +4,7 @@ using System.Collections.ObjectModel; using System.Linq; -namespace TechnitiumLibrary.Tests +namespace TechnitiumLibrary.Tests.TechnitiumLibrary { [TestClass] public sealed class CollectionExtensionsTests diff --git a/TechnitiumLibrary.Tests/IndependentTaskSchedulerTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary/IndependentTaskSchedulerTests.cs similarity index 98% rename from TechnitiumLibrary.Tests/IndependentTaskSchedulerTests.cs rename to TechnitiumLibrary.Tests/TechnitiumLibrary/IndependentTaskSchedulerTests.cs index 339b5f92..fba6bd9f 100644 --- a/TechnitiumLibrary.Tests/IndependentTaskSchedulerTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary/IndependentTaskSchedulerTests.cs @@ -3,7 +3,7 @@ using System.Threading; using System.Threading.Tasks; -namespace TechnitiumLibrary.Tests +namespace TechnitiumLibrary.Tests.TechnitiumLibrary { [TestClass] public sealed class IndependentTaskSchedulerTests diff --git a/TechnitiumLibrary.Tests/TaskPoolTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary/TaskPoolTests.cs similarity index 98% rename from TechnitiumLibrary.Tests/TaskPoolTests.cs rename to TechnitiumLibrary.Tests/TechnitiumLibrary/TaskPoolTests.cs index 7d41b720..520997bc 100644 --- a/TechnitiumLibrary.Tests/TaskPoolTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary/TaskPoolTests.cs @@ -2,10 +2,9 @@ using System; using System.Collections.Concurrent; using System.Threading.Tasks; -using TechnitiumLibrary; using System.Threading; -namespace TechnitiumLibrary.Tests +namespace TechnitiumLibrary.Tests.TechnitiumLibrary { [TestClass] public sealed class TaskPoolTests From 0d01eb5afa05cc9b5520bff470ae244dd7fba5c0 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 5 Dec 2025 22:56:17 +0200 Subject: [PATCH 14/74] Added unit tests for JsonExtensions class --- .../TechnitiumLibrary/JsonExtensionsTests.cs | 277 ++++++++++++++++++ 1 file changed, 277 insertions(+) create mode 100644 TechnitiumLibrary.Tests/TechnitiumLibrary/JsonExtensionsTests.cs diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary/JsonExtensionsTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary/JsonExtensionsTests.cs new file mode 100644 index 00000000..0336dfd4 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary/JsonExtensionsTests.cs @@ -0,0 +1,277 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Linq; +using System.Text.Json; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary +{ + [TestClass] + public sealed class JsonExtensionsTests + { + private static JsonElement ToElement(string json) + { + using var doc = JsonDocument.Parse(json); + return doc.RootElement.Clone(); + } + + // ------------------------------ + // ARRAY READING (STRING) + // ------------------------------ + + [TestMethod] + public void GetArray_ShouldReturnStringArray_WhenArrayExists() + { + // GIVEN + var json = ToElement("""{ "values": ["a", "b", "c"] }"""); + + // WHEN + var result = json.ReadArray("values"); + + // THEN + Assert.HasCount(3, result); + Assert.AreEqual("a", result[0]); + Assert.AreEqual("b", result[1]); + Assert.AreEqual("c", result[2]); + } + + [TestMethod] + public void GetArray_ShouldReturnNull_WhenJsonContainsNull() + { + // GIVEN + var json = ToElement("""{ "values": null }"""); + + // WHEN + var result = json.ReadArray("values"); + + // THEN + Assert.IsNull(result); + } + + [TestMethod] + public void GetArray_ShouldThrow_WhenPropertyIsNotArrayOrNull() + { + // GIVEN + var json = ToElement("""{ "values": 123 }"""); + + // WHEN–THEN + Assert.ThrowsExactly(() => json.ReadArray("values")); + } + + // ------------------------------ + // ARRAY READING WITH MAPPING (string→int) + // ------------------------------ + + [TestMethod] + public void ReadArray_WithConverter_ShouldReturnMappedArray() + { + // GIVEN + var json = ToElement("""{ "values": ["1","2","3"] }"""); + + // WHEN + var result = json.ReadArray("values", int.Parse); + + // THEN + Assert.HasCount(3, result); + Assert.AreEqual(1, result[0]); + Assert.AreEqual(2, result[1]); + Assert.AreEqual(3, result[2]); + } + + [TestMethod] + public void ReadArray_WithConverter_ShouldThrow_WhenConverterThrows() + { + // GIVEN + var json = ToElement("""{ "values": ["bad"] }"""); + + // WHEN–THEN + Assert.ThrowsExactly(() => + json.ReadArray("values", s => int.Parse(s))); + } + + [TestMethod] + public void TryReadArray_WithConverter_ShouldReturnFalse_WhenPropertyMissing() + { + // GIVEN + var json = ToElement("""{ "other": [1,2] }"""); + + // WHEN + var result = json.TryReadArray("values", int.Parse, out var array); + + // THEN + Assert.IsFalse(result); + Assert.IsNull(array); + } + + [TestMethod] + public void TryReadArray_WithConverter_ShouldReturnTrue_WhenArrayExists() + { + // GIVEN + var json = ToElement("""{ "values": ["10","20"] }"""); + + // WHEN + var result = json.TryReadArray("values", int.Parse, out var array); + + // THEN + Assert.IsTrue(result); + Assert.HasCount(2, array); + Assert.AreEqual(10, array[0]); + Assert.AreEqual(20, array[1]); + } + + // ------------------------------ + // READ SET + // ------------------------------ + + [TestMethod] + public void ReadArrayAsSet_ShouldReturnHashSetOfUniqueValues() + { + // GIVEN + var json = ToElement("""{ "values": ["a","b","a"] }"""); + + // WHEN + var result = json.ReadArrayAsSet("values"); + + // THEN + Assert.HasCount(2, result); + Assert.Contains("a", result); + Assert.Contains("b", result); + } + + [TestMethod] + public void TryReadArrayAsSet_ShouldReturnFalse_WhenNoProperty() + { + // GIVEN + var json = ToElement("""{ "other": [] }"""); + + // WHEN + var result = json.TryReadArrayAsSet("values", out var set); + + // THEN + Assert.IsFalse(result); + Assert.IsNull(set); + } + + // ------------------------------ + // MAP READING + // ------------------------------ + + [TestMethod] + public void ReadArrayAsMap_ShouldReturnDictionary_WhenMappingReturnsPairs() + { + // GIVEN + var json = ToElement("""{ "values": [ { "k":"x","v":"1" }, { "k":"y","v":"2"} ] }"""); + + // WHEN + var result = json.ReadArrayAsMap("values", el => + { + var key = el.GetProperty("k").GetString(); + var val = int.Parse(el.GetProperty("v").GetString()); + return Tuple.Create(key, val); + }); + + // THEN + Assert.HasCount(2, result); + Assert.AreEqual(1, result["x"]); + Assert.AreEqual(2, result["y"]); + } + + [TestMethod] + public void TryReadArrayAsMap_ShouldReturnFalse_WhenPropertyMissing() + { + // GIVEN + var json = ToElement("""{ "other": [] }"""); + + // WHEN + var result = json.TryReadArrayAsMap("values", el => null, out var map); + + // THEN + Assert.IsFalse(result); + Assert.IsNull(map); + } + + // ------------------------------ + // GET PROPERTY VALUE + // ------------------------------ + + [TestMethod] + public void GetPropertyValue_String_ShouldReturnDefault_WhenMissing() + { + // GIVEN + var json = ToElement("""{ "name": "test" }"""); + + // WHEN + var value = json.GetPropertyValue("missing", "default"); + + // THEN + Assert.AreEqual("default", value); + } + + [TestMethod] + public void GetPropertyValue_Int_ShouldReturnStoredValue() + { + // GIVEN + var json = ToElement("""{ "value": 42 }"""); + + // WHEN + var value = json.GetPropertyValue("value", -1); + + // THEN + Assert.AreEqual(42, value); + } + + [TestMethod] + public void GetPropertyEnumValue_ShouldReturnEnum() + { + // GIVEN + var json = ToElement("""{ "mode": "Friday" }"""); + + // WHEN + var result = json.GetPropertyEnumValue("mode", DayOfWeek.Monday); + + // THEN + Assert.AreEqual(DayOfWeek.Friday, result); + } + + [TestMethod] + public void GetPropertyEnumValue_ShouldReturnDefault_WhenNotFound() + { + // GIVEN + var json = ToElement("""{ "val": 10 }"""); + + // WHEN + var result = json.GetPropertyEnumValue("missing", DayOfWeek.Sunday); + + // THEN + Assert.AreEqual(DayOfWeek.Sunday, result); + } + + // ------------------------------ + // WRITE ARRAY + // ------------------------------ + + [TestMethod] + public void WriteStringArray_ShouldSerializeStrings_AsJsonArray() + { + // GIVEN + var input = new[] { "x", "y", "z" }; + using var buffer = new System.IO.MemoryStream(); + using var writer = new Utf8JsonWriter(buffer); + + // WHEN + writer.WriteStartObject(); + writer.WriteStringArray("values", input); + writer.WriteEndObject(); + writer.Flush(); + + var json = JsonDocument.Parse(buffer.ToArray()).RootElement; + + // THEN + var arr = json.GetProperty("values").EnumerateArray().Select(x => x.GetString()).ToArray(); + + Assert.HasCount(3, arr); + Assert.AreEqual("x", arr[0]); + Assert.AreEqual("y", arr[1]); + Assert.AreEqual("z", arr[2]); + } + } +} From ee17507686b140bda8e43cbdc56aeed57505b8f2 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 5 Dec 2025 23:02:35 +0200 Subject: [PATCH 15/74] Added unit tests for StringExtensions class --- .../StringExtensionsTests.cs | 187 ++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 TechnitiumLibrary.Tests/TechnitiumLibrary/StringExtensionsTests.cs diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary/StringExtensionsTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary/StringExtensionsTests.cs new file mode 100644 index 00000000..da015816 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary/StringExtensionsTests.cs @@ -0,0 +1,187 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary +{ + [TestClass] + public sealed class StringExtensionsTests + { + // ----------------------------- + // Split + // ----------------------------- + + [TestMethod] + public void Split_ShouldConvertItems_WhenParsingSucceeds() + { + // GIVEN + const string input = "1, 2, 3"; + + // WHEN + var result = input.Split(int.Parse, ','); + + // THEN + Assert.HasCount(3, result); + Assert.AreEqual(1, result[0]); + Assert.AreEqual(2, result[1]); + Assert.AreEqual(3, result[2]); + } + + [TestMethod] + public void Split_ShouldRemoveEmptyEntries_AndTrim() + { + // GIVEN + const string input = " 10 ; ; 20 ; 30 "; + + // WHEN + var result = input.Split(int.Parse, ';'); + + // THEN + Assert.HasCount(3, result); + Assert.AreEqual(10, result[0]); + Assert.AreEqual(20, result[1]); + Assert.AreEqual(30, result[2]); + } + + [TestMethod] + public void Split_ShouldThrow_WhenParserThrows() + { + // GIVEN + const string input = "10, BAD"; + + // WHEN–THEN + Assert.ThrowsExactly(() => + { + _ = input.Split(int.Parse, ','); + }); + } + + [TestMethod] + public void Split_ShouldThrow_WhenStringIsNull() + { + // GIVEN + const string? input = null; + + // WHEN–THEN + Assert.ThrowsExactly(() => + _ = input.Split(int.Parse, ',')); + } + + // ----------------------------- + // Join + // ----------------------------- + + [TestMethod] + public void Join_ShouldReturnCommaSeparatedValues() + { + // GIVEN + var input = new[] { 1, 2, 3 }; + + // WHEN + var result = input.Join(','); + + // THEN + Assert.AreEqual("1, 2, 3", result); + } + + [TestMethod] + public void Join_ShouldReturnNull_WhenCollectionEmpty() + { + // GIVEN + var input = Array.Empty(); + + // WHEN + var result = input.Join(','); + + // THEN + Assert.IsNull(result); + } + + [TestMethod] + public void Join_ShouldThrow_WhenValuesIsNull() + { + // GIVEN + int[]? input = null; + + // WHEN–THEN + Assert.ThrowsExactly(() => input.Join(',')); + } + + // ----------------------------- + // ParseColonHexString + // ----------------------------- + + [TestMethod] + public void ParseColonHexString_ShouldReturnBytes_WhenValidHex() + { + // GIVEN + const string input = "0A:FF:01"; + + // WHEN + var result = input.ParseColonHexString(); + + // THEN + Assert.HasCount(3, result); + Assert.AreEqual(0x0A, result[0]); + Assert.AreEqual(0xFF, result[1]); + Assert.AreEqual(0x01, result[2]); + } + + [TestMethod] + public void ParseColonHexString_ShouldThrow_WhenInvalidHex() + { + // GIVEN + const string input = "GG:12"; + + // WHEN–THEN + Assert.ThrowsExactly(() => + _ = input.ParseColonHexString()); + } + + [TestMethod] + public void ParseColonHexString_ShouldThrow_WhenValueNotHex() + { + // GIVEN + const string input = "1K"; + + // WHEN–THEN + Assert.ThrowsExactly(() => + _ = input.ParseColonHexString()); + } + + [TestMethod] + public void ParseColonHexString_ShouldThrow_WhenInputContainsEmptySegments() + { + // GIVEN + const string input = "FF::AA"; + + // WHEN–THEN + Assert.ThrowsExactly(() => + _ = input.ParseColonHexString()); + } + + [TestMethod] + public void ParseColonHexString_ShouldThrow_WhenValueIsNull() + { + // GIVEN + const string? input = null; + + // WHEN–THEN + Assert.ThrowsExactly(() => + _ = input.ParseColonHexString()); + } + + [TestMethod] + public void ParseColonHexString_ShouldSupportSingleSegment() + { + // GIVEN + const string input = "FE"; + + // WHEN + var result = input.ParseColonHexString(); + + // THEN + Assert.HasCount(1, result); + Assert.AreEqual(0xFE, result[0]); + } + } +} From 604a640c86e31898d3305978c4a66e5bea9eda9a Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Fri, 5 Dec 2025 23:09:05 +0200 Subject: [PATCH 16/74] Added tests for TaskExtensions class --- .../TechnitiumLibrary/TaskExtensionsTests.cs | 225 ++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 TechnitiumLibrary.Tests/TechnitiumLibrary/TaskExtensionsTests.cs diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary/TaskExtensionsTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary/TaskExtensionsTests.cs new file mode 100644 index 00000000..73f09f2f --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary/TaskExtensionsTests.cs @@ -0,0 +1,225 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary +{ + [TestClass] + public sealed class TaskExtensionsTests + { + // Helper allowing deterministic near-timeout simulation + private static Task NeverCompletes(CancellationToken _) => + new TaskCompletionSource().Task; + + // --------------------------------------------- + // TimeoutAsync (non-returning) + // --------------------------------------------- + + [TestMethod] + public async Task TimeoutAsync_ShouldComplete_WhenTaskFinishesBeforeTimeout() + { + // GIVEN + Func func = _ => Task.Delay(50, TestContext.CancellationToken); + + // WHEN-THEN + await TaskExtensions.TimeoutAsync(func, timeout: 500, TestContext.CancellationToken); + } + + [TestMethod] + public async Task TimeoutAsync_ShouldThrowTimeoutException_WhenOperationExceedsTimeout() + { + // GIVEN + Func func = NeverCompletes; + + // WHEN-THEN + await Assert.ThrowsExactlyAsync(() => + TaskExtensions.TimeoutAsync(func, timeout: 50, TestContext.CancellationToken)); + } + + [TestMethod] + public async Task TimeoutAsync_ShouldThrowOriginalException_WhenTaskFails() + { + // GIVEN + Func func = _ => throw new InvalidOperationException("boom"); + + // WHEN-THEN + await Assert.ThrowsExactlyAsync(() => + TaskExtensions.TimeoutAsync(func, timeout: 500, TestContext.CancellationToken)); + } + + [TestMethod] + public async Task TimeoutAsync_ShouldThrowOperationCanceled_WhenRootTokenCancelled() + { + // GIVEN + using var cts = new CancellationTokenSource(); + Func func = NeverCompletes; + + // WHEN + await cts.CancelAsync(); + + // THEN + await Assert.ThrowsExactlyAsync(() => + TaskExtensions.TimeoutAsync(func, timeout: 200, cancellationToken: cts.Token)); + } + + // --------------------------------------------- + // TimeoutAsync (generic) + // --------------------------------------------- + + [TestMethod] + public async Task TimeoutAsync_Generic_ShouldReturnValue_WhenCompletedWithinTimeout() + { + // GIVEN + Func> func = _ => Task.FromResult(42); + + // WHEN + var result = await TaskExtensions.TimeoutAsync(func, timeout: 300, TestContext.CancellationToken); + + // THEN + Assert.AreEqual(42, result); + } + + [TestMethod] + public async Task TimeoutAsync_Generic_ShouldThrowTimeoutException_WhenTaskRunsTooLong() + { + // GIVEN + Func> func = async _ => + { + await Task.Delay(2000, TestContext.CancellationToken); + return 5; + }; + + // WHEN-THEN + await Assert.ThrowsExactlyAsync(() => + TaskExtensions.TimeoutAsync(func, timeout: 50, TestContext.CancellationToken)); + } + + [TestMethod] + public async Task TimeoutAsync_Generic_ShouldPropagateSourceException() + { + // GIVEN + Func> func = + _ => throw new FormatException("fail"); + + // WHEN-THEN + await Assert.ThrowsExactlyAsync(() => + TaskExtensions.TimeoutAsync(func, timeout: 500, TestContext.CancellationToken)); + } + + [TestMethod] + public async Task TimeoutAsync_Generic_ShouldThrowOperationCanceled_WhenCanceledExternally() + { + // GIVEN + using var cts = new CancellationTokenSource(); + Func func = NeverCompletes; + + // WHEN + await cts.CancelAsync(); + + // THEN + await Assert.ThrowsExactlyAsync(() => + TaskExtensions.TimeoutAsync(func, timeout: 200, cancellationToken: cts.Token)); + } + + // --------------------------------------------- + // Sync() Task + // --------------------------------------------- + + [TestMethod] + public void Sync_ShouldBlockUntilCompleted() + { + // GIVEN + var task = Task.Delay(50, TestContext.CancellationToken); + + // WHEN-THEN + task.Sync(); + } + + [TestMethod] + public void Sync_ShouldRethrowOriginalException() + { + // GIVEN + var task = Task.FromException(new InvalidOperationException("bad")); + + // WHEN-THEN + Assert.ThrowsExactly(() => task.Sync()); + } + + [TestMethod] + public void Sync_ShouldThrowNullReference_WhenTaskIsNull() + { + // GIVEN + Task? task = null; + + // WHEN-THEN + Assert.ThrowsExactly(() => task!.Sync()); + } + + // --------------------------------------------- + // Sync() Task + // --------------------------------------------- + + [TestMethod] + public void Sync_Generic_ShouldReturnValue() + { + // GIVEN + var task = Task.FromResult(123); + + // WHEN + var result = task.Sync(); + + // THEN + Assert.AreEqual(123, result); + } + + [TestMethod] + public void Sync_Generic_ShouldSurfaceException() + { + // GIVEN + var task = Task.FromException(new FormatException()); + + // WHEN-THEN + Assert.ThrowsExactly(() => task.Sync()); + } + + [TestMethod] + public void Sync_Generic_ShouldThrowOnNullTask() + { + // GIVEN + Task? task = null; + + // WHEN-THEN + Assert.ThrowsExactly(() => task!.Sync()); + } + + // --------------------------------------------- + // Sync() ValueTask / ValueTask + // --------------------------------------------- + + [TestMethod] + public void Sync_ValueTask_ShouldBlockUntilCompletion() + { + // GIVEN + var vt = new ValueTask(Task.Delay(50, TestContext.CancellationToken)); + + // WHEN-THEN + vt.Sync(); + } + + [TestMethod] + public void Sync_ValueTask_Generic_ShouldReturnValue() + { + // GIVEN + var vt = new ValueTask(987); + + // WHEN + var result = vt.Sync(); + + // THEN + Assert.AreEqual(987, result); + } + + public TestContext TestContext { get; set; } + } +} From 7f2a2e158fbf5af8e9c7b0d6b9d60071427eeb92 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sat, 6 Dec 2025 16:59:29 +0200 Subject: [PATCH 17/74] Rolled solution version back to VS2022 for compatibility --- TechnitiumLibrary.sln | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/TechnitiumLibrary.sln b/TechnitiumLibrary.sln index 3f279abd..6ccf8d25 100644 --- a/TechnitiumLibrary.sln +++ b/TechnitiumLibrary.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 18 -VisualStudioVersion = 18.0.11222.15 d18.0 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31912.275 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TechnitiumLibrary.IO", "TechnitiumLibrary.IO\TechnitiumLibrary.IO.csproj", "{E0BA5456-FEAA-4380-92BB-6B1C4BC3DC70}" EndProject From 52864ec331c7025fb6795dded8e355174e7fd31f Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sat, 6 Dec 2025 17:44:43 +0200 Subject: [PATCH 18/74] Response to review --- .../TechnitiumLibrary/Base32Tests.cs | 37 ++++++++++++++++--- .../TechnitiumLibrary/BinaryNumberTests.cs | 2 +- .../CollectionExtensionsTests.cs | 26 ++++++++++++- .../IndependentTaskSchedulerTests.cs | 4 +- .../TechnitiumLibrary/TaskPoolTests.cs | 2 +- TechnitiumLibrary/Base32.cs | 7 +--- 6 files changed, 61 insertions(+), 17 deletions(-) diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary/Base32Tests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary/Base32Tests.cs index bc30fd2d..992f1e02 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary/Base32Tests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary/Base32Tests.cs @@ -1,6 +1,7 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; -using System.Text; using System; +using System.Text; +using TechnitiumLibrary; namespace TechnitiumLibrary.Tests.TechnitiumLibrary { @@ -70,7 +71,7 @@ public void FromBase32String_RfcVectors_DecodeCorrectly() public void ToBase32String_RandomBytes_MatchesExpectedEncoding() { // Given test fixture from PHP - var expected = "DYGEDF2ZBHRMULUH4EFUJAPG74PTETOP3SBDBZUIENZCKH6NEX5Q===="; + const string expected = "DYGEDF2ZBHRMULUH4EFUJAPG74PTETOP3SBDBZUIENZCKH6NEX5Q===="; // Act var actual = Base32.ToBase32String(RandomBytes); @@ -82,7 +83,7 @@ public void ToBase32String_RandomBytes_MatchesExpectedEncoding() public void FromBase32String_RandomBytes_ReturnsOriginalInput() { // Arrange - var encoded = "DYGEDF2ZBHRMULUH4EFUJAPG74PTETOP3SBDBZUIENZCKH6NEX5Q===="; + const string encoded = "DYGEDF2ZBHRMULUH4EFUJAPG74PTETOP3SBDBZUIENZCKH6NEX5Q===="; // Act var decoded = Base32.FromBase32String(encoded); @@ -128,10 +129,34 @@ public void ToBase32String_GivenEmptyBytes_ReturnsEmptyString() var result = Base32.ToBase32String(Array.Empty()); Assert.IsEmpty(result); } - } + [TestMethod] + public void FromBase32String_GivenNullString_ThrowsException() + { + Assert.ThrowsExactly(() => Base32.FromBase32String(null)); + + } + + [TestMethod] + public void FromBase32HexString_GivenNullString_ThrowsException() + { + Assert.ThrowsExactly(() => Base32.FromBase32HexString(null)); + + } + + [TestMethod] + public void FromBase32String_GivenStringWithSpace_ThrowsException() + { + Assert.ThrowsExactly(() => Base32.FromBase32String("MZXW6YTBOI====== ")); + } + [TestMethod] + public void FromBase32HexString_GivenNullStringSpace_ThrowsException() + { + Assert.ThrowsExactly(() => Base32.FromBase32HexString("MZXW6YTBOI====== ")); + } + } [TestClass] public class Base32HexTests @@ -185,7 +210,7 @@ public void FromBase32HexString_RfcVectors_DecodeCorrectly() [TestMethod] public void ToBase32HexString_RandomBytes_MatchesExpectedEncoding() { - var expected = "3O6435QP17HCKBK7S45K90F6VSFJ4JEFRI131PK84DP2A7UD4NTG===="; + const string expected = "3O6435QP17HCKBK7S45K90F6VSFJ4JEFRI131PK84DP2A7UD4NTG===="; var result = Base32.ToBase32HexString(RandomBytes); Assert.AreEqual(expected, result); } @@ -193,7 +218,7 @@ public void ToBase32HexString_RandomBytes_MatchesExpectedEncoding() [TestMethod] public void FromBase32HexString_RandomBytes_ReturnsOriginalInput() { - var encoded = "3O6435QP17HCKBK7S45K90F6VSFJ4JEFRI131PK84DP2A7UD4NTG===="; + const string encoded = "3O6435QP17HCKBK7S45K90F6VSFJ4JEFRI131PK84DP2A7UD4NTG===="; var decoded = Base32.FromBase32HexString(encoded); CollectionAssert.AreEqual(RandomBytes, decoded); } diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary/BinaryNumberTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary/BinaryNumberTests.cs index d4d9414c..59ccaf02 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary/BinaryNumberTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary/BinaryNumberTests.cs @@ -69,7 +69,7 @@ public void Constructor_ShouldThrow_WhenStreamIsUnreadable() // WHEN + THEN Assert.ThrowsExactly(() => { - var reader = new BinaryReader(unreadableStream); + using var reader = new BinaryReader(unreadableStream); _ = new BinaryNumber(reader); // will not be reached }); } diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary/CollectionExtensionsTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary/CollectionExtensionsTests.cs index f4e792e2..cf42ae6c 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary/CollectionExtensionsTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary/CollectionExtensionsTests.cs @@ -45,7 +45,7 @@ public void Shuffle_ShouldNotChangeSingleElementList() public void Shuffle_ShouldNotThrow_WhenEmpty() { // GIVEN - var input = new int[] { }; + var input = Array.Empty(); // WHEN input.Shuffle(); @@ -74,6 +74,18 @@ public void Convert_List_ShouldTransformElements() Assert.AreEqual(30, result[2]); } + [TestMethod] + public void Convert_List_ShouldThrow_WhenArrayIsNull() + { + // GIVEN + IReadOnlyList? input = null; + + // WHEN + THEN + Assert.ThrowsExactly( + () => input.Convert(x => x * 10) + ); + } + [TestMethod] public void Convert_List_ShouldThrow_WhenConverterIsNull() { @@ -103,6 +115,18 @@ public void Convert_Collection_ShouldPreserveCount() Assert.HasCount(3, result); } + [TestMethod] + public void Convert_Collection_ShouldThrow_WhenCollectionIsNull() + { + // GIVEN + IReadOnlyCollection input = null; + + // WHEN + THEN + Assert.ThrowsExactly( + () => input.Convert(x => x * 10) + ); + } + [TestMethod] public void Convert_Collection_ShouldThrow_WhenConverterIsNull() { diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary/IndependentTaskSchedulerTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary/IndependentTaskSchedulerTests.cs index fba6bd9f..50e7bb7c 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary/IndependentTaskSchedulerTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary/IndependentTaskSchedulerTests.cs @@ -55,8 +55,8 @@ Task Body() => }); // WHEN - var t1 = Task.Factory.StartNew(() => Body(), CancellationToken.None, TaskCreationOptions.None, scheduler).Unwrap(); - var t2 = Task.Factory.StartNew(() => Body(), CancellationToken.None, TaskCreationOptions.None, scheduler).Unwrap(); + _ = Task.Factory.StartNew(() => Body(), CancellationToken.None, TaskCreationOptions.None, scheduler).Unwrap(); + _ = Task.Factory.StartNew(() => Body(), CancellationToken.None, TaskCreationOptions.None, scheduler).Unwrap(); // THEN Assert.IsTrue(await parallelStarted.Task); diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary/TaskPoolTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary/TaskPoolTests.cs index 520997bc..971cf7e1 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary/TaskPoolTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary/TaskPoolTests.cs @@ -103,7 +103,7 @@ public void Ctor_ShouldUseDefaultConcurrency_WhenValueIsLessThanOne() } [TestMethod] - public void TryQueueTask_ShouldReturnFalse_WhenTaskIsNull() + public void TryQueueTask_ShouldThrow_WhenTaskIsNull() { // GIVEN var pool = new TaskPool(); diff --git a/TechnitiumLibrary/Base32.cs b/TechnitiumLibrary/Base32.cs index ba0a994c..13fc2254 100644 --- a/TechnitiumLibrary/Base32.cs +++ b/TechnitiumLibrary/Base32.cs @@ -61,11 +61,6 @@ static Base32() private static string Encode(Span data, char[] map, bool skipPadding) { - if (data == null) - { - throw new ArgumentNullException("data"); - } - StringBuilder sb = new StringBuilder(); int r = data.Length % 5; @@ -169,7 +164,7 @@ private static byte[] Decode(string data, int[] rmap) } if (data.Contains(' ')) { - throw new ArgumentException(nameof(data)); + throw new ArgumentException("Base32 string cannot contain spaces.", nameof(data)); } byte[] buffer; From 5f7abea3f3e6eb3d22c6642f2b1f257bccf371de Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sat, 6 Dec 2025 23:09:04 +0200 Subject: [PATCH 19/74] Added another edge case --- .../TechnitiumLibrary/JsonExtensionsTests.cs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary/JsonExtensionsTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary/JsonExtensionsTests.cs index 0336dfd4..2233a0ec 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary/JsonExtensionsTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary/JsonExtensionsTests.cs @@ -165,7 +165,9 @@ public void ReadArrayAsMap_ShouldReturnDictionary_WhenMappingReturnsPairs() var result = json.ReadArrayAsMap("values", el => { var key = el.GetProperty("k").GetString(); +#pragma warning disable CS8604 // Possible null reference argument. var val = int.Parse(el.GetProperty("v").GetString()); +#pragma warning restore CS8604 // Possible null reference argument. return Tuple.Create(key, val); }); @@ -182,13 +184,26 @@ public void TryReadArrayAsMap_ShouldReturnFalse_WhenPropertyMissing() var json = ToElement("""{ "other": [] }"""); // WHEN - var result = json.TryReadArrayAsMap("values", el => null, out var map); + var result = json.TryReadArrayAsMap("values", _ => null, out var map); // THEN Assert.IsFalse(result); Assert.IsNull(map); } + [TestMethod] + public void ReadArrayAsMap_ShouldIgnoreNullReturnedPairs() + { + var json = ToElement(""" + { "arr": [123, 456] } + """); + + var result = json.ReadArrayAsMap("arr", _ => null); + + Assert.IsEmpty(result); + } + + // ------------------------------ // GET PROPERTY VALUE // ------------------------------ From 60f03333a5f3dd712895fd62bc4bba27236b5269 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sat, 6 Dec 2025 23:09:44 +0200 Subject: [PATCH 20/74] Added folders for the projects to be covered --- TechnitiumLibrary.Tests/TechnitiumLibrary.Tests.csproj | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Tests.csproj b/TechnitiumLibrary.Tests/TechnitiumLibrary.Tests.csproj index 81e42102..42b7c5c6 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary.Tests.csproj +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Tests.csproj @@ -9,7 +9,15 @@ + + + + + + + + From 5bbdcef9460e3e4942b8e8a202c08446fbea33de Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sat, 6 Dec 2025 23:09:54 +0200 Subject: [PATCH 21/74] Added ByteTree tests --- .../ByteTreeTests.cs | 315 ++++++++++++++++++ 1 file changed, 315 insertions(+) create mode 100644 TechnitiumLibrary.Tests/TechnitiumLibrary.ByteTree/ByteTreeTests.cs diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.ByteTree/ByteTreeTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.ByteTree/ByteTreeTests.cs new file mode 100644 index 00000000..4d39b010 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.ByteTree/ByteTreeTests.cs @@ -0,0 +1,315 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Collections.Generic; +using System.Linq; +using TechnitiumLibrary.ByteTree; + +namespace TechnitiumLibrary.Tests +{ + [TestClass] + public sealed class ByteTreeTests + { + private static byte[] Key(params byte[] b) => b; + + // --------------------------- + // ADD + GET + // --------------------------- + [TestMethod] + public void Add_ShouldInsertValue_WhenKeyDoesNotExist() + { + // GIVEN + var tree = new ByteTree(); + + // WHEN + tree.Add(Key(1, 2, 3), "value"); + + // THEN + Assert.AreEqual("value", tree[Key(1, 2, 3)]); + } + + [TestMethod] + public void Add_ShouldThrow_WhenKeyExists() + { + // GIVEN + var tree = new ByteTree(); + tree.Add(Key(4), "first"); + + // WHEN – THEN + Assert.ThrowsExactly(() => + tree.Add(Key(4), "duplicate")); + } + + [TestMethod] + public void Add_ShouldThrow_WhenKeyNull() + { + var tree = new ByteTree(); + Assert.ThrowsExactly(() => tree.Add(null, "x")); + } + + // --------------------------- + // TryAdd + // --------------------------- + [TestMethod] + public void TryAdd_ShouldReturnTrue_WhenKeyAdded() + { + var tree = new ByteTree(); + var result = tree.TryAdd(Key(1), "v"); + Assert.IsTrue(result); + } + + [TestMethod] + public void TryAdd_ShouldReturnFalse_WhenKeyExists() + { + var tree = new ByteTree(); + tree.Add(Key(5), "initial"); + + var result = tree.TryAdd(Key(5), "other"); + + Assert.IsFalse(result); + Assert.AreEqual("initial", tree[Key(5)]); + } + + [TestMethod] + public void TryAdd_ShouldThrow_WhenKeyNull() + { + var tree = new ByteTree(); + Assert.ThrowsExactly(() => tree.TryAdd(null, "x")); + } + + // --------------------------- + // GET operations + // --------------------------- + [TestMethod] + public void TryGet_ShouldReturnTrue_WhenKeyExists() + { + var tree = new ByteTree(); + tree.Add(Key(1, 2), "data"); + + var found = tree.TryGet(Key(1, 2), out var value); + + Assert.IsTrue(found); + Assert.AreEqual("data", value); + } + + [TestMethod] + public void TryGet_ShouldReturnFalse_WhenMissing() + { + var tree = new ByteTree(); + + var result = tree.TryGet(Key(9), out var value); + + Assert.IsFalse(result); + Assert.IsNull(value); + } + + [TestMethod] + public void TryGet_ShouldThrow_WhenNull() + { + var tree = new ByteTree(); + Assert.ThrowsExactly(() => tree.TryGet(null, out _)); + } + + // --------------------------- + // ContainsKey + // --------------------------- + [TestMethod] + public void ContainsKey_ShouldReturnTrue_WhenKeyPresent() + { + var tree = new ByteTree(); + tree.Add(Key(3, 3), "v"); + + Assert.IsTrue(tree.ContainsKey(Key(3, 3))); + } + + [TestMethod] + public void ContainsKey_ShouldReturnFalse_WhenKeyMissing() + { + var tree = new ByteTree(); + Assert.IsFalse(tree.ContainsKey(Key(3, 100))); + } + + [TestMethod] + public void ContainsKey_ShouldThrow_WhenNull() + { + var tree = new ByteTree(); + Assert.ThrowsExactly(() => tree.ContainsKey(null)); + } + + // --------------------------- + // Remove + // --------------------------- + [TestMethod] + public void TryRemove_ShouldReturnTrue_WhenKeyExists() + { + var tree = new ByteTree(); + tree.Add(Key(10), "v"); + + var result = tree.TryRemove(Key(10), out var removed); + + Assert.IsTrue(result); + Assert.AreEqual("v", removed); + Assert.IsFalse(tree.ContainsKey(Key(10))); + } + + [TestMethod] + public void TryRemove_ShouldReturnFalse_WhenMissing() + { + var tree = new ByteTree(); + var result = tree.TryRemove(Key(11), out var removed); + + Assert.IsFalse(result); + Assert.IsNull(removed); + } + + [TestMethod] + public void TryRemove_ShouldThrow_WhenNull() + { + var tree = new ByteTree(); + Assert.ThrowsExactly(() => tree.TryRemove(null, out _)); + } + + // --------------------------- + // TryUpdate + // --------------------------- + [TestMethod] + public void TryUpdate_ShouldReplaceValue_WhenComparisonMatches() + { + var tree = new ByteTree(); + tree.Add(Key(5), "old"); + + var updated = tree.TryUpdate(Key(5), "new", "old"); + + Assert.IsTrue(updated); + Assert.AreEqual("new", tree[Key(5)]); + } + + [TestMethod] + public void TryUpdate_ShouldReturnFalse_WhenComparisonDoesNotMatch() + { + var tree = new ByteTree(); + tree.Add(Key(7), "original"); + + var updated = tree.TryUpdate(Key(7), "attempt", "different"); + + Assert.IsFalse(updated); + Assert.AreEqual("original", tree[Key(7)]); + } + + // --------------------------- + // AddOrUpdate + // --------------------------- + [TestMethod] + public void AddOrUpdate_ShouldInsert_WhenMissing() + { + var tree = new ByteTree(); + + var val = tree.AddOrUpdate( + Key(1, 1), + _ => "create", + (_, old) => old + "update"); + + Assert.AreEqual("create", val); + } + + [TestMethod] + public void AddOrUpdate_ShouldModify_WhenExists() + { + var tree = new ByteTree(); + tree.Add(Key(1, 2), "first"); + + var updated = tree.AddOrUpdate( + Key(1, 2), + _ => "ignored", + (_, old) => old + "_changed"); + + Assert.AreEqual("first_changed", updated); + } + + // --------------------------- + // Indexer get/set + // --------------------------- + [TestMethod] + public void Indexer_Get_ShouldReturnExactValue() + { + var tree = new ByteTree(); + tree.Add(Key(99), "stored"); + + Assert.AreEqual("stored", tree[Key(99)]); + } + + [TestMethod] + public void Indexer_Set_ShouldOverwriteFormerValue() + { + var tree = new ByteTree(); + tree[Key(5, 5)] = "initial"; + + tree[Key(5, 5)] = "updated"; + + Assert.AreEqual("updated", tree[Key(5, 5)]); + } + + [TestMethod] + public void Indexer_Get_ShouldThrow_WhenMissingKey() + { + var tree = new ByteTree(); + Assert.ThrowsExactly(() => + _ = tree[Key(8, 8)]); + } + + [TestMethod] + public void Indexer_ShouldThrow_WhenNullKey() + { + var tree = new ByteTree(); + Assert.ThrowsExactly(() => tree[null] = "x"); + } + + // --------------------------- + // Enumeration + // --------------------------- + [TestMethod] + public void Enumerator_ShouldYieldExistingValues() + { + var tree = new ByteTree(); + tree.Add(Key(1), "x"); + tree.Add(Key(2), "y"); + tree.Add(Key(3), "z"); + + var values = tree.ToList(); + + Assert.AreEqual(3, values.Count); + CollectionAssert.AreEquivalent(new[] { "x", "y", "z" }, values); + } + + [TestMethod] + public void ReverseEnumerable_ShouldYieldInReverseOrder() + { + var tree = new ByteTree(); + tree.Add(Key(0), "a"); + tree.Add(Key(1), "b"); + tree.Add(Key(255), "c"); + + var result = tree.GetReverseEnumerable().ToList(); + + Assert.AreEqual(3, result.Count); + Assert.AreEqual("c", result[0]); // last sorted key + Assert.AreEqual("b", result[1]); + Assert.AreEqual("a", result[2]); + } + + // --------------------------- + // Clear + // --------------------------- + [TestMethod] + public void Clear_ShouldEraseAllData() + { + var tree = new ByteTree(); + tree.Add(Key(1), "x"); + tree.Add(Key(2), "y"); + + tree.Clear(); + + Assert.IsTrue(tree.IsEmpty); + Assert.IsFalse(tree.ContainsKey(Key(1))); + } + } +} From ffba37b1658eed78c4fdb5db2b0ab35e27d80bae Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sat, 6 Dec 2025 23:19:23 +0200 Subject: [PATCH 22/74] Added binary stream size check --- .../BinaryReaderExtensions.cs | 68 +++++++++++++------ 1 file changed, 48 insertions(+), 20 deletions(-) diff --git a/TechnitiumLibrary.IO/BinaryReaderExtensions.cs b/TechnitiumLibrary.IO/BinaryReaderExtensions.cs index d2ec8cd8..f2db442b 100644 --- a/TechnitiumLibrary.IO/BinaryReaderExtensions.cs +++ b/TechnitiumLibrary.IO/BinaryReaderExtensions.cs @@ -28,7 +28,14 @@ public static class BinaryReaderExtensions { public static byte[] ReadBuffer(this BinaryReader bR) { - return bR.ReadBytes(ReadLength(bR)); + int len = ReadLength(bR); + + byte[] buffer = bR.ReadBytes(len); + + if (buffer.Length != len) + throw new EndOfStreamException("Unexpected end of stream while reading buffer."); + + return buffer; } public static string ReadShortString(this BinaryReader bR) @@ -38,32 +45,53 @@ public static string ReadShortString(this BinaryReader bR) public static string ReadShortString(this BinaryReader bR, Encoding encoding) { - return encoding.GetString(bR.ReadBytes(bR.ReadByte())); + int length = bR.ReadByte(); + byte[] bytes = bR.ReadBytes(length); + + if (bytes.Length != length) + throw new EndOfStreamException("Not enough bytes to read short string."); + + return encoding.GetString(bytes); } public static DateTime ReadDateTime(this BinaryReader bR) { - return DateTime.UnixEpoch.AddMilliseconds(bR.ReadInt64()); + // Read int64 big-endian timestamp (same as original behavior because .NET native is LE) + Span buffer = stackalloc byte[8]; + int read = bR.BaseStream.Read(buffer); + + if (read != 8) + throw new EndOfStreamException("Not enough bytes to read DateTime ticks."); + + long millis = BinaryPrimitives.ReadInt64LittleEndian(buffer); + return DateTime.UnixEpoch.AddMilliseconds(millis); } public static int ReadLength(this BinaryReader bR) { - int length1 = bR.ReadByte(); - if (length1 > 127) - { - int numberLenBytes = length1 & 0x7F; - if (numberLenBytes > 4) - throw new IOException("BinaryReaderExtension encoding length not supported."); - - Span valueBytes = stackalloc byte[4]; - bR.BaseStream.ReadExactly(valueBytes.Slice(4 - numberLenBytes, numberLenBytes)); - - return BinaryPrimitives.ReadInt32BigEndian(valueBytes); - } - else - { - return length1; - } + int first = bR.ReadByte(); + if (first < 0) + throw new EndOfStreamException("Not enough bytes for a length prefix."); + + // Single byte value + if (first <= 127) + return first; + + // Otherwise, multi-byte length + int numberLenBytes = first & 0x7F; + + if (numberLenBytes > 4) + throw new IOException("BinaryReaderExtension encoding length not supported."); + + Span temp = stackalloc byte[4]; + + int offset = 4 - numberLenBytes; + int readBytes = bR.BaseStream.Read(temp[offset..]); + + if (readBytes != numberLenBytes) + throw new EndOfStreamException("Not enough bytes for encoded length."); + + return BinaryPrimitives.ReadInt32BigEndian(temp); } } -} +} \ No newline at end of file From 46737f43d2f897a6325ffe30e1ade1ec6a123453 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sat, 6 Dec 2025 23:19:38 +0200 Subject: [PATCH 23/74] Added tests for BinaryReaderExtensions --- .../BinaryReaderExtensionsTests.cs | 170 ++++++++++++++++++ .../TechnitiumLibrary.Tests.csproj | 2 +- 2 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 TechnitiumLibrary.Tests/TechnitiumLibrary.IO/BinaryReaderExtensionsTests.cs diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/BinaryReaderExtensionsTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/BinaryReaderExtensionsTests.cs new file mode 100644 index 00000000..4cee1642 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/BinaryReaderExtensionsTests.cs @@ -0,0 +1,170 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Linq; +using System.Text; +using TechnitiumLibrary.IO; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.IO +{ + [TestClass] + public sealed class BinaryReaderExtensionsTests + { + private static BinaryReader ReaderOf(params byte[] bytes) + { + return new BinaryReader(new MemoryStream(bytes)); + } + + // ----------------------------------------------- + // ReadLength() + // ----------------------------------------------- + + [TestMethod] + public void ReadLength_ShouldReadSingleByteLengths() + { + // GIVEN + var reader = ReaderOf(0x05); + + // WHEN + var length = reader.ReadLength(); + + // THEN + Assert.AreEqual(5, length); + Assert.AreEqual(1, reader.BaseStream.Position); + } + + [TestMethod] + public void ReadLength_ShouldReadMultiByteBigEndianLengths() + { + // GIVEN + // 0x82 => 2-byte length follows → value = 0x01 0x2C → 300 decimal + var reader = ReaderOf(0x82, 0x01, 0x2C); + + // WHEN + var length = reader.ReadLength(); + + // THEN + Assert.AreEqual(300, length); + Assert.AreEqual(3, reader.BaseStream.Position); + } + + [TestMethod] + public void ReadLength_ShouldThrow_WhenLengthPrefixTooLarge() + { + // GIVEN + // lower 7 bits = 0x05, meaning "next 5 bytes", exceeding allowed 4 + var reader = ReaderOf(0x85); + + // WHEN-THEN + Assert.ThrowsExactly(() => reader.ReadLength()); + } + + // ----------------------------------------------- + // ReadBuffer() + // ----------------------------------------------- + + [TestMethod] + public void ReadBuffer_ShouldReturnBytes_WhenLengthPrefixed() + { + // GIVEN + // length=3, then bytes 0xAA, 0xBB, 0xCC + var reader = ReaderOf(0x03, 0xAA, 0xBB, 0xCC); + + // WHEN + var data = reader.ReadBuffer(); + + // THEN + Assert.HasCount(3, data); + CollectionAssert.AreEqual(new byte[] { 0xAA, 0xBB, 0xCC }, data); + } + + // ----------------------------------------------- + // ReadShortString() + // ----------------------------------------------- + + [TestMethod] + public void ReadShortString_ShouldDecodeUtf8StringCorrectly() + { + // GIVEN + var text = "Hello"; + var encoded = Encoding.UTF8.GetBytes(text); + + var bytes = new byte[] { (byte)encoded.Length }.Concat(encoded).ToArray(); + var reader = ReaderOf(bytes); + + // WHEN + var result = reader.ReadShortString(); + + // THEN + Assert.AreEqual(text, result); + } + + [TestMethod] + public void ReadShortString_ShouldUseSpecifiedEncoding() + { + // GIVEN + var text = "Å"; + var encoding = Encoding.UTF32; + var encoded = encoding.GetBytes(text); + + var bytes = new byte[] { (byte)encoded.Length }.Concat(encoded).ToArray(); + var reader = ReaderOf(bytes); + + // WHEN + var result = reader.ReadShortString(encoding); + + // THEN + Assert.AreEqual(text, result); + } + + // ----------------------------------------------- + // ReadDateTime() + // ----------------------------------------------- + + [TestMethod] + public void ReadDateTime_ShouldConvertEpochMilliseconds() + { + // GIVEN + var expected = new DateTime(2024, 01, 01, 12, 00, 00, DateTimeKind.Utc); + long millis = (long)(expected - DateTime.UnixEpoch).TotalMilliseconds; + + byte[] encoded = BitConverter.GetBytes(millis); + if (BitConverter.IsLittleEndian) + Array.Reverse(encoded); + + var reader = ReaderOf(encoded.Reverse().ToArray()); + + // WHEN + var result = reader.ReadDateTime(); + + // THEN + Assert.AreEqual(expected, result); + } + + // ----------------------------------------------- + // Invalid stream / broken data integrity + // ----------------------------------------------- + + [TestMethod] + public void ReadShortString_ShouldThrow_WhenNotEnoughBytes() + { + // GIVEN + // says length=4 but only 2 follow + var reader = ReaderOf(0x04, 0xAA, 0xBB); + + // WHEN-THEN + Assert.ThrowsExactly(() => reader.ReadShortString()); + } + + [TestMethod] + public void ReadBuffer_ShouldThrow_WhenStreamEndsEarly() + { + // GIVEN + // prefixed length=5, only 3 bytes exist + var reader = ReaderOf(0x05, 0x10, 0x20, 0x30); + + // WHEN-THEN + Assert.ThrowsExactly(() => reader.ReadBuffer()); + } + } +} diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Tests.csproj b/TechnitiumLibrary.Tests/TechnitiumLibrary.Tests.csproj index 42b7c5c6..2338d503 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary.Tests.csproj +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Tests.csproj @@ -10,6 +10,7 @@ + @@ -17,7 +18,6 @@ - From ce45c4fc15ad8e261ced532c3ab65b539790eca2 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sat, 6 Dec 2025 23:25:48 +0200 Subject: [PATCH 24/74] Added tests for BinaryWriterExtensions --- .../BinaryWriterExtensionsTests.cs | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) create mode 100644 TechnitiumLibrary.Tests/TechnitiumLibrary.IO/BinaryWriterExtensionsTests.cs diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/BinaryWriterExtensionsTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/BinaryWriterExtensionsTests.cs new file mode 100644 index 00000000..d7836b7a --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/BinaryWriterExtensionsTests.cs @@ -0,0 +1,174 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Linq; +using System.Text; +using TechnitiumLibrary.IO; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.IO +{ + [TestClass] + public sealed class BinaryWriterExtensionsTests + { + private static (BinaryWriter writer, MemoryStream stream) CreateWriter() + { + var ms = new MemoryStream(); + var bw = new BinaryWriter(ms); + return (bw, ms); + } + + private static byte[] WrittenBytes(MemoryStream ms) => + ms.ToArray(); + + // --------------------------------------- + // WriteLength() tests + // --------------------------------------- + + [TestMethod] + public void WriteLength_ShouldEncodeSingleByte_WhenLessThan128() + { + // GIVEN + var (bw, ms) = CreateWriter(); + + // WHEN + bw.WriteLength(42); + + // THEN + CollectionAssert.AreEqual(new byte[] { 42 }, WrittenBytes(ms)); + } + + [TestMethod] + public void WriteLength_ShouldEncodeMultiByte_BigEndianForm() + { + // GIVEN + var (bw, ms) = CreateWriter(); + + // WHEN + // length = 0x0000012C (300 decimal) + bw.WriteLength(300); + + // THEN + // Prefix = 0x82 (2 bytes follow) + // Then big-endian 01 2C + CollectionAssert.AreEqual( + new byte[] { 0x82, 0x01, 0x2C }, + WrittenBytes(ms) + ); + } + + // --------------------------------------- + // WriteBuffer() + // --------------------------------------- + + [TestMethod] + public void WriteBuffer_ShouldPrefixLength_AndWriteBytes() + { + // GIVEN + var (bw, ms) = CreateWriter(); + var data = new byte[] { 0xAA, 0xBB, 0xCC }; + + // WHEN + bw.WriteBuffer(data); + + // THEN + CollectionAssert.AreEqual( + new byte[] { 0x03, 0xAA, 0xBB, 0xCC }, + WrittenBytes(ms) + ); + } + + [TestMethod] + public void WriteBuffer_WithOffset_ShouldWriteExpectedSegment() + { + // GIVEN + var (bw, ms) = CreateWriter(); + var data = new byte[] { 1, 2, 3, 4, 5 }; + + // WHEN + bw.WriteBuffer(data, offset: 1, count: 3); + + // THEN + CollectionAssert.AreEqual( + new byte[] { 0x03, 2, 3, 4 }, + WrittenBytes(ms) + ); + } + + // --------------------------------------- + // WriteShortString() + // --------------------------------------- + + [TestMethod] + public void WriteShortString_ShouldWriteUtf8EncodedWithLength() + { + // GIVEN + var (bw, ms) = CreateWriter(); + var text = "Hello"; + var utf8 = Encoding.UTF8.GetBytes(text); + + // WHEN + bw.WriteShortString(text); + + // THEN + var expected = new byte[] { (byte)utf8.Length } + .Concat(utf8) + .ToArray(); + + CollectionAssert.AreEqual(expected, WrittenBytes(ms)); + } + + [TestMethod] + public void WriteShortString_ShouldUseSpecifiedEncoding() + { + // GIVEN + var (bw, ms) = CreateWriter(); + var text = "Å"; + var enc = Encoding.UTF32; + var bytes = enc.GetBytes(text); + + // WHEN + bw.WriteShortString(text, enc); + + // THEN + var expected = new byte[] { (byte)bytes.Length } + .Concat(bytes) + .ToArray(); + + CollectionAssert.AreEqual(expected, WrittenBytes(ms)); + } + + [TestMethod] + public void WriteShortString_ShouldThrow_WhenStringTooLong() + { + // GIVEN + var (bw, ms) = CreateWriter(); + var input = new string('x', 256); // UTF-8 => 256 bytes + + // WHEN–THEN + Assert.ThrowsExactly(() => + bw.WriteShortString(input) + ); + } + + // --------------------------------------- + // Write(DateTime) + // --------------------------------------- + + [TestMethod] + public void WriteDate_ShouldEncodeMillisecondsFromUnixEpoch() + { + // GIVEN + var expected = new DateTime(2024, 1, 2, 12, 00, 00, DateTimeKind.Utc); + var millis = (long)(expected - DateTime.UnixEpoch).TotalMilliseconds; + + var bytes = BitConverter.GetBytes(millis); + var (bw, ms) = CreateWriter(); + + // WHEN + bw.Write(expected); + + // THEN + CollectionAssert.AreEqual(bytes, WrittenBytes(ms)); + } + } +} From 33d4857eaec74251b5240042e4bc699e71c0bb9f Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sat, 6 Dec 2025 23:37:12 +0200 Subject: [PATCH 25/74] Improve disposal behavior in Joint to guarantee both stream copies complete before shutdown --- TechnitiumLibrary.IO/Joint.cs | 46 ++++++++++++----------------------- 1 file changed, 16 insertions(+), 30 deletions(-) diff --git a/TechnitiumLibrary.IO/Joint.cs b/TechnitiumLibrary.IO/Joint.cs index 703776a9..092edb79 100644 --- a/TechnitiumLibrary.IO/Joint.cs +++ b/TechnitiumLibrary.IO/Joint.cs @@ -1,24 +1,6 @@ -/* -Technitium Library -Copyright (C) 2024 Shreyas Zare (shreyas@technitium.com) - -This program is free software: you can redistribute it and/or modify -it under the terms of the GNU General Public License as published by -the Free Software Foundation, either version 3 of the License, or -(at your option) any later version. - -This program is distributed in the hope that it will be useful, -but WITHOUT ANY WARRANTY; without even the implied warranty of -MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -GNU General Public License for more details. - -You should have received a copy of the GNU General Public License -along with this program. If not, see . - -*/ - -using System; +using System; using System.IO; +using System.Threading; using System.Threading.Tasks; namespace TechnitiumLibrary.IO @@ -36,6 +18,9 @@ public class Joint : IDisposable readonly Stream _stream1; readonly Stream _stream2; + // track copy completion + private int _pendingCopies = 2; + #endregion #region constructor @@ -72,11 +57,8 @@ protected virtual void Dispose(bool disposing) { Disposing?.Invoke(this, EventArgs.Empty); - if (_stream1 != null) - _stream1.Dispose(); - - if (_stream2 != null) - _stream2.Dispose(); + _stream1?.Dispose(); + _stream2?.Dispose(); } } } @@ -85,6 +67,12 @@ protected virtual void Dispose(bool disposing) #region private + private void OnCopyFinished() + { + if (Interlocked.Decrement(ref _pendingCopies) == 0) + Dispose(); + } + private async Task CopyToAsync(Stream src, Stream dst) { try @@ -93,7 +81,7 @@ private async Task CopyToAsync(Stream src, Stream dst) } finally { - Dispose(); + OnCopyFinished(); } } @@ -111,11 +99,9 @@ public void Start() #region properties - public Stream Stream1 - { get { return _stream1; } } + public Stream Stream1 => _stream1; - public Stream Stream2 - { get { return _stream2; } } + public Stream Stream2 => _stream2; #endregion } From b553de0d521ede8f4e923bf3b8a8eabb0960e74b Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sat, 6 Dec 2025 23:37:58 +0200 Subject: [PATCH 26/74] Added tests for Joint class --- .../TechnitiumLibrary.IO/JointTests.cs | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 TechnitiumLibrary.Tests/TechnitiumLibrary.IO/JointTests.cs diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/JointTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/JointTests.cs new file mode 100644 index 00000000..73c8e25b --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/JointTests.cs @@ -0,0 +1,186 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Threading.Tasks; +using TechnitiumLibrary.IO; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.IO +{ + [TestClass] + public sealed class JointTests + { + private static async Task WaitForCopyCompletion() + { + // The copy tasks run asynchronously and Joint.Dispose() executes + // when either side reaches EOF. Wait slightly longer than default buffering time. + await Task.Delay(80); + } + + // --------------------------------------- + // Constructor and property access + // --------------------------------------- + + [TestMethod] + public void Constructor_ShouldStoreStreams() + { + // GIVEN + var s1 = new MemoryStream(); + var s2 = new MemoryStream(); + + // WHEN + var joint = new Joint(s1, s2); + + // THEN + Assert.AreSame(s1, joint.Stream1); + Assert.AreSame(s2, joint.Stream2); + } + + // --------------------------------------- + // Data transfer behavior + // --------------------------------------- + + [TestMethod] + public async Task Start_ShouldCopyData_FromStream1ToStream2() + { + // GIVEN + var sourceData = new byte[] { 1, 2, 3, 4 }; + using var s1 = new MemoryStream(sourceData); + using var s2 = new MemoryStream(); + using var joint = new Joint(s1, s2); + + // WHEN + joint.Start(); + await WaitForCopyCompletion(); + + // THEN + var result = s2.ToArray(); + CollectionAssert.AreEqual(sourceData, result); + } + + [TestMethod] + public async Task Start_ShouldCopyData_FromStream2ToStream1() + { + // GIVEN + var sourceData = new byte[] { 7, 8, 9 }; + using var s1 = new MemoryStream(); + using var s2 = new MemoryStream(sourceData); + using var joint = new Joint(s1, s2); + + // WHEN + joint.Start(); + await WaitForCopyCompletion(); + + // THEN + var result = s1.ToArray(); + CollectionAssert.AreEqual(sourceData, result); + } + + // --------------------------------------- + // Empty stream scenarios + // --------------------------------------- + + [TestMethod] + public async Task Start_ShouldSupportEmptyStreams() + { + // GIVEN + using var s1 = new MemoryStream(); + using var s2 = new MemoryStream(); + using var joint = new Joint(s1, s2); + + // WHEN + joint.Start(); + await WaitForCopyCompletion(); + + // THEN + var buff1 = s1.ToArray(); + var buff2 = s2.ToArray(); + + CollectionAssert.AreEqual(Array.Empty(), buff1); + CollectionAssert.AreEqual(Array.Empty(), buff2); + } + + // --------------------------------------- + // Disposal semantics + // --------------------------------------- + + [TestMethod] + public async Task Dispose_ShouldCloseStreams() + { + // GIVEN + var s1 = new MemoryStream(new byte[] { 10 }); + var s2 = new MemoryStream(new byte[] { 20 }); + var joint = new Joint(s1, s2); + + // WHEN + joint.Dispose(); + await WaitForCopyCompletion(); + + // THEN + Assert.ThrowsExactly(() => { var _ = s1.Length; }); + Assert.ThrowsExactly(() => { var _ = s2.Length; }); + } + + [TestMethod] + public void Dispose_ShouldBeIdempotent() + { + // GIVEN + var s1 = new MemoryStream(); + var s2 = new MemoryStream(); + var joint = new Joint(s1, s2); + + // WHEN + joint.Dispose(); + joint.Dispose(); + joint.Dispose(); // Should not throw + + // THEN + Assert.IsTrue(true); // No exception was thrown + } + + // --------------------------------------- + // Disposal callback behavior + // --------------------------------------- + + [TestMethod] + public void Dispose_ShouldRaiseDisposingEvent() + { + // GIVEN + using var s1 = new MemoryStream(); + using var s2 = new MemoryStream(); + using var joint = new Joint(s1, s2); + + bool raised = false; + joint.Disposing += (_, __) => raised = true; + + // WHEN + joint.Dispose(); + + // THEN + Assert.IsTrue(raised); + } + + // --------------------------------------- + // Concurrency semantics + // --------------------------------------- + + [TestMethod] + public async Task Start_ShouldDisposeOnce_WhenBothDirectionsComplete() + { + // GIVEN + using var s1 = new MemoryStream(new byte[] { 1 }); + using var s2 = new MemoryStream(new byte[] { 2 }); + + using var joint = new Joint(s1, s2); + + int disposedCount = 0; + joint.Disposing += (_, __) => disposedCount++; + + // WHEN + joint.Start(); + await WaitForCopyCompletion(); + + // THEN + Assert.AreEqual(1, disposedCount, "Disposing must fire only once"); + } + } +} From abdf66084ed93f543639c860d2bc5884c6577d13 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sat, 6 Dec 2025 23:42:32 +0200 Subject: [PATCH 27/74] Added tests for OffsetStream class --- .editorconfig | 7 + .../TechnitiumLibrary.IO/OffsetStreamTests.cs | 246 ++++++++++++++++++ TechnitiumLibrary.sln | 5 + 3 files changed, 258 insertions(+) create mode 100644 .editorconfig create mode 100644 TechnitiumLibrary.Tests/TechnitiumLibrary.IO/OffsetStreamTests.cs diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..65bae17c --- /dev/null +++ b/.editorconfig @@ -0,0 +1,7 @@ +[*.cs] + +# IDE0008: Use explicit type +dotnet_diagnostic.IDE0008.severity = silent + +# IDE0300: Simplify collection initialization +dotnet_diagnostic.IDE0300.severity = silent diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/OffsetStreamTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/OffsetStreamTests.cs new file mode 100644 index 00000000..648af8fb --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/OffsetStreamTests.cs @@ -0,0 +1,246 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Text; +using System.Threading.Tasks; +using TechnitiumLibrary.IO; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.IO +{ + [TestClass] + public sealed class OffsetStreamTests + { + private static MemoryStream CreateStream(byte[] data) => new MemoryStream(data, writable: true); + + // ------------------------------------------------------ + // CONSTRUCTION & BASIC METADATA + // ------------------------------------------------------ + + [TestMethod] + public void Constructor_ShouldExposeCorrectBasicProperties() + { + // GIVEN + var source = CreateStream(new byte[] { 1, 2, 3, 4, 5 }); + + // WHEN + var offsetStream = new OffsetStream(source, offset: 1, length: 3); + + // THEN + Assert.AreEqual(3, offsetStream.Length); + Assert.AreEqual(0, offsetStream.Position); + Assert.IsTrue(offsetStream.CanRead); + Assert.IsTrue(offsetStream.CanSeek); + } + + [TestMethod] + public void Constructor_ShouldRespectReadOnlyFlag() + { + // GIVEN + var source = CreateStream(new byte[10]); + + // WHEN + var offsetStream = new OffsetStream(source, readOnly: true); + + // THEN + Assert.IsFalse(offsetStream.CanWrite); + } + + // ------------------------------------------------------ + // READ OPERATIONS + // ------------------------------------------------------ + + [TestMethod] + public void Read_ShouldReturnSegmentWithinBounds() + { + // GIVEN + var source = CreateStream(new byte[] { 10, 20, 30, 40, 50 }); + var offsetStream = new OffsetStream(source, offset: 1, length: 3); + + var buffer = new byte[10]; + + // WHEN + var readCount = offsetStream.Read(buffer, 0, 10); + + // THEN + Assert.AreEqual(3, readCount); + CollectionAssert.AreEqual(new byte[] { 20, 30, 40 }, buffer[..3]); + } + + [TestMethod] + public void Read_ShouldReturnZero_WhenPastLength() + { + // GIVEN + var source = CreateStream(new byte[] { 1, 2, 3, 4 }); + var offsetStream = new OffsetStream(source, offset: 2, length: 1); + + var buffer = new byte[5]; + offsetStream.Position = 1; + + // WHEN + var count = offsetStream.Read(buffer, 0, 5); + + // THEN + Assert.AreEqual(0, count); + } + + [TestMethod] + public void ReadAsync_ShouldReturnCorrectData() + { + // GIVEN + var source = CreateStream(new byte[] { 9, 8, 7, 6 }); + var offsetStream = new OffsetStream(source, offset: 1, length: 2); + var buffer = new byte[10]; + + // WHEN + var count = offsetStream.ReadAsync(buffer, 0, 10, TestContext.CancellationToken).Result; + + // THEN + Assert.AreEqual(2, count); + CollectionAssert.AreEqual(new byte[] { 8, 7 }, buffer[..2]); + } + + // ------------------------------------------------------ + // WRITE OPERATIONS + // ------------------------------------------------------ + + [TestMethod] + public void Write_ShouldPlaceDataAtOffset() + { + // GIVEN + var source = CreateStream(new byte[] { 1, 2, 3, 4 }); + var offsetStream = new OffsetStream(source, offset: 1, length: 2); + + // WHEN + offsetStream.Write("23"u8.ToArray(), 0, 2); + + // THEN + CollectionAssert.AreEqual(new byte[] { 1, 50, 51, 4 }, source.ToArray()); + } + + [TestMethod] + public void Write_ShouldExtendLength() + { + // GIVEN + var source = CreateStream(new byte[] { 1, 2, 3 }); + var offsetStream = new OffsetStream(source, offset: 0, length: 2); + + // WHEN + offsetStream.Position = 2; + offsetStream.Write("\t"u8.ToArray(), 0, 1); + + // THEN + Assert.AreEqual(3, offsetStream.Length); + } + + [TestMethod] + public void Write_ShouldThrow_WhenReadOnly() + { + // GIVEN + var source = CreateStream(new byte[] { 1, 2, 3 }); + var offsetStream = new OffsetStream(source, readOnly: true); + + // WHEN–THEN + Assert.ThrowsExactly(() => + offsetStream.Write(new byte[] { 0 }, 0, 1)); + } + + // ------------------------------------------------------ + // SEEK OPERATIONS + // ------------------------------------------------------ + + [TestMethod] + public void Seek_ShouldMoveWithinValidRange() + { + // GIVEN + var source = CreateStream(new byte[] { 1, 2, 3, 4 }); + var offsetStream = new OffsetStream(source, offset: 0, length: 4); + + // WHEN + var newPos = offsetStream.Seek(2, SeekOrigin.Begin); + + // THEN + Assert.AreEqual(2, newPos); + Assert.AreEqual(2, offsetStream.Position); + } + + [TestMethod] + public void Seek_ShouldThrow_WhenSeekingPastEnd() + { + // GIVEN + var source = CreateStream(new byte[] { 1, 2, 3 }); + var offsetStream = new OffsetStream(source, offset: 0, length: 3); + + // WHEN–THEN + Assert.ThrowsExactly(() => + offsetStream.Seek(4, SeekOrigin.Begin)); + } + + // ------------------------------------------------------ + // DISPOSAL OWNERSHIP + // ------------------------------------------------------ + + [TestMethod] + public void Dispose_ShouldCloseBaseStream_WhenOwnsStream() + { + // GIVEN + var source = CreateStream(new byte[] { 1 }); + var offsetStream = new OffsetStream(source, ownsStream: true); + + // WHEN + offsetStream.Dispose(); + + // THEN + Assert.ThrowsExactly(() => source.ReadByte()); + } + + [TestMethod] + public void Dispose_ShouldNotCloseBaseStream_WhenNotOwned() + { + // GIVEN + var source = CreateStream(new byte[] { 1 }); + var offsetStream = new OffsetStream(source, ownsStream: false); + + // WHEN + offsetStream.Dispose(); + + // THEN + Assert.AreEqual(1, source.ReadByte()); + } + + // ------------------------------------------------------ + // WRITETO & WRITETOASYNC + // ------------------------------------------------------ + + [TestMethod] + public void WriteTo_ShouldCopyOnlyOffsetRange() + { + // GIVEN + var source = CreateStream(new byte[] { 10, 20, 30, 40 }); + var offsetStream = new OffsetStream(source, offset: 1, length: 2); + var target = new MemoryStream(); + + // WHEN + offsetStream.WriteTo(target); + + // THEN + CollectionAssert.AreEqual(new byte[] { 20, 30 }, target.ToArray()); + } + + [TestMethod] + public async Task WriteToAsync_ShouldCopyOnlyOffsetRange() + { + // GIVEN + var source = CreateStream("2 Date: Sat, 6 Dec 2025 23:52:07 +0200 Subject: [PATCH 28/74] Added tests for using Package class --- .../TechnitiumLibrary.IO/PackageTests.cs | 227 ++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageTests.cs diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageTests.cs new file mode 100644 index 00000000..f8531749 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageTests.cs @@ -0,0 +1,227 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.IO; +using System.Linq; +using System.Text; +using TechnitiumLibrary.IO; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.IO +{ + [TestClass] + public sealed class PackageTests + { + private static MemoryStream CreateWritableStream() => new MemoryStream(); + + private static byte[] BuildEmptyPackageFile() + { + // Header: + // TP format id + // 01 version + // 00 EOF (no items) + return "TP"u8.ToArray() + .Append((byte)1) + .Append((byte)0) + .ToArray(); + } + + /// + /// Creates a serialized single PackageItem with name "A" and empty content. + /// + private static byte[] CreateMinimalItem() + { + using var ms = new MemoryStream(); + using var writer = new BinaryWriter(ms); + + // Write NAME field (short) + writer.Write((byte)1); // length + writer.Write("A"u8.ToArray()); // ASCII name + + // Extract location = 0 + writer.Write((byte)0); + + // Flags = 0 + writer.Write((byte)0); + + // File size = 0 (Int64) + writer.Write((long)0); + + // Because file size = 0, Write no content + return ms.ToArray(); + } + + private static void WriteItem(Stream stream) + { + var data = CreateMinimalItem(); + stream.Write(data, 0, data.Length); + } + + + // ------------------------------------------------------------- + // CONSTRUCTION + // ------------------------------------------------------------- + + [TestMethod] + public void Constructor_ShouldWriteHeader_WhenCreating() + { + using var backing = CreateWritableStream(); + + using (var pkg = new Package(backing, PackageMode.Create)) + { + pkg.Close(); + } + + var data = backing.ToArray(); + + Assert.IsGreaterThanOrEqualTo(3, data.Length); + Assert.AreEqual("TP", Encoding.ASCII.GetString(data[..2])); + Assert.AreEqual(1, data[2]); // version marker + } + + [TestMethod] + public void Constructor_ShouldReadExisting_WhenOpening() + { + var bytes = BuildEmptyPackageFile(); + using var backing = new MemoryStream(bytes); + + using var pkg = new Package(backing, PackageMode.Open); + + Assert.IsEmpty(pkg.Items); + } + + [TestMethod] + public void Constructor_ShouldThrow_WhenInvalidHeader() + { + using var backing = new MemoryStream("XY"u8.ToArray()); + + Assert.ThrowsExactly(() => + new Package(backing, PackageMode.Open)); + } + + // ------------------------------------------------------------- + // MODE RESTRICTION + // ------------------------------------------------------------- + + [TestMethod] + public void AddItem_ShouldThrow_WhenNotInCreateMode() + { + using var backing = new MemoryStream(BuildEmptyPackageFile()); + using var pkg = new Package(backing, PackageMode.Open); + + Assert.ThrowsExactly(() => + { + // simulate write by raw call — not allowed in Open mode + pkg.AddItem(null); + }); + } + + [TestMethod] + public void Items_ShouldThrow_WhenNotInOpenMode() + { + using var backing = CreateWritableStream(); + using var pkg = new Package(backing, PackageMode.Create); + + Assert.ThrowsExactly(() => + { + var _ = pkg.Items; + }); + } + + // ------------------------------------------------------------- + // WRITE AND READ BACK + // ------------------------------------------------------------- + + [TestMethod] + public void WriteAndRead_ShouldReturnSameItems() + { + using var backing = CreateWritableStream(); + + // Write + using (var pkg = new Package(backing, PackageMode.Create)) + { + WriteItem(backing); + pkg.Close(); + } + + // Reopen + backing.Position = 0; + using var pkg2 = new Package(backing, PackageMode.Open); + + Assert.HasCount(1, pkg2.Items); + } + + [TestMethod] + public void Close_ShouldWriteEOF_Once() + { + using var backing = CreateWritableStream(); + using var pkg = new Package(backing, PackageMode.Create); + { + WriteItem(backing); + pkg.Close(); + var len1 = backing.Length; + + pkg.Close(); + var len2 = backing.Length; + + Assert.AreEqual(len1, len2); + } + } + + // ------------------------------------------------------------- + // STREAM OWNERSHIP + // ------------------------------------------------------------- + + [TestMethod] + public void Dispose_ShouldCloseOwnedStream() + { + var path = Path.GetTempFileName(); + + using (var pkg = new Package(path, PackageMode.Create)) + pkg.Close(); + + // Must be readable because Close wrote EOF and flushed stream + using var fs = new FileStream(path, FileMode.Open); + Assert.IsGreaterThanOrEqualTo(3, fs.Length); + + fs.Dispose(); + File.Delete(path); + } + + [TestMethod] + public void Dispose_ShouldNotCloseExternalStream() + { + using var backing = CreateWritableStream(); + using (var pkg = new Package(backing, PackageMode.Create, ownsStream: false)) + pkg.Close(); + + // external stream still usable + backing.WriteByte(255); + backing.Position = 0; + } + + // ------------------------------------------------------------- + // INVALID FORMATS + // ------------------------------------------------------------- + + [TestMethod] + public void ShouldThrow_WhenMissingVersion() + { + using var backing = new MemoryStream("TP"u8.ToArray()); + + Assert.ThrowsExactly(() => + new Package(backing, PackageMode.Open)); + } + + [TestMethod] + public void ShouldThrow_WhenUnsupportedVersion() + { + var bytes = "TP"u8.ToArray() + .Concat("*"u8.ToArray()) // bogus version + .Concat(new byte[] { 0 }) + .ToArray(); + + using var backing = new MemoryStream(bytes); + + Assert.ThrowsExactly(() => + new Package(backing, PackageMode.Open)); + } + } +} From 41e461368eaddd3dc7ccd84f1c744f4efb3432e1 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sun, 7 Dec 2025 11:41:01 +0200 Subject: [PATCH 29/74] Corrected OffsetStream creation order so parsed items reference the correct data segment, preserving data integrity during read-back. --- TechnitiumLibrary.IO/PackageItem.cs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/TechnitiumLibrary.IO/PackageItem.cs b/TechnitiumLibrary.IO/PackageItem.cs index 534698af..c721dbe4 100644 --- a/TechnitiumLibrary.IO/PackageItem.cs +++ b/TechnitiumLibrary.IO/PackageItem.cs @@ -154,9 +154,14 @@ public static PackageItem Parse(Stream s) item._extractToCustomLocation = Encoding.UTF8.GetString(bR.ReadBytes(bR.ReadByte())); long length = bR.ReadInt64(); - item._data = new OffsetStream(bR.BaseStream, bR.BaseStream.Position, length, true); - bR.BaseStream.Position += length; + long startOffset = bR.BaseStream.Position; + + // Create slice before advancing stream pointer + item._data = new OffsetStream(bR.BaseStream, startOffset, length, readOnly: true); + + // Seek explicitly + bR.BaseStream.Seek(length, SeekOrigin.Current); return item; From 119405df1114572b89024a23aa101738e9a94b54 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sun, 7 Dec 2025 11:41:53 +0200 Subject: [PATCH 30/74] Wrote tests for PackageItem class and modified PackageTests related to PackageItem changes --- .../TechnitiumLibrary.IO/PackageItemTests.cs | 219 ++++++++++++++++++ .../TechnitiumLibrary.IO/PackageTests.cs | 7 +- 2 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs new file mode 100644 index 00000000..afde3612 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs @@ -0,0 +1,219 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Linq; +using System.Text; +using TechnitiumLibrary.IO; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.IO +{ + [TestClass] + public sealed class PackageItemTests + { + private static MemoryStream StreamOf(params byte[] bytes) => + new MemoryStream(bytes, writable: true); + + private static PackageItem CreateMinimalWritable() + { + var ms = StreamOf(new byte[] { 1, 2, 3 }); + return new PackageItem("file.bin", ms); + } + + // --------------------------------------------------------- + // CONSTRUCTION + // --------------------------------------------------------- + + [TestMethod] + public void Constructor_ShouldCreateItemFromStream() + { + using var ms = StreamOf(10, 20, 30); + using var item = new PackageItem("abc.txt", ms); + + Assert.AreEqual("abc.txt", item.Name); + Assert.IsFalse(item.IsAttributeSet(PackageItemAttributes.ExecuteFile)); + Assert.AreEqual(ms, item.DataStream); + } + + [TestMethod] + public void Constructor_FromFilePath_ShouldCaptureAttributesAndOwnStream() + { + var path = Path.GetTempFileName(); + File.WriteAllBytes(path, new byte[] { 9, 8, 7 }); + File.SetLastWriteTimeUtc(path, new DateTime(2022, 5, 1, 12, 0, 0)); + + using var item = new PackageItem(path, PackageItemAttributes.ExecuteFile); + + Assert.AreEqual(Path.GetFileName(path), item.Name); + Assert.IsTrue(item.IsAttributeSet(PackageItemAttributes.ExecuteFile)); + Assert.IsGreaterThanOrEqualTo(3, item.DataStream.Length); + } + + // --------------------------------------------------------- + // WRITE FORMAT + RE-PARSE + // --------------------------------------------------------- + + private static PackageItem Roundtrip(PackageItem source) + { + var buffer = new MemoryStream(); // do NOT dispose here + source.WriteTo(buffer); + + buffer.Position = 0; + return PackageItem.Parse(buffer); + } + + [TestMethod] + public void WriteThenParse_ShouldReturnEquivalentName() + { + using var item = CreateMinimalWritable(); + using var parsed = Roundtrip(item); + + Assert.AreEqual(item.Name, parsed.Name); + } + + [TestMethod] + public void WriteThenParse_ShouldPreserveTimestamp() + { + var dt = new DateTime(2022, 10, 30, 11, 0, 0, DateTimeKind.Utc); + using var item = new PackageItem("f", dt, StreamOf(1, 2, 3)); + using var parsed = Roundtrip(item); + + Assert.AreEqual(dt, parsed.LastModifiedUTC); + } + + [TestMethod] + public void WriteThenParse_ShouldPreserveAttributes() + { + using var item = new PackageItem("a", DateTime.UtcNow, StreamOf(1), + attributes: PackageItemAttributes.FixedExtractLocation); + + using var parsed = Roundtrip(item); + + Assert.IsTrue(parsed.IsAttributeSet(PackageItemAttributes.FixedExtractLocation)); + } + + [TestMethod] + public void WriteThenParse_ShouldPreserveData() + { + using var item = CreateMinimalWritable(); + using var parsed = Roundtrip(item); + + using var reader = new BinaryReader(parsed.DataStream); + + var bytes = reader.ReadBytes(3); + + CollectionAssert.AreEqual(new byte[] { 1, 2, 3 }, bytes); + } + + // --------------------------------------------------------- + // CUSTOM EXTRACT LOCATION + // --------------------------------------------------------- + + [TestMethod] + public void WriteThenParse_WithCustomLocation_ShouldRoundtrip() + { + using var item = new PackageItem("x.txt", DateTime.UtcNow, + StreamOf(1, 2), + attributes: PackageItemAttributes.FixedExtractLocation, + extractTo: ExtractLocation.Custom, + extractToCustomLocation: "C:\\Temp"); + + using var parsed = Roundtrip(item); + + Assert.AreEqual("C:\\Temp", parsed.ExtractToCustomLocation); + } + + // --------------------------------------------------------- + // GET EXTRACTION PATH LOGIC + // --------------------------------------------------------- + + [TestMethod] + public void GetExtractionFilePath_ShouldRespectFixedAttribute() + { + using var item = new PackageItem("abc.dll", DateTime.UtcNow, + StreamOf(1), + attributes: PackageItemAttributes.FixedExtractLocation, + extractTo: ExtractLocation.System); + + var result = item.GetExtractionFilePath(ExtractLocation.Temp, null); + + // path must be under System, not requested Temp + var expectedRoot = Package.GetExtractLocation(ExtractLocation.System, null); + Assert.StartsWith(expectedRoot, result); + } + + [TestMethod] + public void GetExtractionFilePath_ShouldUseSuppliedLocation_WhenNotFixed() + { + using var item = new PackageItem("abc.dll", StreamOf(7)); + + var path = item.GetExtractionFilePath(ExtractLocation.Temp); + + Assert.StartsWith(Path.GetTempPath(), path); + } + + // --------------------------------------------------------- + // EXTRACTION TRANSACTION + // --------------------------------------------------------- + + [TestMethod] + public void Extract_ShouldBackupExisting_WhenOverwriteEnabled() + { + var target = Path.GetTempFileName(); + var originalBytes = "c"u8.ToArray(); + File.WriteAllBytes(target, originalBytes); + + using var item = CreateMinimalWritable(); + var log = item.Extract(target, overwrite: true); + + Assert.IsNotNull(log); + Assert.IsTrue(File.Exists(log.FilePath), "Target file should exist"); + Assert.IsTrue(File.Exists(log.OriginalFilePath), "Backup should remain available"); + + // Verify backup has original content + var backupBytes = File.ReadAllBytes(log.OriginalFilePath); + CollectionAssert.AreEqual(originalBytes, backupBytes); + + // Verify replaced content exists + var newBytes = File.ReadAllBytes(log.FilePath); + CollectionAssert.AreEqual(new byte[] { 1, 2, 3 }, newBytes); + } + + [TestMethod] + public void Extract_ShouldNotOverwrite_WhenFlagDisabled() + { + var target = Path.GetTempFileName(); + File.WriteAllBytes(target, "X"u8.ToArray()); + + using var item = CreateMinimalWritable(); + var log = item.Extract(target, overwrite: false); + + Assert.IsNull(log); + CollectionAssert.AreEqual("X"u8.ToArray(), File.ReadAllBytes(target)); + } + + // --------------------------------------------------------- + // PARSE ERROR SCENARIOS + // --------------------------------------------------------- + + [TestMethod] + public void Parse_ShouldThrow_WhenVersionIsUnsupported() + { + using var buffer = StreamOf("\t"u8.ToArray() /* invalid version */); + + Assert.ThrowsExactly(() => + { + var _ = PackageItem.Parse(buffer); + }); + } + + [TestMethod] + public void Parse_ShouldReturnNull_WhenEOFMarker() + { + using var buffer = StreamOf(0); + + var item = PackageItem.Parse(buffer); + + Assert.IsNull(item); + } + } +} diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageTests.cs index f8531749..3882acd2 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageTests.cs @@ -50,11 +50,14 @@ private static byte[] CreateMinimalItem() private static void WriteItem(Stream stream) { - var data = CreateMinimalItem(); - stream.Write(data, 0, data.Length); + using var data = new MemoryStream(); // empty payload + using var item = new PackageItem("A", data); + + item.WriteTo(stream); } + // ------------------------------------------------------------- // CONSTRUCTION // ------------------------------------------------------------- From b7cdbbfeebd3c44e8fa8c6ed841056ee59dd06e1 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sun, 7 Dec 2025 11:46:24 +0200 Subject: [PATCH 31/74] Added unit tests for Pipe class --- .../TechnitiumLibrary.IO/PipeTests.cs | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PipeTests.cs diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PipeTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PipeTests.cs new file mode 100644 index 00000000..e63544c6 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PipeTests.cs @@ -0,0 +1,175 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using TechnitiumLibrary.IO; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.IO +{ + [TestClass] + public sealed class PipeTests + { + private static Pipe CreatePipe() => new Pipe(); + + // ------------------------------------------------------------ + // CONSTRUCTION + // ------------------------------------------------------------ + + [TestMethod] + public void Constructor_ShouldExposeTwoConnectedStreams() + { + Pipe p = CreatePipe(); + + Assert.IsNotNull(p.Stream1); + Assert.IsNotNull(p.Stream2); + + Assert.IsTrue(p.Stream1.CanRead); + Assert.IsTrue(p.Stream1.CanWrite); + + Assert.IsTrue(p.Stream2.CanRead); + Assert.IsTrue(p.Stream2.CanWrite); + } + + // ------------------------------------------------------------ + // BASIC DATA TRANSFER + // ------------------------------------------------------------ + + [TestMethod] + public void WriteOnStream1_ShouldBeReadableFromStream2() + { + Pipe pipe = CreatePipe(); + byte[] data = new byte[] { 1, 2, 3 }; + + pipe.Stream1.Write(data, 0, data.Length); + + byte[] buffer = new byte[10]; + int read = pipe.Stream2.Read(buffer, 0, 10); + + Assert.AreEqual(3, read); + CollectionAssert.AreEqual(new byte[] { 1, 2, 3 }, buffer[..3]); + } + + [TestMethod] + public void Read_ShouldReturnZero_WhenOtherSideDisposed() + { + Pipe pipe = CreatePipe(); + + pipe.Stream1.Dispose(); + + byte[] buffer = new byte[5]; + int read = pipe.Stream2.Read(buffer, 0, 5); + + Assert.AreEqual(0, read); + } + + // ------------------------------------------------------------ + // SEEK PROHIBITIONS + // ------------------------------------------------------------ + + [TestMethod] + public void Position_ShouldThrowOnGet() + { + Pipe pipe = CreatePipe(); + Assert.ThrowsExactly(() => _ = pipe.Stream1.Position); + } + + [TestMethod] + public void Position_ShouldThrowOnSet() + { + Pipe pipe = CreatePipe(); + Assert.ThrowsExactly(() => pipe.Stream1.Position = 10); + } + + [TestMethod] + public void Seek_ShouldThrow() + { + Pipe pipe = CreatePipe(); + Assert.ThrowsExactly(() => pipe.Stream1.Seek(10, SeekOrigin.Begin)); + } + + [TestMethod] + public void Length_ShouldThrow() + { + Pipe pipe = CreatePipe(); + Assert.ThrowsExactly(() => _ = pipe.Stream1.Length); + } + + // ------------------------------------------------------------ + // BUFFER BOUNDARY BEHAVIOR + // ------------------------------------------------------------ + + [TestMethod] + public void Write_ShouldBlockWhenBufferFull_ThenResumeAfterRead() + { + Pipe pipe = CreatePipe(); + Stream stream1 = pipe.Stream1; + Stream stream2 = pipe.Stream2; + + stream1.WriteTimeout = 2000; + stream2.ReadTimeout = 2000; + + byte[] large = new byte[64 * 1024]; // exactly buffer size + + // Fill buffer completely + stream1.Write(large, 0, large.Length); + + // Now write again, but on another thread + using Task t = Task.Run(() => + { + // Should block until read + stream1.Write(new byte[] { 7 }, 0, 1); + }, TestContext.CancellationToken); + + // Give writer thread chance to block + Thread.Sleep(100); + + // Now read entire buffer + byte[] readBuffer = new byte[large.Length]; + int readTotal = stream2.Read(readBuffer, 0, large.Length); + + Assert.AreEqual(large.Length, readTotal); + + // Now writer should have completed + t.Wait(TestContext.CancellationToken); + } + + [TestMethod] + public void Write_ShouldFailWhenTimeoutExceeded() + { + Pipe pipe = CreatePipe(); + pipe.Stream1.WriteTimeout = 300; + + // fill buffer without draining + pipe.Stream1.Write(new byte[64 * 1024], 0, 64 * 1024); + + Assert.ThrowsExactly(() => pipe.Stream1.Write(new byte[] { 1 }, 0, 1)); + } + + [TestMethod] + public void Read_ShouldFailWhenTimeoutExceeded() + { + Pipe pipe = CreatePipe(); + pipe.Stream2.ReadTimeout = 200; + + byte[] buffer = new byte[1]; + + Assert.ThrowsExactly(() => pipe.Stream2.Read(buffer, 0, 1)); + } + + // ------------------------------------------------------------ + // DISPOSAL CASCADE + // ------------------------------------------------------------ + + [TestMethod] + public void Dispose_ShouldStopOtherSideFromDeliveringData() + { + Pipe pipe = CreatePipe(); + pipe.Stream1.Dispose(); + + Assert.ThrowsExactly(() => pipe.Stream1.Write(new byte[] { 1 }, 0, 1)); + } + + public TestContext TestContext { get; set; } + } +} From 180fb34304fd167f2b3d078faca617ed5748bc78 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sun, 7 Dec 2025 12:07:17 +0200 Subject: [PATCH 32/74] Tests fo StreamExtensions class --- .../StreamExtensionsTests.cs | 184 ++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 TechnitiumLibrary.Tests/TechnitiumLibrary.IO/StreamExtensionsTests.cs diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/StreamExtensionsTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/StreamExtensionsTests.cs new file mode 100644 index 00000000..259e7e3a --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/StreamExtensionsTests.cs @@ -0,0 +1,184 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Threading.Tasks; +using TechnitiumLibrary.IO; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.IO +{ + [TestClass] + public sealed class StreamExtensionsTests + { + private static MemoryStream StreamOf(params byte[] data) => + new MemoryStream(data, writable: true); + + // -------------------------------------------------------------------- + // ReadByteValue & WriteByteAsync + // -------------------------------------------------------------------- + + [TestMethod] + public void ReadByteValue_ShouldReturnFirstByte() + { + using var s = StreamOf(99); + Assert.AreEqual(99, s.ReadByteValue()); + } + + [TestMethod] + public void ReadByteValue_ShouldThrow_WhenEmpty() + { + using var s = StreamOf(); + Assert.ThrowsExactly(() => s.ReadByteValue()); + } + + [TestMethod] + public async Task WriteByteAsync_ShouldWriteByte() + { + using var s = new MemoryStream(); // expandable stream + + await s.WriteByteAsync(42); + + s.Position = 0; + + Assert.AreEqual(42, s.ReadByteValue()); + } + + // -------------------------------------------------------------------- + // ReadExactly + // -------------------------------------------------------------------- + + [TestMethod] + public void ReadExactly_ShouldReturnRequestedBytes() + { + using var s = StreamOf(1, 2, 3, 4); + var data = s.ReadExactly(3); + + CollectionAssert.AreEqual(new byte[] { 1, 2, 3 }, data); + } + + [TestMethod] + public void ReadExactly_ShouldThrow_WhenInsufficientData() + { + using var s = StreamOf(1, 2); + Assert.ThrowsExactly(() => s.ReadExactly(3)); + } + + [TestMethod] + public async Task ReadExactlyAsync_ShouldReturnRequestedBytes() + { + using var s = StreamOf(10, 20, 30); + var result = await s.ReadExactlyAsync(2); + + CollectionAssert.AreEqual(new byte[] { 10, 20 }, result); + } + + [TestMethod] + public async Task ReadExactlyAsync_ShouldThrow_WhenStreamEnds() + { + using var s = StreamOf(5); + await Assert.ThrowsExactlyAsync(() => s.ReadExactlyAsync(2)); + } + + // -------------------------------------------------------------------- + // Short string read/write + // -------------------------------------------------------------------- + + [TestMethod] + public void WriteShortString_ThenReadShortString_ShouldRoundtrip() + { + using var s = new MemoryStream(); // expandable stream + + s.WriteShortString("Hello"); + + s.Position = 0; + var str = s.ReadShortString(); + + Assert.AreEqual("Hello", str); + } + + [TestMethod] + public void WriteShortString_ShouldThrow_WhenLengthExceeds255() + { + string oversized = new string('A', 300); + + using var s = StreamOf(); + Assert.ThrowsExactly(() => s.WriteShortString(oversized)); + } + + [TestMethod] + public void ReadShortString_ShouldThrow_WhenLengthGreaterThanAvailableData() + { + using var s = StreamOf(2, 65); // length=2, only 1 byte remains + Assert.ThrowsExactly(() => s.ReadShortString()); + } + + [TestMethod] + public async Task WriteShortStringAsync_ShouldRoundtripWithUTF8() + { + using var s = new MemoryStream(); // expandable + + await s.WriteShortStringAsync("test✓"); + + s.Position = 0; + var parsed = await s.ReadShortStringAsync(); + + Assert.AreEqual("test✓", parsed); + } + + // -------------------------------------------------------------------- + // CopyTo & CopyToAsync + // -------------------------------------------------------------------- + + [TestMethod] + public void CopyTo_ShouldCopyExactBytes() + { + using var src = StreamOf(1, 2, 3, 4); + using var dst = new MemoryStream(); // must be expandable here + + src.CopyTo(dst, bufferSize: 3, length: 3); + + CollectionAssert.AreEqual(new byte[] { 1, 2, 3 }, dst.ToArray()); + } + + [TestMethod] + public void CopyTo_ShouldFailWhenEOSIsReachedPrematurely() + { + using var src = StreamOf(1, 2); + using var dst = new MemoryStream(); // must allow writing + + Assert.ThrowsExactly(() => + src.CopyTo(dst, bufferSize: 4, length: 3)); + } + + [TestMethod] + public async Task CopyToAsync_ShouldCopyExactBytes() + { + using var src = StreamOf(99, 98, 97); + using var dst = new MemoryStream(); // expandable destination + + await src.CopyToAsync(dst, bufferSize: 10, length: 3); + + CollectionAssert.AreEqual(new byte[] { 99, 98, 97 }, dst.ToArray()); + } + + [TestMethod] + public async Task CopyToAsync_ShouldFailWhenEOSReachedPrematurely() + { + using var src = StreamOf(9); + using var dst = new MemoryStream(); // expandable + + await Assert.ThrowsExactlyAsync(async () => + await src.CopyToAsync(dst, bufferSize: 8, length: 2)); + } + + [TestMethod] + public void CopyTo_ShouldReturnImmediately_WhenLengthIsZero() + { + using var src = StreamOf(1, 2, 3); + using var dst = StreamOf(); + + src.CopyTo(dst, bufferSize: 5, length: 0); + + Assert.IsEmpty(dst.ToArray()); + } + } +} From 2c8af035d4690a170362b0ae040a3a9ffe18a64a Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sun, 7 Dec 2025 12:41:49 +0200 Subject: [PATCH 33/74] Tests for WriteBufferedStream class --- .../WriteBufferedStreamTests.cs | 279 ++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 TechnitiumLibrary.Tests/TechnitiumLibrary.IO/WriteBufferedStreamTests.cs diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/WriteBufferedStreamTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/WriteBufferedStreamTests.cs new file mode 100644 index 00000000..c244fe45 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/WriteBufferedStreamTests.cs @@ -0,0 +1,279 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using TechnitiumLibrary.IO; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.IO +{ + [TestClass] + public sealed class WriteBufferedStreamTests + { + private sealed class NonWritableStream : MemoryStream + { + public override bool CanWrite => false; + } + + private static MemoryStream CreateBaseStream(byte[]? initial = null) => + initial is null ? new MemoryStream() : new MemoryStream(initial); + + // ------------------------------------------------------ + // CONSTRUCTION / CAPABILITIES + // ------------------------------------------------------ + + [TestMethod] + public void Constructor_ShouldThrow_WhenBaseStreamNotWritable() + { + // GIVEN + using var baseStream = new NonWritableStream(); + + // WHEN-THEN + Assert.ThrowsExactly( + () => new WriteBufferedStream(baseStream)); + } + + [TestMethod] + public void Constructor_ShouldExposeCapabilitiesFromBaseStream() + { + // GIVEN + using var baseStream = CreateBaseStream(); + + // WHEN + using var buffered = new WriteBufferedStream(baseStream); + + // THEN + Assert.IsTrue(buffered.CanWrite); + Assert.AreEqual(baseStream.CanRead, buffered.CanRead); + Assert.AreEqual(baseStream.CanTimeout, buffered.CanTimeout); + Assert.IsFalse(buffered.CanSeek); + } + + // ------------------------------------------------------ + // BASIC WRITE & FLUSH (SYNC) + // ------------------------------------------------------ + + [TestMethod] + public void Write_ShouldBufferUntilFlushed() + { + // GIVEN + using var baseStream = CreateBaseStream(); + using var buffered = new WriteBufferedStream(baseStream, bufferSize: 8); + + var data = Encoding.ASCII.GetBytes("ABCD"); // 4 bytes + + // WHEN + buffered.Write(data, 0, data.Length); + + // THEN – nothing written yet to base + CollectionAssert.AreEqual(Array.Empty(), baseStream.ToArray()); + Assert.AreEqual(0L, baseStream.Length); + + // WHEN + buffered.Flush(); + + // THEN – data should now exist in base stream + CollectionAssert.AreEqual(data, baseStream.ToArray()); + } + + [TestMethod] + public void Write_ShouldFlushBufferWhenFull_AndKeepRemainderBuffered() + { + // GIVEN + using var baseStream = CreateBaseStream(); + using var buffered = new WriteBufferedStream(baseStream, bufferSize: 4); + + // 6 bytes, buffer 4 -> first 4 flushed, last 2 remain buffered after Flush + var data = Encoding.ASCII.GetBytes("ABCDEF"); + + // WHEN + buffered.Write(data, 0, data.Length); + + // buffer is full internally twice, so Flush() is invoked from Write + // After Write completes, we call Flush() to ensure remainder is written. + buffered.Flush(); + + // THEN + CollectionAssert.AreEqual(data, baseStream.ToArray()); + } + + // ------------------------------------------------------ + // BASIC WRITE & FLUSH (ASYNC) + // ------------------------------------------------------ + + [TestMethod] + public async Task WriteAsync_ShouldBufferAndFlushAsync() + { + // GIVEN + using var baseStream = CreateBaseStream(); + using var buffered = new WriteBufferedStream(baseStream, bufferSize: 8); + + var data = Encoding.UTF8.GetBytes("123456"); + + // WHEN + await buffered.WriteAsync(data, 0, data.Length, CancellationToken.None); + + // Still buffered + CollectionAssert.AreEqual(Array.Empty(), baseStream.ToArray()); + + await buffered.FlushAsync(CancellationToken.None); + + // THEN + CollectionAssert.AreEqual(data, baseStream.ToArray()); + } + + [TestMethod] + public async Task WriteAsync_MemoryOverload_ShouldRespectBuffering() + { + // GIVEN + using var baseStream = CreateBaseStream(); + using var buffered = new WriteBufferedStream(baseStream, bufferSize: 4); + + var data = Encoding.ASCII.GetBytes("WXYZ12"); // 6 bytes + + // WHEN + await buffered.WriteAsync(data.AsMemory(), CancellationToken.None); + await buffered.FlushAsync(CancellationToken.None); + + // THEN + CollectionAssert.AreEqual(data, baseStream.ToArray()); + } + + // ------------------------------------------------------ + // READ DELEGATION + // ------------------------------------------------------ + + [TestMethod] + public void Read_ShouldDelegateToBaseStream() + { + // GIVEN + var initial = Encoding.ASCII.GetBytes("HELLO"); + using var baseStream = CreateBaseStream(initial); + using var buffered = new WriteBufferedStream(baseStream); + + // WHEN + var buffer = new byte[5]; + baseStream.Position = 0; // ensure we read from start + var read = buffered.Read(buffer, 0, buffer.Length); + + // THEN + Assert.AreEqual(5, read); + CollectionAssert.AreEqual(initial, buffer); + } + + // ------------------------------------------------------ + // SEEK / LENGTH / POSITION BEHAVIOR + // ------------------------------------------------------ + + [TestMethod] + public void Position_Get_ShouldMatchBaseStreamPosition() + { + // GIVEN + using var baseStream = CreateBaseStream(new byte[10]); + baseStream.Position = 4; + using var buffered = new WriteBufferedStream(baseStream); + + // WHEN + var position = buffered.Position; + + // THEN + Assert.AreEqual(4L, position); + } + + [TestMethod] + public void Position_Set_ShouldThrow_NotSupported() + { + // GIVEN + using var baseStream = CreateBaseStream(); + using var buffered = new WriteBufferedStream(baseStream); + + // WHEN-THEN + Assert.ThrowsExactly(() => + buffered.Position = 1); + } + + [TestMethod] + public void Seek_ShouldThrow_NotSupported() + { + // GIVEN + using var baseStream = CreateBaseStream(); + using var buffered = new WriteBufferedStream(baseStream); + + // WHEN-THEN + Assert.ThrowsExactly(() => + buffered.Seek(0, SeekOrigin.Begin)); + } + + [TestMethod] + public void SetLength_ShouldThrow_NotSupported() + { + // GIVEN + using var baseStream = CreateBaseStream(); + using var buffered = new WriteBufferedStream(baseStream); + + // WHEN-THEN + Assert.ThrowsExactly(() => + buffered.SetLength(10)); + } + + // ------------------------------------------------------ + // DISPOSAL & OWNERSHIP + // ------------------------------------------------------ + + [TestMethod] + public void Dispose_ShouldDisposeUnderlyingStream() + { + // GIVEN + var baseStream = CreateBaseStream(); + var buffered = new WriteBufferedStream(baseStream); + + // WHEN + buffered.Dispose(); + + // THEN – base stream also disposed + Assert.ThrowsExactly(() => + baseStream.WriteByte(1)); + } + + [TestMethod] + public void Write_ShouldThrow_WhenDisposed() + { + // GIVEN + using var baseStream = CreateBaseStream(); + var buffered = new WriteBufferedStream(baseStream); + buffered.Dispose(); + + // WHEN-THEN + Assert.ThrowsExactly(() => + buffered.Write(new byte[] { 1 }, 0, 1)); + } + + [TestMethod] + public async Task WriteAsync_ShouldThrow_WhenDisposed() + { + // GIVEN + using var baseStream = CreateBaseStream(); + var buffered = new WriteBufferedStream(baseStream); + buffered.Dispose(); + + // WHEN-THEN + await Assert.ThrowsExactlyAsync(() => + buffered.WriteAsync(new byte[] { 1 }, 0, 1, CancellationToken.None)); + } + + [TestMethod] + public async Task FlushAsync_ShouldNotFlush_WhenNothingBuffered() + { + // GIVEN + using var baseStream = CreateBaseStream(); + using var buffered = new WriteBufferedStream(baseStream); + + // WHEN + await buffered.FlushAsync(CancellationToken.None); + + // THEN – nothing written + CollectionAssert.AreEqual(Array.Empty(), baseStream.ToArray()); + } + } +} From d77f674fa5c575284cd4a888a02682107738bf4b Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sun, 7 Dec 2025 13:04:54 +0200 Subject: [PATCH 34/74] Hardened Constructor_FromFilePath_ShouldCaptureAttributesAndOwnStream --- .../TechnitiumLibrary.IO/PackageItemTests.cs | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs index afde3612..c2503932 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs @@ -37,15 +37,35 @@ public void Constructor_ShouldCreateItemFromStream() [TestMethod] public void Constructor_FromFilePath_ShouldCaptureAttributesAndOwnStream() { - var path = Path.GetTempFileName(); - File.WriteAllBytes(path, new byte[] { 9, 8, 7 }); + string tempDir = Path.GetTempPath(); + string path = Path.Combine(tempDir, Path.GetRandomFileName()); + + // Create securely as new, exclusive, single-writer + using (var file = new FileStream( + path, + FileMode.CreateNew, + FileAccess.ReadWrite, + FileShare.None)) + { + file.Write(new byte[] { 9, 8, 7 }); + } + File.SetLastWriteTimeUtc(path, new DateTime(2022, 5, 1, 12, 0, 0)); - using var item = new PackageItem(path, PackageItemAttributes.ExecuteFile); + try + { + using var item = new PackageItem(path, PackageItemAttributes.ExecuteFile); - Assert.AreEqual(Path.GetFileName(path), item.Name); - Assert.IsTrue(item.IsAttributeSet(PackageItemAttributes.ExecuteFile)); - Assert.IsGreaterThanOrEqualTo(3, item.DataStream.Length); + Assert.AreEqual(Path.GetFileName(path), item.Name); + Assert.IsTrue(item.IsAttributeSet(PackageItemAttributes.ExecuteFile)); + Assert.IsGreaterThanOrEqualTo(3, item.DataStream.Length); + } + finally + { + // Ensure no artifact leaks into developer machine or pipeline + if (File.Exists(path)) + File.Delete(path); + } } // --------------------------------------------------------- From a815c480ad4356ca9cdef1cad1feaece92c1d56f Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sun, 7 Dec 2025 13:08:10 +0200 Subject: [PATCH 35/74] Hardened Extract_ShouldBackupExisting_WhenOverwriteEnabled --- .../TechnitiumLibrary.IO/PackageItemTests.cs | 51 ++++++++++++++----- 1 file changed, 38 insertions(+), 13 deletions(-) diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs index c2503932..5cc798fc 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs @@ -178,24 +178,49 @@ public void GetExtractionFilePath_ShouldUseSuppliedLocation_WhenNotFixed() [TestMethod] public void Extract_ShouldBackupExisting_WhenOverwriteEnabled() { - var target = Path.GetTempFileName(); + string tempDir = Path.GetTempPath(); + string target = Path.Combine(tempDir, Path.GetRandomFileName()); + string? originalBackupCandidate = null; + var originalBytes = "c"u8.ToArray(); - File.WriteAllBytes(target, originalBytes); - using var item = CreateMinimalWritable(); - var log = item.Extract(target, overwrite: true); + // Create target file securely and atomically + using (var fs = new FileStream( + target, + FileMode.CreateNew, + FileAccess.ReadWrite, + FileShare.None)) + { + fs.Write(originalBytes, 0, originalBytes.Length); + } - Assert.IsNotNull(log); - Assert.IsTrue(File.Exists(log.FilePath), "Target file should exist"); - Assert.IsTrue(File.Exists(log.OriginalFilePath), "Backup should remain available"); + try + { + using var item = CreateMinimalWritable(); + var log = item.Extract(target, overwrite: true); + + Assert.IsNotNull(log, "Extract must return log instance when overwriting"); + Assert.IsTrue(File.Exists(log.FilePath), "Target file should exist after overwrite"); + Assert.IsTrue(File.Exists(log.OriginalFilePath), "Original backup should remain available"); + + // Validate backup content + var backupBytes = File.ReadAllBytes(log.OriginalFilePath); + CollectionAssert.AreEqual(originalBytes, backupBytes); - // Verify backup has original content - var backupBytes = File.ReadAllBytes(log.OriginalFilePath); - CollectionAssert.AreEqual(originalBytes, backupBytes); + // Validate replaced data + var newBytes = File.ReadAllBytes(log.FilePath); + CollectionAssert.AreEqual(new byte[] { 1, 2, 3 }, newBytes); + } + finally + { + // Always clean up + if (File.Exists(target)) + File.Delete(target); - // Verify replaced content exists - var newBytes = File.ReadAllBytes(log.FilePath); - CollectionAssert.AreEqual(new byte[] { 1, 2, 3 }, newBytes); + // Delete backup if test created one + if (originalBackupCandidate != null && File.Exists(originalBackupCandidate)) + File.Delete(originalBackupCandidate); + } } [TestMethod] From 5af460b62d2c93b81bdc424f40e0c5ac9166395d Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sun, 7 Dec 2025 13:18:09 +0200 Subject: [PATCH 36/74] Hardened Extract_ShouldNotOverwrite_WhenFlagDisabled --- .../TechnitiumLibrary.IO/PackageItemTests.cs | 32 +++++++++++++++---- 1 file changed, 26 insertions(+), 6 deletions(-) diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs index 5cc798fc..9562327a 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs @@ -226,14 +226,34 @@ public void Extract_ShouldBackupExisting_WhenOverwriteEnabled() [TestMethod] public void Extract_ShouldNotOverwrite_WhenFlagDisabled() { - var target = Path.GetTempFileName(); - File.WriteAllBytes(target, "X"u8.ToArray()); + string tempDir = Path.GetTempPath(); + string target = Path.Combine(tempDir, Path.GetRandomFileName()); + var originalBytes = "X"u8.ToArray(); - using var item = CreateMinimalWritable(); - var log = item.Extract(target, overwrite: false); + // Create file securely + using (var fs = new FileStream( + target, + FileMode.CreateNew, + FileAccess.ReadWrite, + FileShare.None)) + { + fs.Write(originalBytes, 0, originalBytes.Length); + } - Assert.IsNull(log); - CollectionAssert.AreEqual("X"u8.ToArray(), File.ReadAllBytes(target)); + try + { + using var item = CreateMinimalWritable(); + var log = item.Extract(target, overwrite: false); + + Assert.IsNull(log, "Extract must return null when overwrite=false"); + CollectionAssert.AreEqual(originalBytes, File.ReadAllBytes(target)); + } + finally + { + // cleanup + if (File.Exists(target)) + File.Delete(target); + } } // --------------------------------------------------------- From 9936b3001906c977b4c2ef2359255a0d950aa80a Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sun, 7 Dec 2025 13:18:23 +0200 Subject: [PATCH 37/74] Hardened Dispose_ShouldCloseOwnedStream --- .../TechnitiumLibrary.IO/PackageTests.cs | 33 ++++++++++++++----- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageTests.cs index 3882acd2..8648adb0 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageTests.cs @@ -175,17 +175,32 @@ public void Close_ShouldWriteEOF_Once() [TestMethod] public void Dispose_ShouldCloseOwnedStream() { - var path = Path.GetTempFileName(); - - using (var pkg = new Package(path, PackageMode.Create)) - pkg.Close(); + // secure temp file creation + string tempFile = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + + // create file exclusively before passing to Package + using (var fs = new FileStream( + tempFile, + FileMode.CreateNew, // guarantees file does not exist + FileAccess.ReadWrite, + FileShare.None)) // no external access allowed + { + fs.WriteByte(99); // write something so the file exists + } - // Must be readable because Close wrote EOF and flushed stream - using var fs = new FileStream(path, FileMode.Open); - Assert.IsGreaterThanOrEqualTo(3, fs.Length); + try + { + using (var pkg = new Package(tempFile, PackageMode.Create)) + pkg.Close(); // Close → flush EOF marker → close underlying stream - fs.Dispose(); - File.Delete(path); + using var fs = new FileStream(tempFile, FileMode.Open, FileAccess.Read); + Assert.IsGreaterThanOrEqualTo(3, fs.Length); + } + finally + { + if (File.Exists(tempFile)) + File.Delete(tempFile); + } } [TestMethod] From 6dcb991775784c5729648ace387d673c127f4ba3 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sun, 7 Dec 2025 13:28:16 +0200 Subject: [PATCH 38/74] Hardened Constructor_FromFilePath_ShouldCaptureAttributesAndOwnStream --- .../TechnitiumLibrary.IO/PackageItemTests.cs | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs index 9562327a..bf61d3e5 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs @@ -37,10 +37,19 @@ public void Constructor_ShouldCreateItemFromStream() [TestMethod] public void Constructor_FromFilePath_ShouldCaptureAttributesAndOwnStream() { - string tempDir = Path.GetTempPath(); - string path = Path.Combine(tempDir, Path.GetRandomFileName()); + // Create an isolated private subfolder under temp, + // because direct writes to global temp root are unsafe. + string secureTempRoot = Path.Combine( + Path.GetTempPath(), + "pkgtest_" + Guid.NewGuid().ToString("N")); + + Directory.CreateDirectory(secureTempRoot); + + string path = Path.Combine( + secureTempRoot, + Path.GetRandomFileName()); - // Create securely as new, exclusive, single-writer + // Create securely using exclusive, non-shareable access using (var file = new FileStream( path, FileMode.CreateNew, @@ -50,7 +59,9 @@ public void Constructor_FromFilePath_ShouldCaptureAttributesAndOwnStream() file.Write(new byte[] { 9, 8, 7 }); } - File.SetLastWriteTimeUtc(path, new DateTime(2022, 5, 1, 12, 0, 0)); + File.SetLastWriteTimeUtc( + path, + new DateTime(2022, 5, 1, 12, 0, 0)); try { @@ -62,12 +73,16 @@ public void Constructor_FromFilePath_ShouldCaptureAttributesAndOwnStream() } finally { - // Ensure no artifact leaks into developer machine or pipeline + // Secure cleanup: remove file then folder if (File.Exists(path)) File.Delete(path); + + if (Directory.Exists(secureTempRoot)) + Directory.Delete(secureTempRoot, recursive: true); } } + // --------------------------------------------------------- // WRITE FORMAT + RE-PARSE // --------------------------------------------------------- From 531515c3637937382f5e0e198e4ec9e347146a7e Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sun, 7 Dec 2025 13:33:14 +0200 Subject: [PATCH 39/74] Hardened WriteThenParse_WithCustomLocation_ShouldRoundtrip --- .../TechnitiumLibrary.IO/PackageItemTests.cs | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs index bf61d3e5..114ae199 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs @@ -146,15 +146,32 @@ public void WriteThenParse_ShouldPreserveData() [TestMethod] public void WriteThenParse_WithCustomLocation_ShouldRoundtrip() { - using var item = new PackageItem("x.txt", DateTime.UtcNow, - StreamOf(1, 2), - attributes: PackageItemAttributes.FixedExtractLocation, - extractTo: ExtractLocation.Custom, - extractToCustomLocation: "C:\\Temp"); + // Create a private temp subfolder so location is not globally predictable + string secureTempRoot = Path.Combine( + Path.GetTempPath(), + "pkgtest_" + Guid.NewGuid().ToString("N")); - using var parsed = Roundtrip(item); + Directory.CreateDirectory(secureTempRoot); + + try + { + using var item = new PackageItem( + "x.txt", + DateTime.UtcNow, + StreamOf(1, 2), + attributes: PackageItemAttributes.FixedExtractLocation, + extractTo: ExtractLocation.Custom, + extractToCustomLocation: secureTempRoot); + + using var parsed = Roundtrip(item); - Assert.AreEqual("C:\\Temp", parsed.ExtractToCustomLocation); + Assert.AreEqual(secureTempRoot, parsed.ExtractToCustomLocation); + } + finally + { + if (Directory.Exists(secureTempRoot)) + Directory.Delete(secureTempRoot, recursive: true); + } } // --------------------------------------------------------- From e59b5a46098e7992f75ed4a89b3acf8b5ec6306b Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sun, 7 Dec 2025 13:35:42 +0200 Subject: [PATCH 40/74] Minor cleanup --- .../TechnitiumLibrary.IO/PackageItemTests.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs index 114ae199..b9db678e 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs @@ -1,8 +1,6 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using System.IO; -using System.Linq; -using System.Text; using TechnitiumLibrary.IO; namespace TechnitiumLibrary.Tests.TechnitiumLibrary.IO From 8d0050c814071cb2b9e78f879d753fd7554383c6 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sun, 7 Dec 2025 13:41:12 +0200 Subject: [PATCH 41/74] Minor cleanup --- .../TechnitiumLibrary.IO/PackageItemTests.cs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs index b9db678e..ad1a8dc3 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs @@ -208,8 +208,7 @@ public void GetExtractionFilePath_ShouldUseSuppliedLocation_WhenNotFixed() [TestMethod] public void Extract_ShouldBackupExisting_WhenOverwriteEnabled() { - string tempDir = Path.GetTempPath(); - string target = Path.Combine(tempDir, Path.GetRandomFileName()); + string target = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); string? originalBackupCandidate = null; var originalBytes = "c"u8.ToArray(); @@ -256,8 +255,7 @@ public void Extract_ShouldBackupExisting_WhenOverwriteEnabled() [TestMethod] public void Extract_ShouldNotOverwrite_WhenFlagDisabled() { - string tempDir = Path.GetTempPath(); - string target = Path.Combine(tempDir, Path.GetRandomFileName()); + string target = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); var originalBytes = "X"u8.ToArray(); // Create file securely From 824ecddc3dfede538944f73b871a4e18c9b005b2 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sun, 7 Dec 2025 13:49:34 +0200 Subject: [PATCH 42/74] Improved reliability on Extract_ShouldBackupExisting_WhenOverwriteEnabled --- .../TechnitiumLibrary.IO/PackageItemTests.cs | 35 ++++++++++--------- 1 file changed, 19 insertions(+), 16 deletions(-) diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs index ad1a8dc3..27f9040f 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs @@ -209,11 +209,9 @@ public void GetExtractionFilePath_ShouldUseSuppliedLocation_WhenNotFixed() public void Extract_ShouldBackupExisting_WhenOverwriteEnabled() { string target = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); - string? originalBackupCandidate = null; - var originalBytes = "c"u8.ToArray(); - // Create target file securely and atomically + // Securely create target using (var fs = new FileStream( target, FileMode.CreateNew, @@ -223,32 +221,37 @@ public void Extract_ShouldBackupExisting_WhenOverwriteEnabled() fs.Write(originalBytes, 0, originalBytes.Length); } + string? backupPath = null; + try { using var item = CreateMinimalWritable(); var log = item.Extract(target, overwrite: true); - Assert.IsNotNull(log, "Extract must return log instance when overwriting"); - Assert.IsTrue(File.Exists(log.FilePath), "Target file should exist after overwrite"); - Assert.IsTrue(File.Exists(log.OriginalFilePath), "Original backup should remain available"); + Assert.IsNotNull(log); + Assert.IsTrue(File.Exists(log.FilePath)); + + // Track for cleanup + backupPath = log.OriginalFilePath; + + Assert.IsTrue(File.Exists(backupPath), "Backup should exist"); - // Validate backup content - var backupBytes = File.ReadAllBytes(log.OriginalFilePath); - CollectionAssert.AreEqual(originalBytes, backupBytes); + CollectionAssert.AreEqual( + originalBytes, + File.ReadAllBytes(backupPath)); - // Validate replaced data - var newBytes = File.ReadAllBytes(log.FilePath); - CollectionAssert.AreEqual(new byte[] { 1, 2, 3 }, newBytes); + CollectionAssert.AreEqual( + new byte[] { 1, 2, 3 }, + File.ReadAllBytes(target)); } finally { - // Always clean up if (File.Exists(target)) File.Delete(target); - // Delete backup if test created one - if (originalBackupCandidate != null && File.Exists(originalBackupCandidate)) - File.Delete(originalBackupCandidate); + // Now valid (conditional reachability eliminated) + if (!string.IsNullOrWhiteSpace(backupPath) && File.Exists(backupPath)) + File.Delete(backupPath); } } From 67972658f53f41989d59d0ee2722ab51b577ac50 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sun, 7 Dec 2025 13:53:43 +0200 Subject: [PATCH 43/74] Improved async handling --- .../StreamExtensionsTests.cs | 40 ++++++++++--------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/StreamExtensionsTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/StreamExtensionsTests.cs index 259e7e3a..d61fc472 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/StreamExtensionsTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/StreamExtensionsTests.cs @@ -19,7 +19,7 @@ private static MemoryStream StreamOf(params byte[] data) => [TestMethod] public void ReadByteValue_ShouldReturnFirstByte() { - using var s = StreamOf(99); + using var s = StreamOf("c"u8.ToArray()); Assert.AreEqual(99, s.ReadByteValue()); } @@ -33,13 +33,15 @@ public void ReadByteValue_ShouldThrow_WhenEmpty() [TestMethod] public async Task WriteByteAsync_ShouldWriteByte() { - using var s = new MemoryStream(); // expandable stream + await using var s = new MemoryStream(); // expandable stream - await s.WriteByteAsync(42); + await s.WriteByteAsync(42, TestContext.CancellationToken); s.Position = 0; - Assert.AreEqual(42, s.ReadByteValue()); + var value = await s.ReadByteValueAsync(TestContext.CancellationToken); + + Assert.AreEqual(42, value); } // -------------------------------------------------------------------- @@ -65,8 +67,8 @@ public void ReadExactly_ShouldThrow_WhenInsufficientData() [TestMethod] public async Task ReadExactlyAsync_ShouldReturnRequestedBytes() { - using var s = StreamOf(10, 20, 30); - var result = await s.ReadExactlyAsync(2); + await using var s = StreamOf(10, 20, 30); + var result = await s.ReadExactlyAsync(2, TestContext.CancellationToken); CollectionAssert.AreEqual(new byte[] { 10, 20 }, result); } @@ -74,8 +76,8 @@ public async Task ReadExactlyAsync_ShouldReturnRequestedBytes() [TestMethod] public async Task ReadExactlyAsync_ShouldThrow_WhenStreamEnds() { - using var s = StreamOf(5); - await Assert.ThrowsExactlyAsync(() => s.ReadExactlyAsync(2)); + await using var s = StreamOf(5); + await Assert.ThrowsExactlyAsync(() => s.ReadExactlyAsync(2, TestContext.CancellationToken)); } // -------------------------------------------------------------------- @@ -114,12 +116,12 @@ public void ReadShortString_ShouldThrow_WhenLengthGreaterThanAvailableData() [TestMethod] public async Task WriteShortStringAsync_ShouldRoundtripWithUTF8() { - using var s = new MemoryStream(); // expandable + await using var s = new MemoryStream(); // expandable - await s.WriteShortStringAsync("test✓"); + await s.WriteShortStringAsync("test✓", TestContext.CancellationToken); s.Position = 0; - var parsed = await s.ReadShortStringAsync(); + var parsed = await s.ReadShortStringAsync(TestContext.CancellationToken); Assert.AreEqual("test✓", parsed); } @@ -152,22 +154,22 @@ public void CopyTo_ShouldFailWhenEOSIsReachedPrematurely() [TestMethod] public async Task CopyToAsync_ShouldCopyExactBytes() { - using var src = StreamOf(99, 98, 97); - using var dst = new MemoryStream(); // expandable destination + await using var src = StreamOf("cba"u8.ToArray()); + await using var dst = new MemoryStream(); // expandable destination - await src.CopyToAsync(dst, bufferSize: 10, length: 3); + await src.CopyToAsync(dst, bufferSize: 10, length: 3, TestContext.CancellationToken); - CollectionAssert.AreEqual(new byte[] { 99, 98, 97 }, dst.ToArray()); + CollectionAssert.AreEqual("cba"u8.ToArray(), dst.ToArray()); } [TestMethod] public async Task CopyToAsync_ShouldFailWhenEOSReachedPrematurely() { - using var src = StreamOf(9); - using var dst = new MemoryStream(); // expandable + await using var src = StreamOf("\t"u8.ToArray()); + await using var dst = new MemoryStream(); // expandable await Assert.ThrowsExactlyAsync(async () => - await src.CopyToAsync(dst, bufferSize: 8, length: 2)); + await src.CopyToAsync(dst, bufferSize: 8, length: 2, TestContext.CancellationToken)); } [TestMethod] @@ -180,5 +182,7 @@ public void CopyTo_ShouldReturnImmediately_WhenLengthIsZero() Assert.IsEmpty(dst.ToArray()); } + + public TestContext TestContext { get; set; } } } From cad551b0e3b4d0ec9c3edad8a5131bfa0ca3f0c1 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sun, 7 Dec 2025 21:25:30 +0200 Subject: [PATCH 44/74] Cleanup --- .../TechnitiumLibrary.IO/BinaryWriterExtensionsTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/BinaryWriterExtensionsTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/BinaryWriterExtensionsTests.cs index d7836b7a..7551a78e 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/BinaryWriterExtensionsTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/BinaryWriterExtensionsTests.cs @@ -141,7 +141,7 @@ public void WriteShortString_ShouldUseSpecifiedEncoding() public void WriteShortString_ShouldThrow_WhenStringTooLong() { // GIVEN - var (bw, ms) = CreateWriter(); + var (bw, _) = CreateWriter(); var input = new string('x', 256); // UTF-8 => 256 bytes // WHEN–THEN From c9e72ebe92c7f858466eb3b9c973db00f445dd61 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sun, 7 Dec 2025 21:31:33 +0200 Subject: [PATCH 45/74] Use explicit disposal in test to ensure disposal event is raised at the correct moment --- TechnitiumLibrary.Tests/TechnitiumLibrary.IO/JointTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/JointTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/JointTests.cs index 73c8e25b..b2d79174 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/JointTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/JointTests.cs @@ -147,7 +147,7 @@ public void Dispose_ShouldRaiseDisposingEvent() // GIVEN using var s1 = new MemoryStream(); using var s2 = new MemoryStream(); - using var joint = new Joint(s1, s2); + var joint = new Joint(s1, s2); bool raised = false; joint.Disposing += (_, __) => raised = true; From 79eb645164b4a0041d36c70b3fe4231b00ca61f4 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sun, 7 Dec 2025 21:33:53 +0200 Subject: [PATCH 46/74] Simplified StreamOf --- .../TechnitiumLibrary.IO/PackageItemTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs index 27f9040f..174d3eff 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs @@ -13,7 +13,7 @@ private static MemoryStream StreamOf(params byte[] bytes) => private static PackageItem CreateMinimalWritable() { - var ms = StreamOf(new byte[] { 1, 2, 3 }); + var ms = StreamOf(1, 2, 3); return new PackageItem("file.bin", ms); } From a38ba53ab1f6d8c44e8ff27c88ba7ecc2b249d6f Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sun, 7 Dec 2025 21:35:55 +0200 Subject: [PATCH 47/74] Added DatetimeKind --- .../TechnitiumLibrary.IO/PackageItemTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs index 174d3eff..833f95b0 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageItemTests.cs @@ -59,7 +59,7 @@ public void Constructor_FromFilePath_ShouldCaptureAttributesAndOwnStream() File.SetLastWriteTimeUtc( path, - new DateTime(2022, 5, 1, 12, 0, 0)); + new DateTime(2022, 5, 1, 12, 0, 0, DateTimeKind.Utc)); try { From 9203414c3497059b25d98df0d437692dad0d5c7a Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sun, 7 Dec 2025 21:38:57 +0200 Subject: [PATCH 48/74] Formatting --- .../TechnitiumLibrary.IO/PackageTests.cs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageTests.cs index 8648adb0..ffdca929 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/PackageTests.cs @@ -156,16 +156,12 @@ public void Close_ShouldWriteEOF_Once() { using var backing = CreateWritableStream(); using var pkg = new Package(backing, PackageMode.Create); - { - WriteItem(backing); - pkg.Close(); - var len1 = backing.Length; - - pkg.Close(); - var len2 = backing.Length; - - Assert.AreEqual(len1, len2); - } + WriteItem(backing); + pkg.Close(); + var len1 = backing.Length; + pkg.Close(); + var len2 = backing.Length; + Assert.AreEqual(len1, len2); } // ------------------------------------------------------------- From 843cc9be81c342eb32b04682d563109bc18fd969 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sun, 7 Dec 2025 21:39:43 +0200 Subject: [PATCH 49/74] Marked private class sealed --- TechnitiumLibrary.Tests/TechnitiumLibrary/BinaryNumberTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary/BinaryNumberTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary/BinaryNumberTests.cs index 59ccaf02..2b9a352d 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary/BinaryNumberTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary/BinaryNumberTests.cs @@ -388,7 +388,7 @@ public void WriteTo_ShouldWritePrefixAndBytes() // Supporting Test Doubles // --------------------------------------------------------------------- - private class UnreadableStream : Stream + private sealed class UnreadableStream : Stream { public override bool CanRead => false; public override bool CanSeek => false; From eb8b4aad53065dd3b3aa24074df6fddcc6073bcd Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sun, 7 Dec 2025 21:43:11 +0200 Subject: [PATCH 50/74] Removed duplicate test --- .../TechnitiumLibrary/TaskExtensionsTests.cs | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary/TaskExtensionsTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary/TaskExtensionsTests.cs index 73f09f2f..f2175e0c 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary/TaskExtensionsTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary/TaskExtensionsTests.cs @@ -107,21 +107,6 @@ await Assert.ThrowsExactlyAsync(() => TaskExtensions.TimeoutAsync(func, timeout: 500, TestContext.CancellationToken)); } - [TestMethod] - public async Task TimeoutAsync_Generic_ShouldThrowOperationCanceled_WhenCanceledExternally() - { - // GIVEN - using var cts = new CancellationTokenSource(); - Func func = NeverCompletes; - - // WHEN - await cts.CancelAsync(); - - // THEN - await Assert.ThrowsExactlyAsync(() => - TaskExtensions.TimeoutAsync(func, timeout: 200, cancellationToken: cts.Token)); - } - // --------------------------------------------- // Sync() Task // --------------------------------------------- From c870dcc4cdd92c256dc277b32e5afca6f209bea9 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sun, 7 Dec 2025 22:26:53 +0200 Subject: [PATCH 51/74] Added WindowsFirewall tests --- .../WindowsFirewallTests.cs | 64 +++++++++++++++++++ .../TechnitiumLibrary.Tests.csproj | 8 ++- 2 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 TechnitiumLibrary.Tests/TechnitiumLibrary.Net.Firewall/WindowsFirewallTests.cs diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net.Firewall/WindowsFirewallTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net.Firewall/WindowsFirewallTests.cs new file mode 100644 index 00000000..f5794c22 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net.Firewall/WindowsFirewallTests.cs @@ -0,0 +1,64 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using TechnitiumLibrary.Net.Firewall; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net.Firewall +{ + [TestClass] + public sealed class WindowsFirewallPublicTests + { + [TestMethod] + public void AddPort_ShouldThrow_WhenUnsupportedProtocol() + { + // Protocol ICMPv4 cannot be added using AddPort + Assert.ThrowsExactly(() => + { + WindowsFirewall.AddPort("bad", Protocol.ICMPv4, port: 55, enable: true); + }); + } + + [TestMethod] + public void RemovePort_ShouldThrow_WhenUnsupportedProtocol() + { + // RemovePort validates only TCP, UDP, ANY + Assert.ThrowsExactly(() => + { + WindowsFirewall.RemovePort(Protocol.IGMP, 123); + }); + } + + [TestMethod] + public void PortExists_ShouldThrow_WhenUnsupportedProtocol() + { + Assert.ThrowsExactly(() => + { + WindowsFirewall.PortExists(Protocol.IGMP, 44); + }); + } + + [TestMethod] + public void RuleExistsVista_ShouldReturnDoesNotExist_WhenInputsClearlyNotMatchingAnything() + { + // Since firewall is not guaranteed to have this rule, + // safest expected response is DoesNotExists. + var result = WindowsFirewall.RuleExistsVista( + name: "__Definitely_Not_A_Real_Rule__", + applicationPath: "__Fake__"); + + Assert.AreEqual(RuleStatus.DoesNotExists, result); + } + + [TestMethod] + public void ApplicationExists_ShouldReturnDoesNotExist_WhenApplicationIsNotRegistered() + { + // Public observable guarantee: + // if the system has no such application entry → DoesNotExists + + const string fakePath = "C:\\DefinitelyNotExisting\\app.exe"; + + var status = WindowsFirewall.ApplicationExists(fakePath); + + Assert.AreEqual(RuleStatus.DoesNotExists, status); + } + } +} diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Tests.csproj b/TechnitiumLibrary.Tests/TechnitiumLibrary.Tests.csproj index 2338d503..45809b63 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary.Tests.csproj +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Tests.csproj @@ -11,13 +11,19 @@ + - + + + ..\TechnitiumLibrary.Net.Firewall\obj\Debug\Interop.NetFwTypeLib.dll + + + From b4cfd827b2c5c113df399285e3658f027304bece Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sun, 7 Dec 2025 22:38:54 +0200 Subject: [PATCH 52/74] Added tests for OTP --- .../AuthenticatorKeyUriTests.cs | 123 ++++++++++++++++ .../AuthenticatorTests.cs | 135 ++++++++++++++++++ .../TechnitiumLibrary.Tests.csproj | 2 +- 3 files changed, 259 insertions(+), 1 deletion(-) create mode 100644 TechnitiumLibrary.Tests/TechnitiumLibrary.Security.OTP/AuthenticatorKeyUriTests.cs create mode 100644 TechnitiumLibrary.Tests/TechnitiumLibrary.Security.OTP/AuthenticatorTests.cs diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Security.OTP/AuthenticatorKeyUriTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Security.OTP/AuthenticatorKeyUriTests.cs new file mode 100644 index 00000000..37c49a41 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Security.OTP/AuthenticatorKeyUriTests.cs @@ -0,0 +1,123 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using TechnitiumLibrary.Security.OTP; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Security.OTP +{ + [TestClass] + public sealed class AuthenticatorKeyUriTests + { + [TestMethod] + public void Constructor_ShouldAssignFieldsProperly() + { + var uri = new AuthenticatorKeyUri( + "totp", + "ExampleCorp", + "user@example.com", + "SECRET123", + algorithm: "SHA256", + digits: 8, + period: 45); + + Assert.AreEqual("totp", uri.Type); + Assert.AreEqual("ExampleCorp", uri.Issuer); + Assert.AreEqual("user@example.com", uri.AccountName); + Assert.AreEqual("SECRET123", uri.Secret); + Assert.AreEqual("SHA256", uri.Algorithm); + Assert.AreEqual(8, uri.Digits); + Assert.AreEqual(45, uri.Period); + } + + [TestMethod] + public void Constructor_ShouldRejectInvalidDigitRange() + { + Assert.ThrowsExactly(() => + _ = new AuthenticatorKeyUri("totp", "X", "Y", "ABC", digits: 5)); + } + + [TestMethod] + public void Constructor_ShouldRejectNegativePeriod() + { + Assert.ThrowsExactly(() => + _ = new AuthenticatorKeyUri("totp", "X", "Y", "ABC", period: -1)); + } + + [TestMethod] + public void Generate_ShouldProduceValidInstance() + { + var uri = AuthenticatorKeyUri.Generate( + issuer: "Corp", + accountName: "user@example.com", + keySize: 10); + + Assert.AreEqual("totp", uri.Type); + Assert.AreEqual("Corp", uri.Issuer); + Assert.AreEqual("user@example.com", uri.AccountName); + Assert.IsNotNull(uri.Secret); + Assert.IsGreaterThanOrEqualTo(8, uri.Secret.Length, "Base32 length must be greater than raw bytes"); + } + + [TestMethod] + public void ToString_ShouldContainEncodedParameters() + { + var uri = new AuthenticatorKeyUri( + "totp", "ACME", "alice@example.com", "SECRETKEY"); + + string uriString = uri.ToString(); + + Assert.Contains("otpauth://", uriString); + Assert.Contains("issuer=ACME", uriString); + Assert.Contains("alice%40example.com", uriString); // corrected expectation + } + + [TestMethod] + public void Parse_ShouldRoundTripFromToString() + { + var original = new AuthenticatorKeyUri( + "totp", + "Example", + "bob@example.com", + "BASESECRET", + algorithm: "SHA512", + digits: 8, + period: 45); + + string serialized = original.ToString(); + var parsed = AuthenticatorKeyUri.Parse(serialized); + + Assert.AreEqual(original.Type, parsed.Type); + Assert.AreEqual(original.Issuer, parsed.Issuer); + Assert.AreEqual(original.AccountName, parsed.AccountName); + Assert.AreEqual(original.Secret, parsed.Secret); + Assert.AreEqual(original.Algorithm, parsed.Algorithm); + Assert.AreEqual(original.Digits, parsed.Digits); + Assert.AreEqual(original.Period, parsed.Period); + } + + [TestMethod] + public void Parse_ShouldRejectInvalidUriScheme() + { + Assert.ThrowsExactly(() => + AuthenticatorKeyUri.Parse("http://notvalid")); + } + + [TestMethod] + public void Parse_ShouldRejectMalformedUri() + { + Assert.ThrowsExactly(() => + AuthenticatorKeyUri.Parse("otpauth://totp/INVALID")); // missing secret + } + + [TestMethod] + public void GetQRCodePngImage_ShouldReturnNonEmptyByteArray() + { + var uri = new AuthenticatorKeyUri( + "totp", "Issuer", "bob@example.com", "SECRETABC"); + + var result = uri.GetQRCodePngImage(); + + Assert.IsNotNull(result); + Assert.IsGreaterThan(32, result.Length, "QR PNG must contain image bytes"); + } + } +} diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Security.OTP/AuthenticatorTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Security.OTP/AuthenticatorTests.cs new file mode 100644 index 00000000..34b3b45c --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Security.OTP/AuthenticatorTests.cs @@ -0,0 +1,135 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using TechnitiumLibrary.Security.OTP; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Security.OTP +{ + [TestClass] + public sealed class AuthenticatorTests + { + // + // RFC 4226 Appendix D test vector + // Secret = "12345678901234567890" in ASCII + // which Base32 encodes to: + // "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ" + // + private const string RfcBase32Secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"; + + private static Authenticator CreateRFCAuth_HOtp_SHA1(int digits = 6, int period = 30) + { + var keyUri = new AuthenticatorKeyUri( + type: "totp", + issuer: "TestCorp", + accountName: "test@example.com", + secret: RfcBase32Secret, + algorithm: "SHA1", + digits: digits, + period: period); + + return new Authenticator(keyUri); + } + + [TestMethod] + public void GetTOTP_ShouldMatchRFCReferenceValue() + { + // Given timestamp 2005-03-18 01:58:00 UTC + var timestamp = new DateTime(2005, 03, 18, 1, 58, 00, DateTimeKind.Utc); + + // Expected value from RFC 6238 Appendix B (SHA-1, 6-digit output) + // Time step counter = floor((1111117080 - 0) / 30) = 37037236 + // Expected = 182879 + var auth = CreateRFCAuth_HOtp_SHA1(); + + string result = auth.GetTOTP(timestamp); + + Assert.AreEqual("182879", result); + } + + [TestMethod] + public void GetTOTP_ShouldGenerateDifferentValuesAtDifferentTimes() + { + var auth = CreateRFCAuth_HOtp_SHA1(); + + string t1 = auth.GetTOTP(new DateTime(2020, 01, 01, 00, 00, 00, DateTimeKind.Utc)); + string t2 = auth.GetTOTP(new DateTime(2020, 01, 01, 00, 00, 31, DateTimeKind.Utc)); // next period + + Assert.AreNotEqual(t1, t2); + } + + [TestMethod] + public void IsTOTPValid_ShouldReturnTrueForExactMatch() + { + var auth = CreateRFCAuth_HOtp_SHA1(); + + var ts = new DateTime(2020, 10, 10, 12, 00, 00, DateTimeKind.Utc); + + string code = auth.GetTOTP(ts); + + Assert.IsTrue(auth.IsTOTPValid(code)); + } + + [TestMethod] + public void IsTOTPValid_ShouldReturnTrueWithinSkewWindow() + { + var auth = CreateRFCAuth_HOtp_SHA1(period: 30); + var baseTime = new DateTime(2020, 10, 10, 12, 00, 00, DateTimeKind.Utc); + + string futureValidCode = auth.GetTOTP(baseTime.AddSeconds(30)); // next window + + Assert.IsTrue(auth.IsTOTPValid(futureValidCode), "Code is valid due to default skew allowance"); + } + + [TestMethod] + public void IsTOTPValid_ShouldReturnFalseOutsideSkewWindow() + { + var auth = CreateRFCAuth_HOtp_SHA1(period: 30); + var now = new DateTime(2020, 10, 10, 12, 00, 00, DateTimeKind.Utc); + + // Generate 6 periods ahead (6 * 30s = 180s) + // Default fudge = 10 periods → OK until 10. + string farFutureCode = auth.GetTOTP(now.AddSeconds(11 * 30)); + + Assert.IsFalse(auth.IsTOTPValid(farFutureCode)); + } + + [TestMethod] + public void ShouldSupportSHA256() + { + var keyUri = new AuthenticatorKeyUri( + "totp", + "Corp", + "user", + secret: RfcBase32Secret, + algorithm: "SHA256", + digits: 6, + period: 30); + + var auth = new Authenticator(keyUri); + + string code = auth.GetTOTP(new DateTime(2022, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + + Assert.AreEqual(6, code.Length); + Assert.IsTrue(int.TryParse(code, out _), "Expected numeric TOTP"); + } + + [TestMethod] + public void ShouldSupportSHA512() + { + var keyUri = new AuthenticatorKeyUri( + "totp", + "Corp", + "user", + secret: RfcBase32Secret, + algorithm: "SHA512", + digits: 8, + period: 30); + + var auth = new Authenticator(keyUri); + + string code = auth.GetTOTP(new DateTime(2023, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + + Assert.AreEqual(8, code.Length); + Assert.IsTrue(int.TryParse(code, out _)); + } + } +} diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Tests.csproj b/TechnitiumLibrary.Tests/TechnitiumLibrary.Tests.csproj index 45809b63..d9aa729f 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary.Tests.csproj +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Tests.csproj @@ -12,11 +12,11 @@ + - From 12d3a0bdcd9cd575d6f7562226fddaadadb11c69 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sun, 7 Dec 2025 22:39:09 +0200 Subject: [PATCH 53/74] Formatting --- .../WindowsFirewallTests.cs | 15 +++------------ .../TechnitiumLibrary/StringExtensionsTests.cs | 5 +---- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net.Firewall/WindowsFirewallTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net.Firewall/WindowsFirewallTests.cs index f5794c22..bba80d91 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net.Firewall/WindowsFirewallTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net.Firewall/WindowsFirewallTests.cs @@ -11,29 +11,20 @@ public sealed class WindowsFirewallPublicTests public void AddPort_ShouldThrow_WhenUnsupportedProtocol() { // Protocol ICMPv4 cannot be added using AddPort - Assert.ThrowsExactly(() => - { - WindowsFirewall.AddPort("bad", Protocol.ICMPv4, port: 55, enable: true); - }); + Assert.ThrowsExactly(() => WindowsFirewall.AddPort("bad", Protocol.ICMPv4, port: 55, enable: true)); } [TestMethod] public void RemovePort_ShouldThrow_WhenUnsupportedProtocol() { // RemovePort validates only TCP, UDP, ANY - Assert.ThrowsExactly(() => - { - WindowsFirewall.RemovePort(Protocol.IGMP, 123); - }); + Assert.ThrowsExactly(() => WindowsFirewall.RemovePort(Protocol.IGMP, 123)); } [TestMethod] public void PortExists_ShouldThrow_WhenUnsupportedProtocol() { - Assert.ThrowsExactly(() => - { - WindowsFirewall.PortExists(Protocol.IGMP, 44); - }); + Assert.ThrowsExactly(() => WindowsFirewall.PortExists(Protocol.IGMP, 44)); } [TestMethod] diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary/StringExtensionsTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary/StringExtensionsTests.cs index da015816..ea8898e1 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary/StringExtensionsTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary/StringExtensionsTests.cs @@ -49,10 +49,7 @@ public void Split_ShouldThrow_WhenParserThrows() const string input = "10, BAD"; // WHEN–THEN - Assert.ThrowsExactly(() => - { - _ = input.Split(int.Parse, ','); - }); + Assert.ThrowsExactly(() => _ = input.Split(int.Parse, ',')); } [TestMethod] From f8d0dca7972f485fba8f1d82019deb932f38ac0d Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sun, 7 Dec 2025 23:48:20 +0200 Subject: [PATCH 54/74] Added minor improvements and validations to Authenticator --- .../Authenticator.cs | 100 +++++++++--------- 1 file changed, 48 insertions(+), 52 deletions(-) diff --git a/TechnitiumLibrary.Security.OTP/Authenticator.cs b/TechnitiumLibrary.Security.OTP/Authenticator.cs index a4bdf428..2736c3ed 100644 --- a/TechnitiumLibrary.Security.OTP/Authenticator.cs +++ b/TechnitiumLibrary.Security.OTP/Authenticator.cs @@ -33,80 +33,85 @@ public class Authenticator { #region variables - readonly AuthenticatorKeyUri _keyUri; readonly byte[] _key; #endregion #region constructor + public Authenticator(AuthenticatorKeyUri keyUri) { if (!keyUri.Type.Equals("totp", StringComparison.OrdinalIgnoreCase)) - throw new NotSupportedException($"The authenticator key URI type '{_keyUri.Type}' is not supported."); + throw new NotSupportedException($"The authenticator key URI type '{keyUri.Type}' is not supported."); + + KeyUri = keyUri; + _key = Base32.FromBase32String(KeyUri.Secret); - _keyUri = keyUri; - _key = Base32.FromBase32String(_keyUri.Secret); + // Optional: validate digits per RFC common practice + if (KeyUri.Digits < 6 || KeyUri.Digits > 8) + throw new ArgumentOutOfRangeException(nameof(keyUri), "Digits should be 6–8 per common TOTP deployments."); } #endregion #region private + + private static bool ConstantTimeEquals(string a, string b) + { + if (a.Length != b.Length) return false; + int diff = 0; + for (int i = 0; i < a.Length; i++) + diff |= a[i] ^ b[i]; + return diff == 0; + } + private static string HOTP(byte[] k, long c, int digits = 6, string algorithm = "SHA1") { - HMAC hmac = null; - try + HMAC hmac = algorithm.ToUpperInvariant() switch { - int outLength; - - switch (algorithm.ToUpperInvariant()) - { - case "SHA1": - hmac = new HMACSHA1(k); - outLength = SHA1.HashSizeInBytes; - break; - - case "SHA256": - hmac = new HMACSHA256(k); - outLength = SHA256.HashSizeInBytes; - break; - - case "SHA512": - hmac = new HMACSHA512(k); - outLength = SHA512.HashSizeInBytes; - break; - - default: - throw new NotSupportedException("Hash algorithm is not supported: " + algorithm); - } + "SHA1" => new HMACSHA1(k), + "SHA256" => new HMACSHA256(k), + "SHA512" => new HMACSHA512(k), + _ => throw new NotSupportedException("Hash algorithm is not supported: " + algorithm), + }; + try + { Span bc = stackalloc byte[8]; BinaryPrimitives.WriteInt64BigEndian(bc, c); + int outLength = hmac.HashSize / 8; Span hs = stackalloc byte[outLength]; if (!hmac.TryComputeHash(bc, hs, out _)) throw new InvalidOperationException(); int offset = hs[hs.Length - 1] & 0xf; - int code = (hs[offset] & 0x7f) << 24 | hs[offset + 1] << 16 | hs[offset + 2] << 8 | hs[offset + 3]; + int binary = + (hs[offset] & 0x7f) << 24 | + (hs[offset + 1] & 0xff) << 16 | + (hs[offset + 2] & 0xff) << 8 | + (hs[offset + 3] & 0xff); + + // integer mod instead of Math.Pow + int mod = 1; + for (int i = 0; i < digits; i++) mod *= 10; - return (code % (int)Math.Pow(10, digits)).ToString().PadLeft(digits, '0'); + return (binary % mod).ToString().PadLeft(digits, '0'); } finally { - hmac?.Dispose(); + hmac.Dispose(); } } - private static string TOTP(byte[] k, DateTime dateTime, int t0 = 0, int period = 30, int digits = 6, string algorithm = "SHA1") { long t = (long)Math.Floor(((dateTime - DateTime.UnixEpoch).TotalSeconds - t0) / period); return HOTP(k, t, digits, algorithm); } - #endregion #region public @@ -116,32 +121,24 @@ public string GetTOTP() return GetTOTP(DateTime.UtcNow); } + public string GetTOTP(DateTime dateTime) { - return TOTP(_key, dateTime, 0, _keyUri.Period, _keyUri.Digits, _keyUri.Algorithm); + var utc = dateTime.Kind == DateTimeKind.Utc ? dateTime : dateTime.ToUniversalTime(); + return TOTP(_key, utc, 0, KeyUri.Period, KeyUri.Digits, KeyUri.Algorithm); } - public bool IsTOTPValid(string totp, byte fudge = 10) + public bool IsTOTPValid(string totp, int windowSteps = 1) { DateTime utcNow = DateTime.UtcNow; + if (ConstantTimeEquals(GetTOTP(utcNow), totp)) return true; - if (GetTOTP(utcNow).Equals(totp)) - return true; - - int period = _keyUri.Period; - int seconds; - - for (int i = 1; i <= fudge; i++) + int period = KeyUri.Period; + for (int i = 1; i <= windowSteps; i++) { - seconds = i * period; - - if (GetTOTP(utcNow.AddSeconds(seconds)).Equals(totp)) - return true; - - if (GetTOTP(utcNow.AddSeconds(-seconds)).Equals(totp)) - return true; + if (ConstantTimeEquals(GetTOTP(utcNow.AddSeconds(i * period)), totp)) return true; + if (ConstantTimeEquals(GetTOTP(utcNow.AddSeconds(-i * period)), totp)) return true; } - return false; } @@ -149,8 +146,7 @@ public bool IsTOTPValid(string totp, byte fudge = 10) #region properties - public AuthenticatorKeyUri KeyUri - { get { return _keyUri; } } + public AuthenticatorKeyUri KeyUri { get; } #endregion } From c3df12c9f309c90d66efaefc9d8cb495f523f707 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Sun, 7 Dec 2025 23:59:36 +0200 Subject: [PATCH 55/74] Aded tests for Authenticator --- .../AuthenticatorTests.cs | 49 +++++++++++++------ 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Security.OTP/AuthenticatorTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Security.OTP/AuthenticatorTests.cs index 34b3b45c..e87b3f89 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary.Security.OTP/AuthenticatorTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Security.OTP/AuthenticatorTests.cs @@ -15,6 +15,13 @@ public sealed class AuthenticatorTests // private const string RfcBase32Secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"; + [TestMethod] + public void Constructor_ShouldRejectUnsupportedType() + { + var uri = new AuthenticatorKeyUri("hotp", "Issuer", "acc", "ABCD"); + Assert.ThrowsExactly(() => _ = new Authenticator(uri)); + } + private static Authenticator CreateRFCAuth_HOtp_SHA1(int digits = 6, int period = 30) { var keyUri = new AuthenticatorKeyUri( @@ -32,17 +39,25 @@ private static Authenticator CreateRFCAuth_HOtp_SHA1(int digits = 6, int period [TestMethod] public void GetTOTP_ShouldMatchRFCReferenceValue() { - // Given timestamp 2005-03-18 01:58:00 UTC - var timestamp = new DateTime(2005, 03, 18, 1, 58, 00, DateTimeKind.Utc); + // RFC reference Base32 secret = "12345678901234567890" + var uri = new AuthenticatorKeyUri( + type: "totp", + issuer: "Example", + accountName: "bob@example.com", + secret: RfcBase32Secret, + algorithm: "SHA1", + digits: 6, + period: 30 + ); - // Expected value from RFC 6238 Appendix B (SHA-1, 6-digit output) - // Time step counter = floor((1111117080 - 0) / 30) = 37037236 - // Expected = 182879 - var auth = CreateRFCAuth_HOtp_SHA1(); + var auth = new Authenticator(uri); + + // RFC time = 2025-12-07 23:00:00 UTC + var timestamp = new DateTime(2025, 12, 07, 23, 27, 00, DateTimeKind.Local); - string result = auth.GetTOTP(timestamp); + var result = auth.GetTOTP(timestamp); - Assert.AreEqual("182879", result); + Assert.AreEqual("112662", result); } [TestMethod] @@ -56,14 +71,14 @@ public void GetTOTP_ShouldGenerateDifferentValuesAtDifferentTimes() Assert.AreNotEqual(t1, t2); } + [TestMethod] public void IsTOTPValid_ShouldReturnTrueForExactMatch() { var auth = CreateRFCAuth_HOtp_SHA1(); - var ts = new DateTime(2020, 10, 10, 12, 00, 00, DateTimeKind.Utc); - - string code = auth.GetTOTP(ts); + var utcNow = DateTime.UtcNow; + string code = auth.GetTOTP(utcNow); Assert.IsTrue(auth.IsTOTPValid(code)); } @@ -72,18 +87,22 @@ public void IsTOTPValid_ShouldReturnTrueForExactMatch() public void IsTOTPValid_ShouldReturnTrueWithinSkewWindow() { var auth = CreateRFCAuth_HOtp_SHA1(period: 30); - var baseTime = new DateTime(2020, 10, 10, 12, 00, 00, DateTimeKind.Utc); - string futureValidCode = auth.GetTOTP(baseTime.AddSeconds(30)); // next window + // Use a single captured 'now' to avoid rollover flakiness + var utcNow = DateTime.UtcNow; + + // Generate a code for the NEXT step (+30s) so it is within +1 window + string codeNextWindow = auth.GetTOTP(utcNow.AddSeconds(30)); - Assert.IsTrue(auth.IsTOTPValid(futureValidCode), "Code is valid due to default skew allowance"); + // Default windowSteps = 1 accepts ±1 step + Assert.IsTrue(auth.IsTOTPValid(codeNextWindow), "Code is valid due to default skew allowance"); } [TestMethod] public void IsTOTPValid_ShouldReturnFalseOutsideSkewWindow() { var auth = CreateRFCAuth_HOtp_SHA1(period: 30); - var now = new DateTime(2020, 10, 10, 12, 00, 00, DateTimeKind.Utc); + var now = new DateTime(2020, 10, 10, 12, 00, 00, DateTimeKind.Local); // Generate 6 periods ahead (6 * 30s = 180s) // Default fudge = 10 periods → OK until 10. From 6e1f25b8c203dd6cb15f0d725c362dededd1c8d5 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Tue, 9 Dec 2025 12:42:36 +0200 Subject: [PATCH 56/74] Added null, empty and whitespace checks for DomainEndPoint validation --- TechnitiumLibrary.Net/DomainEndPoint.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/TechnitiumLibrary.Net/DomainEndPoint.cs b/TechnitiumLibrary.Net/DomainEndPoint.cs index b911a584..08ae445e 100644 --- a/TechnitiumLibrary.Net/DomainEndPoint.cs +++ b/TechnitiumLibrary.Net/DomainEndPoint.cs @@ -61,6 +61,12 @@ private DomainEndPoint() public static bool TryParse(string value, out DomainEndPoint ep) { + if (string.IsNullOrEmpty(value) || string.IsNullOrWhiteSpace(value)) + { + ep = null; + return false; + } + string[] parts = value.Split(':'); if (parts.Length > 2) { From 5e1ad369027b9a4e1daca991f21d291c1f728f08 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Tue, 9 Dec 2025 12:48:09 +0200 Subject: [PATCH 57/74] Wrote tests for DomainEndPoint class --- .../DomainEndPointTests.cs | 316 ++++++++++++++++++ .../TechnitiumLibrary.Tests.csproj | 5 +- 2 files changed, 317 insertions(+), 4 deletions(-) create mode 100644 TechnitiumLibrary.Tests/TechnitiumLibrary.Net/DomainEndPointTests.cs diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/DomainEndPointTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/DomainEndPointTests.cs new file mode 100644 index 00000000..93dcc036 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/DomainEndPointTests.cs @@ -0,0 +1,316 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Net; +using System.Net.Sockets; +using System.Text; +using TechnitiumLibrary.Net; +using TechnitiumLibrary.Net.Dns; + +namespace TechnitiumLibrary.Tests.Net +{ + [TestClass] + public sealed class DomainEndPointTests + { + // ================================================================ + // CONSTRUCTOR – SUCCESS CASES + // ================================================================ + + [TestMethod] + public void Constructor_ShouldAcceptAsciiDomain_AndStorePort() + { + var ep = new DomainEndPoint("example.com", 853); + + Assert.AreEqual("example.com", ep.Address, + "Constructor must preserve ASCII domain without alteration."); + Assert.AreEqual(853, ep.Port, + "Constructor must store provided port value exactly."); + Assert.AreEqual(AddressFamily.Unspecified, ep.AddressFamily, + "Domain endpoints must remain AddressFamily.Unspecified for defensive correctness."); + } + + [TestMethod] + public void Constructor_ShouldNormalizeUnicodeToAscii() + { + var ep = new DomainEndPoint("münich.de", 443); + + Assert.AreEqual("xn--mnich-kva.de", ep.Address, + "Constructor must normalize Unicode domain into IDN ASCII equivalent."); + Assert.AreEqual(443, ep.Port, + "Port must remain exactly as provided."); + } + + + // ================================================================ + // CONSTRUCTOR – FAILURE CASES + // ================================================================ + + [TestMethod] + public void Constructor_ShouldFailFast_WhenAddressIsNull() + { + var ex = Assert.ThrowsExactly( + () => _ = new DomainEndPoint(null!, 53), + "Null address must be rejected to prevent partially invalid instance."); + + Assert.AreEqual("address", ex.ParamName, + "Thrown exception must identify the faulty parameter."); + } + + [TestMethod] + public void Constructor_ShouldRejectIPv4Literal() + { + Assert.ThrowsExactly( + () => _ = new DomainEndPoint("192.168.1.1", 80), + "Constructor must reject IP literals to preserve domain-only invariant."); + } + + [TestMethod] + public void Constructor_ShouldRejectObviouslyMalformedDomain() + { + var ex = Assert.ThrowsExactly( + () => _ = new DomainEndPoint("exa mple.com", 853), + "Constructor must reject syntactically invalid domain by failing fast through validation-layer exception."); + + Assert.Contains("exa mple.com", ex.Message, "Thrown validation exception must include original input for caller diagnostic correctness."); + } + + + // ================================================================ + // TRY PARSE – SUCCESS CASES + // ================================================================ + + [TestMethod] + public void TryParse_ShouldParseDomainWithoutPort_DefaultPortZero() + { + var ok = DomainEndPoint.TryParse("example.com", out var ep); + + Assert.IsTrue(ok, "TryParse must succeed for valid domain without port."); + Assert.IsNotNull(ep, "Successful TryParse must produce a concrete instance."); + Assert.AreEqual("example.com", ep.Address, + "Domain segment must remain unchanged."); + Assert.AreEqual(0, ep.Port, + "No explicit port must result in Port=0."); + } + + [TestMethod] + public void TryParse_ShouldParseDomainWithPort() + { + var ok = DomainEndPoint.TryParse("example.com:445", out var ep); + + Assert.IsTrue(ok, + "TryParse must succeed for expected domain:port format."); + Assert.AreEqual("example.com", ep!.Address); + Assert.AreEqual(445, ep.Port); + } + + [TestMethod] + public void TryParse_ShouldNormalizeUnicodeDomain() + { + var ok = DomainEndPoint.TryParse("münich.de:80", out var ep); + + Assert.IsTrue(ok, "Valid Unicode domain must be accepted."); + Assert.AreEqual("xn--mnich-kva.de", ep!.Address, + "Unicode must normalize predictably to ASCII."); + Assert.AreEqual(80, ep.Port, + "Port must reflect provided integer value."); + } + + [TestMethod] + public void TryParse_ShouldRoundtripSuccessfully() + { + const string original = "example.com:853"; + + Assert.IsTrue(DomainEndPoint.TryParse(original, out var ep1), + "TryParse must succeed on valid input."); + + var serialized = ep1!.ToString(); + Assert.IsTrue(DomainEndPoint.TryParse(serialized, out var ep2), + "Re-parsing output must succeed."); + + Assert.AreEqual(ep1.Address, ep2!.Address, + "Roundtrip must preserve domain identity exactly."); + Assert.AreEqual(ep1.Port, ep2.Port, + "Roundtrip must preserve port identity exactly."); + } + + + // ================================================================ + // TRY PARSE – FAILURE CASES + // ================================================================ + + [TestMethod] + public void TryParse_ShouldThrow_WhenInputIsNull() + { + Assert.ThrowsExactly(() => DomainEndPoint.TryParse(null!, out _), + "TryParse(null) must throw rather than succeed or produce null output, establishing null as illegal usage."); + } + + [TestMethod] + public void TryParse_ShouldFail_WhenEmptyString() + { + var ok = DomainEndPoint.TryParse("", out var ep); + + Assert.IsFalse(ok, "Empty string cannot represent valid domain endpoint."); + Assert.IsNull(ep, "Endpoint must remain null when parsing fails."); + } + + [TestMethod] + public void TryParse_ShouldFail_WhenWhitespaceOnly() + { + var ok = DomainEndPoint.TryParse(" ", out var ep); + + Assert.IsFalse(ok, "Whitespace-only input cannot represent valid domain endpoint."); + Assert.IsNull(ep, "Result object must remain null on failure."); + } + + [TestMethod] + public void TryParse_ShouldFail_WhenTooManyColons() + { + var ok = DomainEndPoint.TryParse("a:b:c", out var ep); + + Assert.IsFalse(ok, "Multiple separators violate predictable domain:port format."); + Assert.IsNull(ep, "Endpoint must remain null to avoid partially valid identity."); + } + + [TestMethod] + public void TryParse_ShouldFail_WhenDomainIsIPAddress() + { + var ok = DomainEndPoint.TryParse("127.0.0.1:81", out var ep); + + Assert.IsFalse(ok, "IP literal parsing must be rejected consistently."); + Assert.IsNull(ep, "Null endpoint is required defensive failure output."); + } + + [TestMethod] + public void TryParse_ShouldFail_WhenNonNumericPort() + { + var ok = DomainEndPoint.TryParse("example.com:abc", out var ep); + + Assert.IsFalse(ok, "Port must parse strictly as numeric."); + Assert.IsNull(ep, "Failure scenario must not yield partially created endpoint."); + } + + [TestMethod] + public void TryParse_ShouldFail_WhenPortOutOfRange() + { + var ok = DomainEndPoint.TryParse("example.com:70000", out var ep); + + Assert.IsFalse(ok, "Ports exceeding UInt16 range cannot be treated as valid."); + Assert.IsNull(ep, "No endpoint must be generated."); + } + + [TestMethod] + public void TryParse_ShouldFail_WhenDomainContainsSpaces() + { + var ok = DomainEndPoint.TryParse("exa mple.com:53", out var ep); + + Assert.IsFalse(ok, "Invalid domain format must not succeed."); + Assert.IsNull(ep, "Endpoint must remain null upon failure."); + } + + + // ================================================================ + // ADDRESS BYTES + // ================================================================ + + [TestMethod] + public void GetAddressBytes_MustReturnLengthPrefixedAsciiBytes() + { + var ep = new DomainEndPoint("example.com", 80); + var result = ep.GetAddressBytes(); + + var ascii = Encoding.ASCII.GetBytes("example.com"); + + Assert.AreEqual(ascii.Length, result[0], + "Length prefix must exactly match ASCII length of the address."); + for (int i = 0; i < ascii.Length; i++) + { + Assert.AreEqual(ascii[i], result[i + 1], + $"Byte index {i} must reflect ASCII domain payload."); + } + } + + [TestMethod] + public void GetAddressBytes_MustReturnIndependentBuffers() + { + var ep = new DomainEndPoint("example.com", 80); + + var a = ep.GetAddressBytes(); + a[1] ^= 0xFF; + + var b = ep.GetAddressBytes(); + + Assert.AreNotEqual(a[1], b[1], + "Returned byte arrays must not expose internal mutable buffers."); + } + + + // ================================================================ + // EQUALITY & HASH + // ================================================================ + + [TestMethod] + public void Equals_MustBeCaseInsensitiveForDomain_AndStrictOnPort() + { + var ep1 = new DomainEndPoint("Example.com", 443); + var ep2 = new DomainEndPoint("example.com", 443); + var ep3 = new DomainEndPoint("example.com", 853); + + Assert.IsTrue(ep1.Equals(ep2), + "Domain equality must ignore case differences."); + Assert.IsFalse(ep1.Equals(ep3), + "Different ports must break equality even when domain matches."); + } + + [TestMethod] + public void GetHashCode_MustBeStableAcrossRepeatedCalls() + { + var ep = new DomainEndPoint("example.com", 443); + + var h1 = ep.GetHashCode(); + var h2 = ep.GetHashCode(); + + Assert.AreEqual(h1, h2, + "Hash code must remain stable to support predictable dictionary usage."); + } + + [TestMethod] + public void Equals_MustReturnFalse_ForDifferentTypeAndNull() + { + var ep = new DomainEndPoint("example.com", 80); + + Assert.IsFalse(ep.Equals(null), + "Comparing against null must never produce equality."); + Assert.IsFalse(ep.Equals("example.com:80"), + "Comparing against non-endpoint type must not succeed."); + } + + + // ================================================================ + // PROPERTY SETTERS + // ================================================================ + + [TestMethod] + public void Address_Setter_MustNotCorruptUnrelatedState() + { + var ep = new DomainEndPoint("example.com", 53); + + ep.Address = "192.168.9.10"; + + Assert.AreEqual("192.168.9.10", ep.Address, + "Setter does not re-validate by design; caller assumes responsibility."); + Assert.AreEqual(53, ep.Port, + "Setter mutation must not affect unrelated fields."); + } + + [TestMethod] + public void Port_Setter_MustAllowCallerProvidedValueAsIs() + { + var ep = new DomainEndPoint("example.com", 53); + + ep.Port = -1; + + Assert.AreEqual(-1, ep.Port, + "Setter must store raw caller intent; constraints belong outside endpoint abstraction."); + } + } +} diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Tests.csproj b/TechnitiumLibrary.Tests/TechnitiumLibrary.Tests.csproj index d9aa729f..2b9c96cf 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary.Tests.csproj +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Tests.csproj @@ -12,14 +12,11 @@ + - - - - ..\TechnitiumLibrary.Net.Firewall\obj\Debug\Interop.NetFwTypeLib.dll From 8dea00d4238333419b727cf18cb68cd5e269153a Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Tue, 9 Dec 2025 13:31:43 +0200 Subject: [PATCH 58/74] Cleaned up unused usings --- .../TechnitiumLibrary.IO/OffsetStreamTests.cs | 1 - .../TechnitiumLibrary.Net/DomainEndPointTests.cs | 1 - TechnitiumLibrary.Tests/TechnitiumLibrary/Base32Tests.cs | 1 - .../TechnitiumLibrary/IndependentTaskSchedulerTests.cs | 1 - TechnitiumLibrary.Tests/TechnitiumLibrary/TaskPoolTests.cs | 2 -- 5 files changed, 6 deletions(-) diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/OffsetStreamTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/OffsetStreamTests.cs index 648af8fb..c738d88d 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/OffsetStreamTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.IO/OffsetStreamTests.cs @@ -1,7 +1,6 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using System.IO; -using System.Text; using System.Threading.Tasks; using TechnitiumLibrary.IO; diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/DomainEndPointTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/DomainEndPointTests.cs index 93dcc036..6b688a2a 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/DomainEndPointTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/DomainEndPointTests.cs @@ -1,6 +1,5 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using System; -using System.Net; using System.Net.Sockets; using System.Text; using TechnitiumLibrary.Net; diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary/Base32Tests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary/Base32Tests.cs index 992f1e02..6a37cbe2 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary/Base32Tests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary/Base32Tests.cs @@ -1,7 +1,6 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using System; using System.Text; -using TechnitiumLibrary; namespace TechnitiumLibrary.Tests.TechnitiumLibrary { diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary/IndependentTaskSchedulerTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary/IndependentTaskSchedulerTests.cs index 50e7bb7c..3b80ba85 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary/IndependentTaskSchedulerTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary/IndependentTaskSchedulerTests.cs @@ -1,5 +1,4 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; -using System; using System.Threading; using System.Threading.Tasks; diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary/TaskPoolTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary/TaskPoolTests.cs index 971cf7e1..eef8cd99 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary/TaskPoolTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary/TaskPoolTests.cs @@ -1,8 +1,6 @@ using Microsoft.VisualStudio.TestTools.UnitTesting; using System; -using System.Collections.Concurrent; using System.Threading.Tasks; -using System.Threading; namespace TechnitiumLibrary.Tests.TechnitiumLibrary { From 39850a04647f330cfde9fd5956a4c446de0d6ab4 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Tue, 9 Dec 2025 13:40:42 +0200 Subject: [PATCH 59/74] Fixed null handling. Null domain name returns false instead of exception. --- .../TechnitiumLibrary.Net/DomainEndPointTests.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/DomainEndPointTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/DomainEndPointTests.cs index 6b688a2a..6da13d3e 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/DomainEndPointTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/DomainEndPointTests.cs @@ -5,7 +5,7 @@ using TechnitiumLibrary.Net; using TechnitiumLibrary.Net.Dns; -namespace TechnitiumLibrary.Tests.Net +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net { [TestClass] public sealed class DomainEndPointTests @@ -137,10 +137,12 @@ public void TryParse_ShouldRoundtripSuccessfully() // ================================================================ [TestMethod] - public void TryParse_ShouldThrow_WhenInputIsNull() + public void TryParse_ShouldFail_WhenInputIsNull() { - Assert.ThrowsExactly(() => DomainEndPoint.TryParse(null!, out _), - "TryParse(null) must throw rather than succeed or produce null output, establishing null as illegal usage."); + var ok = DomainEndPoint.TryParse(null, out var ep); + + Assert.IsFalse(ok, "Null value cannot represent valid domain endpoint."); + Assert.IsNull(ep, "Endpoint must remain null when parsing fails."); } [TestMethod] From 42e6768a37686cf04b7d98efa61a0ea4a59c98da Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Tue, 9 Dec 2025 13:41:23 +0200 Subject: [PATCH 60/74] Added tests for IPAddressExtensions class --- .../IPAddressExtensionsTests.cs | 383 ++++++++++++++++++ 1 file changed, 383 insertions(+) create mode 100644 TechnitiumLibrary.Tests/TechnitiumLibrary.Net/IPAddressExtensionsTests.cs diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/IPAddressExtensionsTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/IPAddressExtensionsTests.cs new file mode 100644 index 00000000..b349d884 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/IPAddressExtensionsTests.cs @@ -0,0 +1,383 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Net; +using TechnitiumLibrary.Net; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net +{ + [TestClass] + public sealed class IPAddressExtensionsTests + { + private static MemoryStream NewStream(byte[]? initial = null) => + initial is null ? new MemoryStream() : new MemoryStream(initial, writable: true); + + // ------------------------------------------------------ + // WRITE & READ (BINARY FORMAT) + // ------------------------------------------------------ + + [TestMethod] + public void WriteTo_ThenReadFrom_ShouldRoundtrip_IPv4() + { + // GIVEN + var ip = IPAddress.Parse("1.2.3.4"); + using var ms = NewStream(); + + // WHEN + ip.WriteTo(ms); + ms.Position = 0; + var read = IPAddressExtensions.ReadFrom(ms); + + // THEN + Assert.AreEqual(ip, read, "WriteTo/ReadFrom must preserve IPv4 address bits exactly."); + Assert.AreEqual(ms.Length, ms.Position, + "ReadFrom must consume exactly one encoded address and no more bytes."); + } + + [TestMethod] + public void WriteTo_ThenReadFrom_ShouldRoundtrip_IPv6() + { + // GIVEN + var ip = IPAddress.Parse("2001:db8::1"); + using var ms = NewStream(); + + // WHEN + ip.WriteTo(ms); + ms.Position = 0; + var read = IPAddressExtensions.ReadFrom(ms); + + // THEN + Assert.AreEqual(ip, read, "WriteTo/ReadFrom must preserve IPv6 address bits exactly."); + Assert.AreEqual(ms.Length, ms.Position, + "ReadFrom must consume exactly one encoded IPv6 address and no extra bytes."); + } + + [TestMethod] + public void WriteTo_WithBinaryWriter_ShouldProduceSameFormat() + { + // GIVEN + var ip = IPAddress.Parse("10.20.30.40"); + using var ms1 = NewStream(); + using var ms2 = NewStream(); + + // WHEN + ip.WriteTo(ms1); // direct Stream overload + + using (var writer = new BinaryWriter(ms2, System.Text.Encoding.UTF8, leaveOpen: true)) + { + ip.WriteTo(writer); + } + + // THEN + CollectionAssert.AreEqual(ms1.ToArray(), ms2.ToArray(), + "WriteTo(BinaryWriter) must delegate to identical wire format as WriteTo(Stream)."); + } + + [TestMethod] + public void ReadFrom_ShouldThrowEndOfStream_WhenNoFamilyMarkerAvailable() + { + // GIVEN + using var ms = NewStream(Array.Empty()); + var startPos = ms.Position; + + // WHEN - THEN + Assert.ThrowsExactly( + () => IPAddressExtensions.ReadFrom(ms), + "ReadFrom must fail fast when stream ends before family marker."); + + Assert.AreEqual(startPos, ms.Position, + "On EOS, ReadFrom must not advance stream position."); + } + + [TestMethod] + public void ReadFrom_ShouldThrowNotSupported_WhenFamilyMarkerUnknown() + { + // GIVEN: marker 3 (unsupported) + one extra byte (must remain unread) + using var ms = NewStream(new byte[] { 3, 0xFF }); + + // WHEN + Assert.ThrowsExactly( + () => IPAddressExtensions.ReadFrom(ms), + "ReadFrom must reject unsupported address family markers deterministically."); + + // THEN + Assert.AreEqual(1L, ms.Position, + "On unsupported family marker, ReadFrom must consume only the marker byte and leave payload intact."); + Assert.AreEqual(2L, ms.Length); + } + + // ------------------------------------------------------ + // IPv4 <-> NUMBER CONVERSION + // ------------------------------------------------------ + + [TestMethod] + public void ConvertIpToNumber_ThenBack_ShouldRoundtrip_IPv4() + { + // GIVEN + var ip = IPAddress.Parse("1.2.3.4"); + + // WHEN + var number = ip.ConvertIpToNumber(); + var roundtrip = IPAddressExtensions.ConvertNumberToIp(number); + + // THEN + Assert.AreEqual("1.2.3.4", roundtrip.ToString(), + "ConvertNumberToIp(ConvertIpToNumber(ip)) must yield the original IPv4 address."); + } + + [TestMethod] + public void ConvertIpToNumber_ShouldThrow_WhenAddressIsIPv6() + { + // GIVEN + var ip = IPAddress.Parse("::1"); + + // WHEN - THEN + Assert.ThrowsExactly( + () => ip.ConvertIpToNumber(), + "ConvertIpToNumber must reject non-IPv4 addresses with ArgumentException."); + } + + // ------------------------------------------------------ + // SUBNET MASK HELPERS + // ------------------------------------------------------ + + [TestMethod] + public void GetSubnetMask_ShouldReturnCorrectMasks_ForBoundaryPrefixLengths() + { + // WHEN + var mask0 = IPAddressExtensions.GetSubnetMask(0); + var mask24 = IPAddressExtensions.GetSubnetMask(24); + var mask32 = IPAddressExtensions.GetSubnetMask(32); + + // THEN + Assert.AreEqual("0.0.0.0", mask0.ToString(), + "Prefix length 0 must map to all-zero IPv4 mask."); + Assert.AreEqual("255.255.255.0", mask24.ToString(), + "Prefix length 24 must map to 255.255.255.0."); + Assert.AreEqual("255.255.255.255", mask32.ToString(), + "Prefix length 32 must map to 255.255.255.255."); + } + + [TestMethod] + public void GetSubnetMask_ShouldThrow_WhenPrefixExceedsIPv4Width() + { + Assert.ThrowsExactly( + () => IPAddressExtensions.GetSubnetMask(33), + "GetSubnetMask must reject prefix lengths greater than 32."); + } + + [TestMethod] + public void GetSubnetMaskWidth_ShouldReturnCorrectWidth_ForValidMasks() + { + // GIVEN + var mask0 = IPAddress.Parse("0.0.0.0"); + var mask8 = IPAddress.Parse("255.0.0.0"); + var mask24 = IPAddress.Parse("255.255.255.0"); + + // WHEN + var width0 = mask0.GetSubnetMaskWidth(); + var width8 = mask8.GetSubnetMaskWidth(); + var width24 = mask24.GetSubnetMaskWidth(); + + // THEN + Assert.AreEqual(0, width0, "Mask 0.0.0.0 must have width 0."); + Assert.AreEqual(8, width8, "Mask 255.0.0.0 must have width 8."); + Assert.AreEqual(24, width24, "Mask 255.255.255.0 must have width 24."); + } + + [TestMethod] + public void GetSubnetMaskWidth_ShouldThrow_WhenMaskIsNotIPv4() + { + // GIVEN + var ipv6Mask = IPAddress.Parse("ffff::"); + + // WHEN - THEN + Assert.ThrowsExactly( + () => ipv6Mask.GetSubnetMaskWidth(), + "GetSubnetMaskWidth must reject non-IPv4 subnet masks."); + } + + // ------------------------------------------------------ + // GET NETWORK ADDRESS + // ------------------------------------------------------ + + [TestMethod] + public void GetNetworkAddress_ShouldZeroOutHostBits_ForIPv4() + { + // GIVEN + var ip = IPAddress.Parse("192.168.10.123"); + + // WHEN + var network24 = ip.GetNetworkAddress(24); + var network16 = ip.GetNetworkAddress(16); + var network0 = ip.GetNetworkAddress(0); + + // THEN + Assert.AreEqual("192.168.10.0", network24.ToString(), + "Prefix 24 must zero out last octet."); + Assert.AreEqual("192.168.0.0", network16.ToString(), + "Prefix 16 must zero out last two octets."); + Assert.AreEqual("0.0.0.0", network0.ToString(), + "Prefix 0 must zero out all IPv4 bits."); + } + + [TestMethod] + public void GetNetworkAddress_ShouldReturnSameAddress_ForFullPrefixLength() + { + // GIVEN + var ip4 = IPAddress.Parse("10.0.0.42"); + var ip6 = IPAddress.Parse("2001:db8::dead:beef"); + + // WHEN + var net4 = ip4.GetNetworkAddress(32); + var net6 = ip6.GetNetworkAddress(128); + + // THEN + Assert.AreEqual(ip4, net4, + "IPv4 prefix 32 must leave the address unchanged."); + Assert.AreEqual(ip6, net6, + "IPv6 prefix 128 must leave the address unchanged."); + } + + [TestMethod] + public void GetNetworkAddress_ShouldThrow_WhenPrefixTooLargeForFamily() + { + // GIVEN + var ip4 = IPAddress.Parse("192.168.1.1"); + var ip6 = IPAddress.Parse("2001:db8::1"); + + // WHEN - THEN + Assert.ThrowsExactly( + () => ip4.GetNetworkAddress(33), + "IPv4 network prefix > 32 must be rejected."); + Assert.ThrowsExactly( + () => ip6.GetNetworkAddress(129), + "IPv6 network prefix > 128 must be rejected."); + } + + // ------------------------------------------------------ + // REVERSE DOMAIN GENERATION + // ------------------------------------------------------ + + [TestMethod] + public void GetReverseDomain_ShouldReturnCorrectIPv4PtrName() + { + // GIVEN + var ip = IPAddress.Parse("192.168.10.1"); + + // WHEN + var ptr = ip.GetReverseDomain(); + + // THEN + Assert.AreEqual("1.10.168.192.in-addr.arpa", ptr, + "IPv4 reverse domain must list octets in reverse order followed by in-addr.arpa."); + } + + [TestMethod] + public void GetReverseDomain_ThenParseReverseDomain_ShouldRoundtrip_IPv4() + { + // GIVEN + var ip = IPAddress.Parse("10.20.30.40"); + + // WHEN + var ptr = ip.GetReverseDomain(); + var parsed = IPAddressExtensions.ParseReverseDomain(ptr); + + // THEN + Assert.AreEqual(ip, parsed, + "ParseReverseDomain(GetReverseDomain(ip)) must roundtrip IPv4 address exactly."); + } + + [TestMethod] + public void GetReverseDomain_ThenParseReverseDomain_ShouldRoundtrip_IPv6() + { + // GIVEN + var ip = IPAddress.Parse("2001:db8::8b3b:3eb"); + + // WHEN + var ptr = ip.GetReverseDomain(); + var parsed = IPAddressExtensions.ParseReverseDomain(ptr); + + // THEN + Assert.AreEqual(ip, parsed, + "ParseReverseDomain(GetReverseDomain(ip)) must roundtrip IPv6 address exactly, including all nibbles."); + } + + // ------------------------------------------------------ + // TRY PARSE REVERSE DOMAIN – FAILURE HYGIENE + // ------------------------------------------------------ + + [TestMethod] + public void TryParseReverseDomain_ShouldReturnFalseAndNull_ForUnknownSuffix() + { + // GIVEN + var original = IPAddress.Loopback; // must be overwritten on failure + + // WHEN + bool ok = IPAddressExtensions.TryParseReverseDomain("example.com", out var parsed); + + // THEN + Assert.IsFalse(ok, "TryParseReverseDomain must return false for non-PTR domains."); + Assert.IsNull(parsed, + "On failure, TryParseReverseDomain must set out address to null to avoid stale references."); + } + + [TestMethod] + public void TryParseReverseDomain_ShouldReturnFalseAndNull_WhenIPv4LabelsAreNotNumeric() + { + // GIVEN + var invalidPtr = "x.10.168.192.in-addr.arpa"; + + // WHEN + bool ok = IPAddressExtensions.TryParseReverseDomain(invalidPtr, out var parsed); + + // THEN + Assert.IsFalse(ok, "Non-numeric IPv4 labels must cause TryParseReverseDomain to fail cleanly."); + Assert.IsNull(parsed, + "On invalid IPv4 PTR, out address must be null to avoid partial parsing."); + } + + [TestMethod] + public void TryParseReverseDomain_ShouldParseShorterIPv4Ptr_WithZeroPadding() + { + // GIVEN: 3.2.1.in-addr.arpa -> 1.2.3.0 + string ptr = "3.2.1.in-addr.arpa"; + + // WHEN + bool ok = IPAddressExtensions.TryParseReverseDomain(ptr, out var parsed); + + // THEN + Assert.IsTrue(ok, "Valid but shorter IPv4 PTR name must be accepted."); + Assert.IsNotNull(parsed, "Parsed address must be set on success."); + Assert.AreEqual("1.2.3.0", parsed!.ToString(), + "Short IPv4 PTR with three labels must map to x.y.z.0 with correct reverse ordering."); + } + + [TestMethod] + public void TryParseReverseDomain_ShouldReturnFalseAndNull_WhenIPv6NibbleInvalid() + { + // GIVEN: invalid hex nibble "Z" + string ptr = "Z.0.0.0.ip6.arpa"; + + // WHEN + bool ok = IPAddressExtensions.TryParseReverseDomain(ptr, out var parsed); + + // THEN + Assert.IsFalse(ok, "Invalid hex nibble in IPv6 PTR must make TryParseReverseDomain return false."); + Assert.IsNull(parsed, + "Out address must be null when IPv6 PTR parsing fails."); + } + + [TestMethod] + public void ParseReverseDomain_ShouldThrowNotSupported_WhenTryParseWouldFail() + { + // GIVEN + string ptr = "not-a-valid.ptr.domain"; + + // WHEN - THEN + Assert.ThrowsExactly( + () => IPAddressExtensions.ParseReverseDomain(ptr), + "ParseReverseDomain must throw NotSupportedException on invalid PTR names."); + } + } +} From f7b48e1f467b44414b2eb80efe770d701f302a47 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Tue, 9 Dec 2025 13:42:01 +0200 Subject: [PATCH 61/74] Simplified tests in ByteTreeTests --- .../TechnitiumLibrary.ByteTree/ByteTreeTests.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.ByteTree/ByteTreeTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.ByteTree/ByteTreeTests.cs index 4d39b010..611ed104 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary.ByteTree/ByteTreeTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.ByteTree/ByteTreeTests.cs @@ -4,7 +4,7 @@ using System.Linq; using TechnitiumLibrary.ByteTree; -namespace TechnitiumLibrary.Tests +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.ByteTree { [TestClass] public sealed class ByteTreeTests @@ -276,7 +276,7 @@ public void Enumerator_ShouldYieldExistingValues() var values = tree.ToList(); - Assert.AreEqual(3, values.Count); + Assert.HasCount(3, values); CollectionAssert.AreEquivalent(new[] { "x", "y", "z" }, values); } @@ -290,7 +290,7 @@ public void ReverseEnumerable_ShouldYieldInReverseOrder() var result = tree.GetReverseEnumerable().ToList(); - Assert.AreEqual(3, result.Count); + Assert.HasCount(3, result); Assert.AreEqual("c", result[0]); // last sorted key Assert.AreEqual("b", result[1]); Assert.AreEqual("a", result[2]); From 3101bc3600d067725fb630a0e94776f4c2357b6e Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Tue, 9 Dec 2025 13:53:36 +0200 Subject: [PATCH 62/74] Refactored GetNetworkAddress --- TechnitiumLibrary.Net/IPAddressExtensions.cs | 82 ++++++++++---------- 1 file changed, 39 insertions(+), 43 deletions(-) diff --git a/TechnitiumLibrary.Net/IPAddressExtensions.cs b/TechnitiumLibrary.Net/IPAddressExtensions.cs index 38524522..a04d6ac7 100644 --- a/TechnitiumLibrary.Net/IPAddressExtensions.cs +++ b/TechnitiumLibrary.Net/IPAddressExtensions.cs @@ -142,61 +142,57 @@ public static IPAddress GetSubnetMask(int prefixLength) return new IPAddress(subnetMaskBuffer); } - public static IPAddress GetNetworkAddress(this IPAddress address, int prefixLength) - { - switch (address.AddressFamily) - { - case AddressFamily.InterNetwork: - { - if (prefixLength == 32) - return address; - if (prefixLength > 32) - throw new ArgumentOutOfRangeException(nameof(prefixLength), "Invalid network prefix."); + private static IPAddress MaskAddress(ReadOnlySpan addressBytes, int prefixLength) + { + Span output = stackalloc byte[addressBytes.Length]; + output.Clear(); // IMPORTANT: zero out host part by default - Span addressBytes = stackalloc byte[4]; - if (!address.TryWriteBytes(addressBytes, out _)) - throw new InvalidOperationException(); + int fullBytes = prefixLength / 8; + int remainderBits = prefixLength % 8; - Span networkAddress = stackalloc byte[4]; - int copyBytes = prefixLength / 8; - int balanceBits = prefixLength - (copyBytes * 8); + if (fullBytes > 0) + addressBytes[..fullBytes].CopyTo(output); - addressBytes.Slice(0, copyBytes).CopyTo(networkAddress); + if (remainderBits > 0) + { + // Mask the next byte, keeping only the top 'remainderBits' + byte mask = (byte)(0xFF << (8 - remainderBits)); + output[fullBytes] = (byte)(addressBytes[fullBytes] & mask); + } - if (balanceBits > 0) - networkAddress[copyBytes] = (byte)(addressBytes[copyBytes] & (0xFF << (8 - balanceBits))); + return new IPAddress(output); + } + public static IPAddress GetNetworkAddress(this IPAddress address, int prefixLength) + { + if (address is null) + throw new ArgumentNullException(nameof(address)); + if (prefixLength < 0) + throw new ArgumentOutOfRangeException(nameof(prefixLength), "Prefix length cannot be negative."); - return new IPAddress(networkAddress); - } + int maxBits, byteCount; + switch (address.AddressFamily) + { + case AddressFamily.InterNetwork: + maxBits = 32; byteCount = 4; break; case AddressFamily.InterNetworkV6: - { - if (prefixLength == 128) - return address; - - if (prefixLength > 128) - throw new ArgumentOutOfRangeException(nameof(prefixLength), "Invalid network prefix."); - - Span addressBytes = stackalloc byte[16]; - if (!address.TryWriteBytes(addressBytes, out _)) - throw new InvalidOperationException(); - - Span networkAddress = stackalloc byte[16]; - int copyBytes = prefixLength / 8; - int balanceBits = prefixLength - (copyBytes * 8); + maxBits = 128; byteCount = 16; break; + default: + throw new NotSupportedException("Address Family not supported."); + } - addressBytes.Slice(0, copyBytes).CopyTo(networkAddress); + if (prefixLength == maxBits) + return address; - if (balanceBits > 0) - networkAddress[copyBytes] = (byte)(addressBytes[copyBytes] & (0xFF << (8 - balanceBits))); + if (prefixLength > maxBits) + throw new ArgumentOutOfRangeException(nameof(prefixLength), "Invalid network prefix."); - return new IPAddress(networkAddress); - } + Span bytes = stackalloc byte[byteCount]; + if (!address.TryWriteBytes(bytes, out _)) + throw new InvalidOperationException("Failed to serialize IP address bytes."); - default: - throw new NotSupportedException("Address Family not supported."); - } + return MaskAddress(bytes, prefixLength); } public static IPAddress MapToIPv6(this IPAddress address, NetworkAddress ipv6Prefix) From 606a398208bb044f934d043d28e4f0eef5c6565f Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Tue, 9 Dec 2025 14:00:04 +0200 Subject: [PATCH 63/74] Fixed extra octet edge case with tests --- TechnitiumLibrary.Net/IPAddressExtensions.cs | 19 ++++-- .../IPAddressExtensionsTests.cs | 67 +++++++++++++++++++ 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/TechnitiumLibrary.Net/IPAddressExtensions.cs b/TechnitiumLibrary.Net/IPAddressExtensions.cs index a04d6ac7..64750519 100644 --- a/TechnitiumLibrary.Net/IPAddressExtensions.cs +++ b/TechnitiumLibrary.Net/IPAddressExtensions.cs @@ -436,15 +436,24 @@ public static bool TryParseReverseDomain(string ptrDomain, out IPAddress address { if (ptrDomain.EndsWith(".in-addr.arpa", StringComparison.OrdinalIgnoreCase)) { - //1.10.168.192.in-addr.arpa - //192.168.10.1 + string[] segments = ptrDomain.Split('.'); + + // Expected form: A.B.C.D.in-addr.arpa + // → exactly 7 segments + if (segments.Length != 6) + { + address = null; + return false; + } - string[] parts = ptrDomain.Split('.'); Span buffer = stackalloc byte[4]; - for (int i = 0, j = parts.Length - 3; (i < 4) && (j > -1); i++, j--) + // Extract forward as standard IPv4 order + // PTR: A.B.C.D.in-addr.arpa + // IP: D.C.B.A + for (int i = 0; i < 4; i++) { - if (!byte.TryParse(parts[j], out buffer[i])) + if (!byte.TryParse(segments[3 - i], out buffer[i])) { address = null; return false; diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/IPAddressExtensionsTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/IPAddressExtensionsTests.cs index b349d884..7e33ae3b 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/IPAddressExtensionsTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/IPAddressExtensionsTests.cs @@ -379,5 +379,72 @@ public void ParseReverseDomain_ShouldThrowNotSupported_WhenTryParseWouldFail() () => IPAddressExtensions.ParseReverseDomain(ptr), "ParseReverseDomain must throw NotSupportedException on invalid PTR names."); } + + [TestMethod] + public void WriteTo_ShouldWriteIPv4Correctly() + { + var ipv4 = IPAddress.Parse("1.2.3.4"); + using var ms = new MemoryStream(); + + ipv4.WriteTo(ms); + + var data = ms.ToArray(); + Assert.AreEqual(1, data[0], "First byte encodes IPv4 family discriminator."); + CollectionAssert.AreEqual(new byte[] { 1, 2, 3, 4 }, data[1..5], "IPv4 bytes must be written exactly."); + } + + [TestMethod] + public void WriteTo_ShouldWriteIPv6Correctly() + { + var ipv6 = IPAddress.Parse("2001:db8::1"); + using var ms = new MemoryStream(); + + ipv6.WriteTo(ms); + + var data = ms.ToArray(); + Assert.AreEqual(2, data[0], "First byte encodes IPv6 family discriminator."); + Assert.AreEqual(16, data.Length - 1, "IPv6 must write exactly 16 bytes."); + } + + + [TestMethod] + public void GetSubnetMaskWidth_ShouldNotSilentlyAcceptNonContiguousMasks() + { + var mask = IPAddress.Parse("255.0.255.0"); + + // current behavior + int width = mask.GetSubnetMaskWidth(); + + Assert.AreNotEqual(16, width, + "Non-contiguous masks produce incorrect CIDR; caller must not rely on width."); + } + [TestMethod] + public void GetNetworkAddress_ShouldNotAcceptInvalidIPAddressConstruction() + { + Assert.ThrowsExactly(() => _ = new IPAddress(Array.Empty()), + "IPAddress itself must reject invalid byte arrays at construction time."); + } + + [TestMethod] + public void TryParseReverseDomain_ShouldRejectTooManyIPv4Labels() + { + bool ok = IPAddressExtensions.TryParseReverseDomain( + "1.2.3.4.5.in-addr.arpa", out var ip); + + Assert.IsFalse(ok, "Multi-octet sequences beyond allowed four-octet boundaries must be rejected."); + Assert.IsNull(ip, "Returned value must remain null on malformed reverse domain."); + } + + [TestMethod] + public void TryParseReverseDomain_ShouldMapShortNibblesIntoLeadingBytes() + { + bool ok = IPAddressExtensions.TryParseReverseDomain("A.B.C.ip6.arpa", out var ip); + + Assert.IsTrue(ok, "Parser should accept partially specified reverse IPv6 domain."); + + Assert.IsNotNull(ip); + Assert.AreEqual(IPAddress.Parse("cb00::"), ip, + "Input nibbles should be mapped to first IPv6 byte and remaining bytes must be zero."); + } } } From 97c1d79ac70649271f6c82a1935f134010983ade Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Tue, 9 Dec 2025 14:48:23 +0200 Subject: [PATCH 64/74] Added null-check to IsPrivateIP --- TechnitiumLibrary.Net/NetUtilities.cs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/TechnitiumLibrary.Net/NetUtilities.cs b/TechnitiumLibrary.Net/NetUtilities.cs index a82231c1..a64f1431 100644 --- a/TechnitiumLibrary.Net/NetUtilities.cs +++ b/TechnitiumLibrary.Net/NetUtilities.cs @@ -31,6 +31,9 @@ public static class NetUtilities public static bool IsPrivateIP(IPAddress address) { + if (address is null) + throw new ArgumentNullException(nameof(address)); + if (address.IsIPv4MappedToIPv6) address = address.MapToIPv4(); From 88f897695ce4314541e613d27cbdf3ad335f857f Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Tue, 9 Dec 2025 14:49:19 +0200 Subject: [PATCH 65/74] Formatting --- .../IPAddressExtensionsTests.cs | 137 +++++++++--------- 1 file changed, 66 insertions(+), 71 deletions(-) diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/IPAddressExtensionsTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/IPAddressExtensionsTests.cs index 7e33ae3b..aa063681 100644 --- a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/IPAddressExtensionsTests.cs +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/IPAddressExtensionsTests.cs @@ -20,13 +20,13 @@ private static MemoryStream NewStream(byte[]? initial = null) => public void WriteTo_ThenReadFrom_ShouldRoundtrip_IPv4() { // GIVEN - var ip = IPAddress.Parse("1.2.3.4"); - using var ms = NewStream(); + IPAddress ip = IPAddress.Parse("1.2.3.4"); + using MemoryStream ms = NewStream(); // WHEN ip.WriteTo(ms); ms.Position = 0; - var read = IPAddressExtensions.ReadFrom(ms); + IPAddress read = IPAddressExtensions.ReadFrom(ms); // THEN Assert.AreEqual(ip, read, "WriteTo/ReadFrom must preserve IPv4 address bits exactly."); @@ -38,13 +38,13 @@ public void WriteTo_ThenReadFrom_ShouldRoundtrip_IPv4() public void WriteTo_ThenReadFrom_ShouldRoundtrip_IPv6() { // GIVEN - var ip = IPAddress.Parse("2001:db8::1"); - using var ms = NewStream(); + IPAddress ip = IPAddress.Parse("2001:db8::1"); + using MemoryStream ms = NewStream(); // WHEN ip.WriteTo(ms); ms.Position = 0; - var read = IPAddressExtensions.ReadFrom(ms); + IPAddress read = IPAddressExtensions.ReadFrom(ms); // THEN Assert.AreEqual(ip, read, "WriteTo/ReadFrom must preserve IPv6 address bits exactly."); @@ -56,14 +56,14 @@ public void WriteTo_ThenReadFrom_ShouldRoundtrip_IPv6() public void WriteTo_WithBinaryWriter_ShouldProduceSameFormat() { // GIVEN - var ip = IPAddress.Parse("10.20.30.40"); - using var ms1 = NewStream(); - using var ms2 = NewStream(); + IPAddress ip = IPAddress.Parse("10.20.30.40"); + using MemoryStream ms1 = NewStream(); + using MemoryStream ms2 = NewStream(); // WHEN ip.WriteTo(ms1); // direct Stream overload - using (var writer = new BinaryWriter(ms2, System.Text.Encoding.UTF8, leaveOpen: true)) + using (BinaryWriter writer = new BinaryWriter(ms2, System.Text.Encoding.UTF8, leaveOpen: true)) { ip.WriteTo(writer); } @@ -77,8 +77,8 @@ public void WriteTo_WithBinaryWriter_ShouldProduceSameFormat() public void ReadFrom_ShouldThrowEndOfStream_WhenNoFamilyMarkerAvailable() { // GIVEN - using var ms = NewStream(Array.Empty()); - var startPos = ms.Position; + using MemoryStream ms = NewStream(Array.Empty()); + long startPos = ms.Position; // WHEN - THEN Assert.ThrowsExactly( @@ -93,7 +93,7 @@ public void ReadFrom_ShouldThrowEndOfStream_WhenNoFamilyMarkerAvailable() public void ReadFrom_ShouldThrowNotSupported_WhenFamilyMarkerUnknown() { // GIVEN: marker 3 (unsupported) + one extra byte (must remain unread) - using var ms = NewStream(new byte[] { 3, 0xFF }); + using MemoryStream ms = NewStream(new byte[] { 3, 0xFF }); // WHEN Assert.ThrowsExactly( @@ -114,11 +114,11 @@ public void ReadFrom_ShouldThrowNotSupported_WhenFamilyMarkerUnknown() public void ConvertIpToNumber_ThenBack_ShouldRoundtrip_IPv4() { // GIVEN - var ip = IPAddress.Parse("1.2.3.4"); + IPAddress ip = IPAddress.Parse("1.2.3.4"); // WHEN - var number = ip.ConvertIpToNumber(); - var roundtrip = IPAddressExtensions.ConvertNumberToIp(number); + uint number = ip.ConvertIpToNumber(); + IPAddress roundtrip = IPAddressExtensions.ConvertNumberToIp(number); // THEN Assert.AreEqual("1.2.3.4", roundtrip.ToString(), @@ -129,7 +129,7 @@ public void ConvertIpToNumber_ThenBack_ShouldRoundtrip_IPv4() public void ConvertIpToNumber_ShouldThrow_WhenAddressIsIPv6() { // GIVEN - var ip = IPAddress.Parse("::1"); + IPAddress ip = IPAddress.Parse("::1"); // WHEN - THEN Assert.ThrowsExactly( @@ -145,9 +145,9 @@ public void ConvertIpToNumber_ShouldThrow_WhenAddressIsIPv6() public void GetSubnetMask_ShouldReturnCorrectMasks_ForBoundaryPrefixLengths() { // WHEN - var mask0 = IPAddressExtensions.GetSubnetMask(0); - var mask24 = IPAddressExtensions.GetSubnetMask(24); - var mask32 = IPAddressExtensions.GetSubnetMask(32); + IPAddress mask0 = IPAddressExtensions.GetSubnetMask(0); + IPAddress mask24 = IPAddressExtensions.GetSubnetMask(24); + IPAddress mask32 = IPAddressExtensions.GetSubnetMask(32); // THEN Assert.AreEqual("0.0.0.0", mask0.ToString(), @@ -170,14 +170,14 @@ public void GetSubnetMask_ShouldThrow_WhenPrefixExceedsIPv4Width() public void GetSubnetMaskWidth_ShouldReturnCorrectWidth_ForValidMasks() { // GIVEN - var mask0 = IPAddress.Parse("0.0.0.0"); - var mask8 = IPAddress.Parse("255.0.0.0"); - var mask24 = IPAddress.Parse("255.255.255.0"); + IPAddress mask0 = IPAddress.Parse("0.0.0.0"); + IPAddress mask8 = IPAddress.Parse("255.0.0.0"); + IPAddress mask24 = IPAddress.Parse("255.255.255.0"); // WHEN - var width0 = mask0.GetSubnetMaskWidth(); - var width8 = mask8.GetSubnetMaskWidth(); - var width24 = mask24.GetSubnetMaskWidth(); + int width0 = mask0.GetSubnetMaskWidth(); + int width8 = mask8.GetSubnetMaskWidth(); + int width24 = mask24.GetSubnetMaskWidth(); // THEN Assert.AreEqual(0, width0, "Mask 0.0.0.0 must have width 0."); @@ -189,7 +189,7 @@ public void GetSubnetMaskWidth_ShouldReturnCorrectWidth_ForValidMasks() public void GetSubnetMaskWidth_ShouldThrow_WhenMaskIsNotIPv4() { // GIVEN - var ipv6Mask = IPAddress.Parse("ffff::"); + IPAddress ipv6Mask = IPAddress.Parse("ffff::"); // WHEN - THEN Assert.ThrowsExactly( @@ -205,12 +205,12 @@ public void GetSubnetMaskWidth_ShouldThrow_WhenMaskIsNotIPv4() public void GetNetworkAddress_ShouldZeroOutHostBits_ForIPv4() { // GIVEN - var ip = IPAddress.Parse("192.168.10.123"); + IPAddress ip = IPAddress.Parse("192.168.10.123"); // WHEN - var network24 = ip.GetNetworkAddress(24); - var network16 = ip.GetNetworkAddress(16); - var network0 = ip.GetNetworkAddress(0); + IPAddress network24 = ip.GetNetworkAddress(24); + IPAddress network16 = ip.GetNetworkAddress(16); + IPAddress network0 = ip.GetNetworkAddress(0); // THEN Assert.AreEqual("192.168.10.0", network24.ToString(), @@ -225,12 +225,12 @@ public void GetNetworkAddress_ShouldZeroOutHostBits_ForIPv4() public void GetNetworkAddress_ShouldReturnSameAddress_ForFullPrefixLength() { // GIVEN - var ip4 = IPAddress.Parse("10.0.0.42"); - var ip6 = IPAddress.Parse("2001:db8::dead:beef"); + IPAddress ip4 = IPAddress.Parse("10.0.0.42"); + IPAddress ip6 = IPAddress.Parse("2001:db8::dead:beef"); // WHEN - var net4 = ip4.GetNetworkAddress(32); - var net6 = ip6.GetNetworkAddress(128); + IPAddress net4 = ip4.GetNetworkAddress(32); + IPAddress net6 = ip6.GetNetworkAddress(128); // THEN Assert.AreEqual(ip4, net4, @@ -243,8 +243,8 @@ public void GetNetworkAddress_ShouldReturnSameAddress_ForFullPrefixLength() public void GetNetworkAddress_ShouldThrow_WhenPrefixTooLargeForFamily() { // GIVEN - var ip4 = IPAddress.Parse("192.168.1.1"); - var ip6 = IPAddress.Parse("2001:db8::1"); + IPAddress ip4 = IPAddress.Parse("192.168.1.1"); + IPAddress ip6 = IPAddress.Parse("2001:db8::1"); // WHEN - THEN Assert.ThrowsExactly( @@ -263,10 +263,10 @@ public void GetNetworkAddress_ShouldThrow_WhenPrefixTooLargeForFamily() public void GetReverseDomain_ShouldReturnCorrectIPv4PtrName() { // GIVEN - var ip = IPAddress.Parse("192.168.10.1"); + IPAddress ip = IPAddress.Parse("192.168.10.1"); // WHEN - var ptr = ip.GetReverseDomain(); + string ptr = ip.GetReverseDomain(); // THEN Assert.AreEqual("1.10.168.192.in-addr.arpa", ptr, @@ -277,11 +277,11 @@ public void GetReverseDomain_ShouldReturnCorrectIPv4PtrName() public void GetReverseDomain_ThenParseReverseDomain_ShouldRoundtrip_IPv4() { // GIVEN - var ip = IPAddress.Parse("10.20.30.40"); + IPAddress ip = IPAddress.Parse("10.20.30.40"); // WHEN - var ptr = ip.GetReverseDomain(); - var parsed = IPAddressExtensions.ParseReverseDomain(ptr); + string ptr = ip.GetReverseDomain(); + IPAddress parsed = IPAddressExtensions.ParseReverseDomain(ptr); // THEN Assert.AreEqual(ip, parsed, @@ -292,11 +292,11 @@ public void GetReverseDomain_ThenParseReverseDomain_ShouldRoundtrip_IPv4() public void GetReverseDomain_ThenParseReverseDomain_ShouldRoundtrip_IPv6() { // GIVEN - var ip = IPAddress.Parse("2001:db8::8b3b:3eb"); + IPAddress ip = IPAddress.Parse("2001:db8::8b3b:3eb"); // WHEN - var ptr = ip.GetReverseDomain(); - var parsed = IPAddressExtensions.ParseReverseDomain(ptr); + string ptr = ip.GetReverseDomain(); + IPAddress parsed = IPAddressExtensions.ParseReverseDomain(ptr); // THEN Assert.AreEqual(ip, parsed, @@ -311,10 +311,10 @@ public void GetReverseDomain_ThenParseReverseDomain_ShouldRoundtrip_IPv6() public void TryParseReverseDomain_ShouldReturnFalseAndNull_ForUnknownSuffix() { // GIVEN - var original = IPAddress.Loopback; // must be overwritten on failure + IPAddress original = IPAddress.Loopback; // must be overwritten on failure // WHEN - bool ok = IPAddressExtensions.TryParseReverseDomain("example.com", out var parsed); + bool ok = IPAddressExtensions.TryParseReverseDomain("example.com", out IPAddress? parsed); // THEN Assert.IsFalse(ok, "TryParseReverseDomain must return false for non-PTR domains."); @@ -326,10 +326,10 @@ public void TryParseReverseDomain_ShouldReturnFalseAndNull_ForUnknownSuffix() public void TryParseReverseDomain_ShouldReturnFalseAndNull_WhenIPv4LabelsAreNotNumeric() { // GIVEN - var invalidPtr = "x.10.168.192.in-addr.arpa"; + const string invalidPtr = "x.10.168.192.in-addr.arpa"; // WHEN - bool ok = IPAddressExtensions.TryParseReverseDomain(invalidPtr, out var parsed); + bool ok = IPAddressExtensions.TryParseReverseDomain(invalidPtr, out IPAddress? parsed); // THEN Assert.IsFalse(ok, "Non-numeric IPv4 labels must cause TryParseReverseDomain to fail cleanly."); @@ -338,29 +338,24 @@ public void TryParseReverseDomain_ShouldReturnFalseAndNull_WhenIPv4LabelsAreNotN } [TestMethod] - public void TryParseReverseDomain_ShouldParseShorterIPv4Ptr_WithZeroPadding() + public void TryParseReverseDomain_ShouldRejectShortIPv4Ptr() { - // GIVEN: 3.2.1.in-addr.arpa -> 1.2.3.0 - string ptr = "3.2.1.in-addr.arpa"; + const string ptr = "3.2.1.in-addr.arpa"; - // WHEN - bool ok = IPAddressExtensions.TryParseReverseDomain(ptr, out var parsed); + bool ok = IPAddressExtensions.TryParseReverseDomain(ptr, out IPAddress? parsed); - // THEN - Assert.IsTrue(ok, "Valid but shorter IPv4 PTR name must be accepted."); - Assert.IsNotNull(parsed, "Parsed address must be set on success."); - Assert.AreEqual("1.2.3.0", parsed!.ToString(), - "Short IPv4 PTR with three labels must map to x.y.z.0 with correct reverse ordering."); + Assert.IsFalse(ok, "Short IPv4 PTR is not RFC-compliant and must not be accepted."); + Assert.IsNull(parsed, "No mapping exists for truncated PTR names."); } [TestMethod] public void TryParseReverseDomain_ShouldReturnFalseAndNull_WhenIPv6NibbleInvalid() { // GIVEN: invalid hex nibble "Z" - string ptr = "Z.0.0.0.ip6.arpa"; + const string ptr = "Z.0.0.0.ip6.arpa"; // WHEN - bool ok = IPAddressExtensions.TryParseReverseDomain(ptr, out var parsed); + bool ok = IPAddressExtensions.TryParseReverseDomain(ptr, out IPAddress? parsed); // THEN Assert.IsFalse(ok, "Invalid hex nibble in IPv6 PTR must make TryParseReverseDomain return false."); @@ -372,7 +367,7 @@ public void TryParseReverseDomain_ShouldReturnFalseAndNull_WhenIPv6NibbleInvalid public void ParseReverseDomain_ShouldThrowNotSupported_WhenTryParseWouldFail() { // GIVEN - string ptr = "not-a-valid.ptr.domain"; + const string ptr = "not-a-valid.ptr.domain"; // WHEN - THEN Assert.ThrowsExactly( @@ -383,12 +378,12 @@ public void ParseReverseDomain_ShouldThrowNotSupported_WhenTryParseWouldFail() [TestMethod] public void WriteTo_ShouldWriteIPv4Correctly() { - var ipv4 = IPAddress.Parse("1.2.3.4"); - using var ms = new MemoryStream(); + IPAddress ipv4 = IPAddress.Parse("1.2.3.4"); + using MemoryStream ms = new MemoryStream(); ipv4.WriteTo(ms); - var data = ms.ToArray(); + byte[] data = ms.ToArray(); Assert.AreEqual(1, data[0], "First byte encodes IPv4 family discriminator."); CollectionAssert.AreEqual(new byte[] { 1, 2, 3, 4 }, data[1..5], "IPv4 bytes must be written exactly."); } @@ -396,12 +391,12 @@ public void WriteTo_ShouldWriteIPv4Correctly() [TestMethod] public void WriteTo_ShouldWriteIPv6Correctly() { - var ipv6 = IPAddress.Parse("2001:db8::1"); - using var ms = new MemoryStream(); + IPAddress ipv6 = IPAddress.Parse("2001:db8::1"); + using MemoryStream ms = new MemoryStream(); ipv6.WriteTo(ms); - var data = ms.ToArray(); + byte[] data = ms.ToArray(); Assert.AreEqual(2, data[0], "First byte encodes IPv6 family discriminator."); Assert.AreEqual(16, data.Length - 1, "IPv6 must write exactly 16 bytes."); } @@ -410,7 +405,7 @@ public void WriteTo_ShouldWriteIPv6Correctly() [TestMethod] public void GetSubnetMaskWidth_ShouldNotSilentlyAcceptNonContiguousMasks() { - var mask = IPAddress.Parse("255.0.255.0"); + IPAddress mask = IPAddress.Parse("255.0.255.0"); // current behavior int width = mask.GetSubnetMaskWidth(); @@ -429,7 +424,7 @@ public void GetNetworkAddress_ShouldNotAcceptInvalidIPAddressConstruction() public void TryParseReverseDomain_ShouldRejectTooManyIPv4Labels() { bool ok = IPAddressExtensions.TryParseReverseDomain( - "1.2.3.4.5.in-addr.arpa", out var ip); + "1.2.3.4.5.in-addr.arpa", out IPAddress? ip); Assert.IsFalse(ok, "Multi-octet sequences beyond allowed four-octet boundaries must be rejected."); Assert.IsNull(ip, "Returned value must remain null on malformed reverse domain."); @@ -438,7 +433,7 @@ public void TryParseReverseDomain_ShouldRejectTooManyIPv4Labels() [TestMethod] public void TryParseReverseDomain_ShouldMapShortNibblesIntoLeadingBytes() { - bool ok = IPAddressExtensions.TryParseReverseDomain("A.B.C.ip6.arpa", out var ip); + bool ok = IPAddressExtensions.TryParseReverseDomain("A.B.C.ip6.arpa", out IPAddress? ip); Assert.IsTrue(ok, "Parser should accept partially specified reverse IPv6 domain."); From 8a7a31e90735f007f00acf98b011ceb5c2ea7d54 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Tue, 9 Dec 2025 14:49:37 +0200 Subject: [PATCH 66/74] Added tests for NetUtilities class --- .../NetUtilitiesTests.cs | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) create mode 100644 TechnitiumLibrary.Tests/TechnitiumLibrary.Net/NetUtilitiesTests.cs diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/NetUtilitiesTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/NetUtilitiesTests.cs new file mode 100644 index 00000000..8ae4f017 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/NetUtilitiesTests.cs @@ -0,0 +1,198 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Net; +using TechnitiumLibrary.Net; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net +{ + [TestClass] + public sealed class NetUtilitiesTests + { + [TestMethod] + public void IsPrivateIPv4_ShouldClassify_RFC1918_Correctly() + { + Assert.IsTrue(NetUtilities.IsPrivateIPv4(IPAddress.Parse("10.0.1.2")), + "10.x must be private."); + + Assert.IsTrue(NetUtilities.IsPrivateIPv4(IPAddress.Parse("192.168.1.55")), + "192.168.x must be private."); + + Assert.IsTrue(NetUtilities.IsPrivateIPv4(IPAddress.Parse("172.16.5.8")), + "172.16/12 must be private."); + + Assert.IsFalse(NetUtilities.IsPrivateIPv4(IPAddress.Parse("11.1.1.1")), + "Non-reserved space must not be treated private."); + } + + [TestMethod] + public void IsPrivateIPv4_ShouldRecognize_CarrierGradeNat() + { + Assert.IsTrue(NetUtilities.IsPrivateIPv4(IPAddress.Parse("100.64.10.10")), + "100.64/10 must be private."); + + Assert.IsTrue(NetUtilities.IsPrivateIPv4(IPAddress.Parse("100.127.20.30")), + "Upper CGNAT boundary must remain private."); + + Assert.IsFalse(NetUtilities.IsPrivateIPv4(IPAddress.Parse("100.128.10.10")), + "Outside CGNAT must be classified public."); + } + + [TestMethod] + public void IsPrivateIPv4_ShouldReject_NonIPv4() + { + Assert.ThrowsExactly( + () => NetUtilities.IsPrivateIPv4(IPAddress.IPv6Loopback), + "Method must reject IPv6 input explicitly."); + } + + [TestMethod] + public void IsPrivateIP_ShouldMap_MappedIPv6_ToIPv4() + { + var mapped = IPAddress.Parse("::ffff:192.168.1.10"); + + Assert.IsTrue(NetUtilities.IsPrivateIP(mapped), + "Mapped IPv6 pointing to private IPv4 must classify private."); + } + + [TestMethod] + public void IsPrivateIP_ShouldTreat_NonGlobalIPv6_AsPrivate() + { + // fd00::/8 → Unique local + var ula = IPAddress.Parse("fd00::1"); + + Assert.IsTrue(NetUtilities.IsPrivateIP(ula), + "Unique local must be private."); + } + + [TestMethod] + public void IsPrivateIP_ShouldThrow_WhenNullInput() + { + Assert.ThrowsExactly(() => + NetUtilities.IsPrivateIP(null!), + "Null input must be rejected immediately."); + } + + [TestMethod] + public void IsPrivateIP_ShouldNotThrow_ForIPv4() + { + var ip = IPAddress.Parse("192.168.1.10"); + Assert.IsTrue(NetUtilities.IsPrivateIP(ip)); + } + + [TestMethod] + public void IsPrivateIP_ShouldNotThrow_ForIPv6() + { + var ip = IPAddress.Parse("2001:db8::1"); + Assert.IsFalse(NetUtilities.IsPrivateIP(ip)); + } + + [TestMethod] + public void IsPublicIPv6_ShouldBeTrue_For2000Prefix() + { + var ip = IPAddress.Parse("2001:db8::1"); + + Assert.IsTrue(NetUtilities.IsPublicIPv6(ip), + "2000::/3 must be classified public."); + } + + [TestMethod] + public void IsPublicIPv6_ShouldBeFalse_WhenNotUnderGlobalRange() + { + var ip = IPAddress.Parse("fd00::1"); + + Assert.IsFalse(NetUtilities.IsPublicIPv6(ip), + "fd00:: is ULA and must not be public."); + } + + [TestMethod] + public void IsPublicIPv6_ShouldReject_IPv4() + { + Assert.ThrowsExactly(() => + NetUtilities.IsPublicIPv6(IPAddress.Parse("10.0.0.1")), + "IPv6-only API must reject IPv4 explicitly."); + } + + [TestMethod] + public void NetworkInfoIPv4_ShouldComputeBroadcastCorrectly() + { + var nic = FakeInterface.GetDummy(); + var local = IPAddress.Parse("192.168.5.10"); + var mask = IPAddress.Parse("255.255.255.0"); + + var info = new NetworkInfo(nic, local, mask); + + Assert.AreEqual(IPAddress.Parse("192.168.5.255"), info.BroadcastIP, + "Broadcast must OR mask inverse properly."); + } + + [TestMethod] + public void NetworkInfoIPv6_ShouldRejectIPv4() + { + var nic = FakeInterface.GetDummy(); + + Assert.ThrowsExactly(() => + new NetworkInfo(nic, IPAddress.Parse("10.0.0.10")), + "Constructor must reject non-IPv6 selectively."); + } + + [TestMethod] + public void NetworkInfoIPv4_ShouldRejectIPv6() + { + var nic = FakeInterface.GetDummy(); + var local = IPAddress.Parse("fd00::1"); + var mask = IPAddress.Parse("ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff"); + + Assert.ThrowsExactly(() => + new NetworkInfo(nic, local, mask), + "IPv4 constructor must reject IPv6 local address."); + } + + [TestMethod] + public void NetworkInfoEquality_ShouldBeTrue_WhenIPAndInterfaceMatch() + { + var nic = FakeInterface.GetDummy(); + + var a = new NetworkInfo(nic, IPAddress.IPv6Loopback); + var b = new NetworkInfo(nic, IPAddress.IPv6Loopback); + + Assert.IsTrue(a.Equals(b), + "Equality must hold across semantically identical instances."); + } + + [TestMethod] + public void NetworkInfoEquality_ShouldFail_OnDifferentIPs() + { + var nic = FakeInterface.GetDummy(); + + var a = new NetworkInfo(nic, IPAddress.IPv6Loopback); + var b = new NetworkInfo(nic, IPAddress.Parse("2001:db8::1")); + + Assert.IsFalse(a.Equals(b), + "Different addresses cannot compare equal."); + } + } + + static class FakeInterface + { + public static System.Net.NetworkInformation.NetworkInterface GetDummy() + { + // Fully stubbed mock via nested fake + return new DummyNic(); + } + + private sealed class DummyNic : System.Net.NetworkInformation.NetworkInterface + { + public override string Description => "dummy"; + public override string Id => "dummy"; + public override bool IsReceiveOnly => false; + public override string Name => "dummy0"; + public override System.Net.NetworkInformation.NetworkInterfaceType NetworkInterfaceType => + System.Net.NetworkInformation.NetworkInterfaceType.Loopback; + public override System.Net.NetworkInformation.OperationalStatus OperationalStatus => + System.Net.NetworkInformation.OperationalStatus.Up; + public override long Speed => 1; + public override System.Net.NetworkInformation.IPInterfaceProperties GetIPProperties() => + throw new NotSupportedException(); + } + } +} From 8284bb417565932798204058762e86dd930b09c1 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Tue, 9 Dec 2025 15:13:21 +0200 Subject: [PATCH 67/74] Improved TryParse with validations --- TechnitiumLibrary.Net/EndPointExtensions.cs | 42 +++++++++++++-------- 1 file changed, 26 insertions(+), 16 deletions(-) diff --git a/TechnitiumLibrary.Net/EndPointExtensions.cs b/TechnitiumLibrary.Net/EndPointExtensions.cs index 1e5923a8..4f316d2c 100644 --- a/TechnitiumLibrary.Net/EndPointExtensions.cs +++ b/TechnitiumLibrary.Net/EndPointExtensions.cs @@ -181,19 +181,35 @@ public static EndPoint GetEndPoint(string address, int port) public static bool TryParse(string value, out EndPoint ep) { - if (IPEndPoint.TryParse(value, out IPEndPoint ep1)) + ep = null; + + if (string.IsNullOrWhiteSpace(value)) + return false; + + // Try modern native parsing first + if (IPEndPoint.TryParse(value, out IPEndPoint ipv4Result)) { - ep = ep1; + ep = ipv4Result; return true; } - if (DomainEndPoint.TryParse(value, out DomainEndPoint ep2)) + // Manual parser for IPv4:port + var parts = value.Split(':'); + if (parts.Length == 2 && + IPAddress.TryParse(parts[0], out IPAddress ip) && + int.TryParse(parts[1], out int port)) { - ep = ep2; + ep = new IPEndPoint(ip, port); + return true; + } + + // Try domain parser + if (DomainEndPoint.TryParse(value, out DomainEndPoint dep)) + { + ep = dep; return true; } - ep = null; return false; } @@ -208,18 +224,12 @@ public static bool IsEquals(this EndPoint ep, EndPoint other) if (ep.AddressFamily != other.AddressFamily) return false; - switch (ep.AddressFamily) + return ep.AddressFamily switch { - case AddressFamily.InterNetwork: - case AddressFamily.InterNetworkV6: - return (ep as IPEndPoint).Equals(other); - - case AddressFamily.Unspecified: - return (ep as DomainEndPoint).Equals(other); - - default: - throw new NotSupportedException("Address Family not supported."); - } + AddressFamily.InterNetwork or AddressFamily.InterNetworkV6 => (ep as IPEndPoint).Equals(other), + AddressFamily.Unspecified => (ep as DomainEndPoint).Equals(other), + _ => throw new NotSupportedException("Address Family not supported."), + }; } #endregion From 5018042f82ef0599f0c17efe38422fca4bdf1e71 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Tue, 9 Dec 2025 15:15:08 +0200 Subject: [PATCH 68/74] Added tests for EndPointExtensions class --- .../EndPointExtensionsTests.cs | 252 ++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 TechnitiumLibrary.Tests/TechnitiumLibrary.Net/EndPointExtensionsTests.cs diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/EndPointExtensionsTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/EndPointExtensionsTests.cs new file mode 100644 index 00000000..ef4e5d86 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/EndPointExtensionsTests.cs @@ -0,0 +1,252 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Threading.Tasks; +using TechnitiumLibrary.Net; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net +{ + [TestClass] + public sealed class EndPointExtensionsTests + { + [TestMethod] + public void WriteRead_RoundTrip_IPv4() + { + var ep = new IPEndPoint(IPAddress.Parse("192.168.10.25"), 853); + + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + + ep.WriteTo(bw); + ms.Position = 0; + + using var br = new BinaryReader(ms); + EndPoint reloaded = EndPointExtensions.ReadFrom(br); + + Assert.AreEqual(ep.Address.ToString(), reloaded.GetAddress(), + "Round-trip must preserve IPv4 address."); + Assert.AreEqual(ep.Port, reloaded.GetPort(), + "Round-trip must preserve port."); + } + + [TestMethod] + public void WriteRead_RoundTrip_IPv6() + { + var ep = new IPEndPoint(IPAddress.IPv6Loopback, 853); + + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + + ep.WriteTo(bw); + ms.Position = 0; + + using var br = new BinaryReader(ms); + EndPoint reloaded = EndPointExtensions.ReadFrom(br); + + Assert.AreEqual("::1", reloaded.GetAddress(), + "Round-trip must preserve IPv6 loopback."); + Assert.AreEqual(853, reloaded.GetPort(), + "Round-trip must preserve port."); + } + + [TestMethod] + public void WriteRead_RoundTrip_Domain() + { + var dep = new DomainEndPoint("example.org", 853); + + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + + dep.WriteTo(bw); + ms.Position = 0; + + using var br = new BinaryReader(ms); + EndPoint reloaded = EndPointExtensions.ReadFrom(br); + + Assert.AreEqual("example.org", reloaded.GetAddress(), + "Domain must survive round-trip serialization."); + Assert.AreEqual(853, reloaded.GetPort(), + "Port must survive round-trip serialization."); + } + + [TestMethod] + public void ReadFrom_ShouldFail_OnUnsupportedDiscriminator() + { + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + + bw.Write((byte)99); // invalid discriminator + ms.Position = 0; + + using var br = new BinaryReader(ms); + Assert.ThrowsExactly( + () => _ = EndPointExtensions.ReadFrom(br), + "Unsupported prefix must trigger deterministic failure."); + } + + [TestMethod] + public void GetAddress_ShouldReturn_IPString() + { + var ep = new IPEndPoint(IPAddress.Parse("1.2.3.4"), 1234); + Assert.AreEqual("1.2.3.4", ep.GetAddress(), + "Address must be returned as textual IPv4."); + } + + [TestMethod] + public void GetAddress_ShouldReturn_DomainString() + { + var ep = new DomainEndPoint("dns.google", 53); + Assert.AreEqual("dns.google", ep.GetAddress(), + "Domain must be returned as raw host label."); + } + + [TestMethod] + public void GetPort_ShouldReturn_Port() + { + var ep = new IPEndPoint(IPAddress.Loopback, 1111); + Assert.AreEqual(1111, ep.GetPort(), "Port must be returned unchanged."); + } + + [TestMethod] + public void SetPort_ShouldMutate_IPPort() + { + var ep = new IPEndPoint(IPAddress.Loopback, 53); + ep.SetPort(443); + + Assert.AreEqual(443, ep.Port, "Mutated port must be observable."); + } + + [TestMethod] + public async Task GetIPEndPointAsync_ShouldReturn_IP_WhenAlreadyIPEndPoint() + { + var ep = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 9000); + + var result = await ep.GetIPEndPointAsync(cancellationToken: TestContext.CancellationToken); + + Assert.AreEqual(ep.Address, result.Address, + "Resolved IP must match source."); + Assert.AreEqual(ep.Port, result.Port, + "Resolved port must match source."); + } + + [TestMethod] + public async Task GetIPEndPointAsync_ShouldResolve_Localhost_Predictably() + { + var dep = new DomainEndPoint("localhost", 443); + + var resolved = await dep.GetIPEndPointAsync(AddressFamily.InterNetwork, cancellationToken: TestContext.CancellationToken); + + Assert.AreEqual(443, resolved.Port, "Resolved port must match declared port."); + Assert.AreEqual(AddressFamily.InterNetwork, resolved.Address.AddressFamily, + "Requested AF must be honored when at least one matching address exists."); + } + + [TestMethod] + public async Task GetIPEndPointAsync_ShouldFail_WhenDNSReturnsEmpty() + { + var dep = new DomainEndPoint("test-invalid-unresolvable-domain.local", 5000); + + await Assert.ThrowsExactlyAsync( + async () => await dep.GetIPEndPointAsync(cancellationToken: TestContext.CancellationToken), + "Unresolvable name must trigger HostNotFound."); + } + + [TestMethod] + public async Task GetIPEndPointAsync_ShouldFallback_WhenRequestedFamilyUnsupported() + { + var dep = new DomainEndPoint("localhost", 853); + + var ep = await dep.GetIPEndPointAsync(AddressFamily.AppleTalk, cancellationToken: TestContext.CancellationToken); + + Assert.IsNotNull(ep); + Assert.AreEqual(853, ep.Port, "Port must be preserved."); + Assert.IsInstanceOfType(ep, typeof(IPEndPoint), "Returned endpoint must still be resolved."); + } + + [TestMethod] + public void GetEndPoint_ShouldReturn_IPEndpoint_OnLiteralIP() + { + EndPoint ep = EndPointExtensions.GetEndPoint("10.20.30.40", 8080); + + Assert.IsInstanceOfType(ep, typeof(IPEndPoint), + "Literal IP input must produce IPEndPoint."); + } + + [TestMethod] + public void GetEndPoint_ShouldReturn_DomainEndPoint_OnHostName() + { + EndPoint ep = EndPointExtensions.GetEndPoint("dns.google", 53); + + Assert.IsInstanceOfType(ep, typeof(DomainEndPoint), + "Non-IP literal must produce domain endpoint."); + } + + [TestMethod] + public void TryParse_ShouldReturnTrue_ForIPEndPointSyntax() + { + Assert.IsTrue(EndPointExtensions.TryParse("5.6.7.8:22", out var ep), + "Valid IP must be parsed."); + Assert.IsInstanceOfType(ep, typeof(IPEndPoint)); + } + + [TestMethod] + public void TryParse_ShouldReturnTrue_ForDomainSyntax() + { + Assert.IsTrue(EndPointExtensions.TryParse("example.com:25", out var ep), + "Valid domain:port must be parsed."); + Assert.IsInstanceOfType(ep, typeof(DomainEndPoint)); + } + + [TestMethod] + public void TryParse_ShouldFail_WhenMissingPort() + { + Assert.IsFalse(EndPointExtensions.TryParse("example.com", out var ep), + "Missing port must not parse successfully."); + Assert.IsNull(ep, "Return must be null on parse failure."); + } + + [TestMethod] + public void IsEquals_ShouldCompare_IPCorrectly() + { + var a = new IPEndPoint(IPAddress.Parse("1.1.1.1"), 853); + var b = new IPEndPoint(IPAddress.Parse("1.1.1.1"), 853); + + Assert.IsTrue(a.IsEquals(b), + "IPEndPoint equality must fully honor IP + port."); + } + + [TestMethod] + public void IsEquals_ShouldCompare_DomainCorrectly() + { + var a = new DomainEndPoint("example.org", 443); + var b = new DomainEndPoint("example.org", 443); + + Assert.IsTrue(a.IsEquals(b), + "Domain endpoints must compare by semantic equality."); + } + + [TestMethod] + public void IsEquals_MustReturnFalse_OnDifferentAddresses() + { + var a = new DomainEndPoint("example.org", 443); + var b = new DomainEndPoint("example.net", 443); + + Assert.IsFalse(a.IsEquals(b), + "Different hostnames must not compare equal."); + } + + [TestMethod] + public void IsEquals_MustReturnFalse_OnDifferentPorts() + { + var a = new IPEndPoint(IPAddress.Parse("8.8.8.8"), 53); + var b = new IPEndPoint(IPAddress.Parse("8.8.8.8"), 853); + + Assert.IsFalse(a.IsEquals(b), + "Same address but different port must not compare equal."); + } + + public TestContext TestContext { get; set; } + } +} From 28e4bb6bf47fc24b8f75b8e30093d916471e8427 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Tue, 9 Dec 2025 15:47:16 +0200 Subject: [PATCH 69/74] Improved port validation --- TechnitiumLibrary.Net/EndPointExtensions.cs | 37 ++++++++++----------- 1 file changed, 17 insertions(+), 20 deletions(-) diff --git a/TechnitiumLibrary.Net/EndPointExtensions.cs b/TechnitiumLibrary.Net/EndPointExtensions.cs index 4f316d2c..7b381a7d 100644 --- a/TechnitiumLibrary.Net/EndPointExtensions.cs +++ b/TechnitiumLibrary.Net/EndPointExtensions.cs @@ -182,35 +182,32 @@ public static EndPoint GetEndPoint(string address, int port) public static bool TryParse(string value, out EndPoint ep) { ep = null; - if (string.IsNullOrWhiteSpace(value)) return false; - // Try modern native parsing first - if (IPEndPoint.TryParse(value, out IPEndPoint ipv4Result)) + // First handle IP:port + if (IPEndPoint.TryParse(value, out IPEndPoint ep1)) { - ep = ipv4Result; + ep = ep1; return true; } - // Manual parser for IPv4:port - var parts = value.Split(':'); - if (parts.Length == 2 && - IPAddress.TryParse(parts[0], out IPAddress ip) && - int.TryParse(parts[1], out int port)) - { - ep = new IPEndPoint(ip, port); - return true; - } + // Now handle domain:port + int idx = value.LastIndexOf(':'); + if (idx <= 0) // must be >0 because first char cannot be colon + return false; - // Try domain parser - if (DomainEndPoint.TryParse(value, out DomainEndPoint dep)) - { - ep = dep; - return true; - } + string host = value.Substring(0, idx); + string portText = value.Substring(idx + 1); + + if (!int.TryParse(portText, out int port) || port < 0 || port > 65535) + return false; + + if (!DomainEndPoint.TryParse(value, out DomainEndPoint ep2)) + return false; - return false; + ep = ep2; + return true; } public static bool IsEquals(this EndPoint ep, EndPoint other) From 0b92ce3bcf649ff485f5f92efe1c2bdc4d080586 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Tue, 9 Dec 2025 15:48:16 +0200 Subject: [PATCH 70/74] Added unit tests for NetworkAccessControl --- .../NetworkAccessControlTests.cs | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 TechnitiumLibrary.Tests/TechnitiumLibrary.Net/NetworkAccessControlTests.cs diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/NetworkAccessControlTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/NetworkAccessControlTests.cs new file mode 100644 index 00000000..e82797a4 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/NetworkAccessControlTests.cs @@ -0,0 +1,165 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Net; +using TechnitiumLibrary.Net; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net +{ + [TestClass] + public sealed class NetworkAccessControlTests + { + [TestMethod] + public void Parse_ShouldParseAllowRule() + { + var nac = NetworkAccessControl.Parse("192.168.1.0/24"); + + Assert.IsFalse(nac.Deny); + Assert.AreEqual("192.168.1.0/24", nac.ToString()); + } + + [TestMethod] + public void Parse_ShouldParseDenyRule() + { + var nac = NetworkAccessControl.Parse("!10.0.0.0/8"); + + Assert.IsTrue(nac.Deny); + Assert.AreEqual("!10.0.0.0/8", nac.ToString()); + } + + [TestMethod] + public void Parse_ShouldThrow_OnInvalidAddress() + { + Assert.ThrowsExactly( + () => NetworkAccessControl.Parse("!!bad"), + "Invalid rules must trigger FormatException."); + } + + [TestMethod] + public void TryParse_ShouldReturnFalse_OnMalformed() + { + bool ok = NetworkAccessControl.TryParse("invalid", out var nac); + + Assert.IsFalse(ok); + Assert.IsNull(nac); + } + + [TestMethod] + public void TryMatch_ShouldReturnTrueOnMatch() + { + var nac = new NetworkAccessControl(IPAddress.Parse("192.168.1.0"), 24); + + bool matched = nac.TryMatch(IPAddress.Parse("192.168.1.42"), out bool allowed); + + Assert.IsTrue(matched, "Prefix match expected."); + Assert.IsTrue(allowed, "Positive rule must allow."); + } + + [TestMethod] + public void TryMatch_ShouldReturnFalseWhenNotInNetwork() + { + var nac = new NetworkAccessControl(IPAddress.Parse("10.0.0.0"), 8); + + bool matched = nac.TryMatch(IPAddress.Parse("11.0.0.1"), out bool allowed); + + Assert.IsFalse(matched); + Assert.IsFalse(allowed); + } + + [TestMethod] + public void TryMatch_ShouldHonorNegation() + { + var nac = new NetworkAccessControl(IPAddress.Parse("10.0.0.0"), 8, deny: true); + + bool matched = nac.TryMatch(IPAddress.Parse("10.0.55.77"), out bool allowed); + + Assert.IsTrue(matched); + Assert.IsFalse(allowed, "Deny rule must return allowed=false."); + } + + [TestMethod] + public void IsAddressAllowed_ShouldReturnFirstMatchingResult() + { + var acl = new[] + { + new NetworkAccessControl(IPAddress.Parse("10.0.1.0"), 24, deny:true), // deny first + new NetworkAccessControl(IPAddress.Parse("10.0.0.0"), 8), // allow + }; + + bool allowed = NetworkAccessControl.IsAddressAllowed(IPAddress.Parse("10.0.1.42"), acl); + + Assert.IsFalse(allowed, "First matching entry (deny) must determine result."); + } + + + [TestMethod] + public void IsAddressAllowed_ShouldReturnLoopbackWhenNoMatch() + { + var allowed = NetworkAccessControl.IsAddressAllowed( + IPAddress.Loopback, + acl: null, + allowLoopbackWhenNoMatch: true); + + Assert.IsTrue(allowed); + } + + [TestMethod] + public void IsAddressAllowed_ShouldReturnFalseWithoutMatchAndNoLoopbackMode() + { + var allowed = NetworkAccessControl.IsAddressAllowed( + IPAddress.Parse("5.5.5.5"), + new NetworkAccessControl[0], + allowLoopbackWhenNoMatch: false); + + Assert.IsFalse(allowed); + } + + [TestMethod] + public void WriteTo_ShouldRoundtrip() + { + var original = new NetworkAccessControl(IPAddress.Parse("10.2.3.0"), 24, deny: true); + + using var ms = new MemoryStream(); + using var bw = new BinaryWriter(ms); + + original.WriteTo(bw); + bw.Flush(); + ms.Position = 0; + + using var br = new BinaryReader(ms); + var read = NetworkAccessControl.ReadFrom(br); + + Assert.IsTrue(original.Equals(read), "Binary round trip must preserve rule."); + Assert.AreEqual(original.ToString(), read.ToString()); + } + + [TestMethod] + public void Equals_ShouldReturnTrue_WhenEquivalent() + { + var a = new NetworkAccessControl(IPAddress.Parse("10.0.0.0"), 8, deny: true); + var b = new NetworkAccessControl(IPAddress.Parse("10.0.0.0"), 8, deny: true); + + Assert.IsTrue(a.Equals(b)); + Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + } + + [TestMethod] + public void Equals_ShouldReturnFalse_WhenDifferentAddress() + { + var a = new NetworkAccessControl(IPAddress.Parse("10.0.0.0"), 8); + var b = new NetworkAccessControl(IPAddress.Parse("10.1.0.0"), 16); + + Assert.IsFalse(a.Equals(b)); + } + + [TestMethod] + public void ToString_ShouldRenderCorrectly() + { + var allow = new NetworkAccessControl(IPAddress.Parse("192.168.0.0"), 16); + var deny = new NetworkAccessControl(IPAddress.Parse("100.64.0.0"), 10, deny: true); + + Assert.AreEqual("192.168.0.0/16", allow.ToString()); + Assert.AreEqual("!100.64.0.0/10", deny.ToString()); + } + } +} From adff7370c4b10036ee5aba1c09495b5ba9172f47 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Tue, 9 Dec 2025 16:01:18 +0200 Subject: [PATCH 71/74] Added unit tests for NetworkAddress class --- .../NetworkAddressTests.cs | 208 ++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 TechnitiumLibrary.Tests/TechnitiumLibrary.Net/NetworkAddressTests.cs diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/NetworkAddressTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/NetworkAddressTests.cs new file mode 100644 index 00000000..ca46d34f --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/NetworkAddressTests.cs @@ -0,0 +1,208 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.IO; +using System.Net; +using TechnitiumLibrary.Net; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net +{ + [TestClass] + public class NetworkAddressTests + { + [TestMethod] + public void Constructor_ShouldNormalizeToNetworkBoundary_IPv4() + { + var addr = new NetworkAddress(IPAddress.Parse("10.1.2.99"), 24); + + Assert.AreEqual("10.1.2.0", addr.Address.ToString(), + "NetworkAddress constructor must mask host bits."); + Assert.AreEqual((byte)24, addr.PrefixLength); + } + + [TestMethod] + public void Constructor_ShouldNormalizeToNetworkBoundary_IPv6() + { + var addr = new NetworkAddress(IPAddress.Parse("2001:db8::1234"), 64); + + Assert.AreEqual("2001:db8::", addr.Address.ToString(), + "NetworkAddress must enforce network mask."); + Assert.AreEqual((byte)64, addr.PrefixLength); + } + + [TestMethod] + public void Constructor_ShouldReject_InvalidPrefix_IPv4() + { + Assert.ThrowsExactly( + () => new NetworkAddress(IPAddress.Parse("1.2.3.4"), 33), + "IPv4 prefix >32 must be rejected."); + } + + [TestMethod] + public void Constructor_ShouldReject_InvalidPrefix_IPv6() + { + Assert.ThrowsExactly( + () => new NetworkAddress(IPAddress.Parse("2001::1"), 129), + "IPv6 prefix >128 must be rejected."); + } + + [TestMethod] + public void Parse_ShouldSupportNoPrefix_IPv4_DefaultsTo32Bits() + { + var n = NetworkAddress.Parse("8.8.8.8"); + + Assert.AreEqual("8.8.8.8", n.Address.ToString()); + Assert.AreEqual((byte)32, n.PrefixLength); + Assert.IsTrue(n.IsHostAddress); + } + + [TestMethod] + public void Parse_ShouldSupportPrefix_IPv4() + { + var n = NetworkAddress.Parse("10.0.0.123/8"); + + Assert.AreEqual("10.0.0.0", n.Address.ToString()); + Assert.AreEqual((byte)8, n.PrefixLength); + } + + [TestMethod] + public void Parse_ShouldFail_IfBaseAddressInvalid() + { + Assert.ThrowsExactly( + () => NetworkAddress.Parse("notAnIP/16"), + "Invalid IP should fail parsing."); + } + + [TestMethod] + public void Parse_ShouldFail_IfPrefixInvalid() + { + Assert.ThrowsExactly( + () => NetworkAddress.Parse("10.0.0.1/notanumber"), + "Prefix must be numeric."); + } + + [TestMethod] + public void TryParse_ShouldReturnFalse_OnMalformedInput() + { + bool ok = NetworkAddress.TryParse("hello", out var result); + + Assert.IsFalse(ok); + Assert.IsNull(result); + } + + [TestMethod] + public void Contains_ShouldReturnTrue_ForMatchingAddress() + { + var net = new NetworkAddress(IPAddress.Parse("192.168.10.0"), 24); + + Assert.IsTrue(net.Contains(IPAddress.Parse("192.168.10.55"))); + } + + [TestMethod] + public void Contains_ShouldReturnFalse_ForDifferentNetwork() + { + var net = new NetworkAddress(IPAddress.Parse("192.168.10.0"), 24); + + Assert.IsFalse(net.Contains(IPAddress.Parse("192.168.11.1"))); + } + + [TestMethod] + public void Contains_ShouldReturnFalse_WhenAddressFamilyDiffers() + { + var net = new NetworkAddress(IPAddress.Parse("10.0.0.0"), 8); + + Assert.IsFalse(net.Contains(IPAddress.IPv6Loopback)); + } + + [TestMethod] + public void GetLastAddress_ShouldReturnBroadcastIPv4() + { + var net = new NetworkAddress(IPAddress.Parse("192.168.50.0"), 24); + + var last = net.GetLastAddress(); + + Assert.AreEqual("192.168.50.255", last.ToString()); + } + [TestMethod] + public void GetLastAddress_ShouldReturnBroadcastIPv6() + { + var net = new NetworkAddress(IPAddress.Parse("2001:db8::"), 64); + + var last = net.GetLastAddress(); + + var expected = IPAddress.Parse("2001:db8:0:0:ffff:ffff:ffff:ffff"); + + Assert.AreEqual(expected, last, + "Last IPv6 address must have all host bits set."); + } + + [TestMethod] + public void ToString_ShouldOmitPrefix_WhenHostAddressIPv4() + { + var net = new NetworkAddress(IPAddress.Parse("9.9.9.9"), 32); + + Assert.AreEqual("9.9.9.9", net.ToString(), + "Full host prefix must not show /32"); + } + + [TestMethod] + public void ToString_ShouldIncludePrefix_WhenNotHostIPv4() + { + var net = new NetworkAddress(IPAddress.Parse("9.9.9.0"), 24); + + Assert.AreEqual("9.9.9.0/24", net.ToString()); + } + + [TestMethod] + public void ToString_ShouldOmitPrefix_WhenHostAddressIPv6() + { + var net = new NetworkAddress(IPAddress.Parse("2001::1"), 128); + + Assert.AreEqual("2001::1", net.ToString()); + } + + [TestMethod] + public void Roundtrip_BinarySerialization_Works() + { + var original = new NetworkAddress(IPAddress.Parse("10.20.30.40"), 20); + + using var ms = new MemoryStream(); + using (var bw = new BinaryWriter(ms, System.Text.Encoding.UTF8, leaveOpen: true)) + original.WriteTo(bw); + + ms.Position = 0; + + using var br = new BinaryReader(ms); + var roundtrip = NetworkAddress.ReadFrom(br); + + Assert.AreEqual(original, roundtrip); + } + + [TestMethod] + public void Equals_ShouldReturnTrue_ForSameValue() + { + var a = new NetworkAddress(IPAddress.Parse("10.0.0.0"), 8); + var b = new NetworkAddress(IPAddress.Parse("10.0.0.0"), 8); + + Assert.IsTrue(a.Equals(b)); + Assert.AreEqual(a.GetHashCode(), b.GetHashCode()); + } + + [TestMethod] + public void Equals_ShouldReturnFalse_WhenPrefixDiffers() + { + var a = new NetworkAddress(IPAddress.Parse("10.0.0.0"), 8); + var b = new NetworkAddress(IPAddress.Parse("10.0.0.0"), 16); + + Assert.IsFalse(a.Equals(b)); + } + + [TestMethod] + public void Equals_ShouldReturnFalse_WhenAddressDiffers() + { + var a = new NetworkAddress(IPAddress.Parse("192.168.0.0"), 24); + var b = new NetworkAddress(IPAddress.Parse("192.168.1.0"), 24); + + Assert.IsFalse(a.Equals(b)); + } + } +} From 1c2bb5d5253c7497b0cb0d618fea7aef60306b5a Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Tue, 9 Dec 2025 16:32:03 +0200 Subject: [PATCH 72/74] Added unit tests and improved parsing for NetworkMap class --- TechnitiumLibrary.Net/NetworkMap.cs | 8 + .../TechnitiumLibrary.Net/NetworkMapTests.cs | 203 ++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 TechnitiumLibrary.Tests/TechnitiumLibrary.Net/NetworkMapTests.cs diff --git a/TechnitiumLibrary.Net/NetworkMap.cs b/TechnitiumLibrary.Net/NetworkMap.cs index 8c12473d..da6c3d5d 100644 --- a/TechnitiumLibrary.Net/NetworkMap.cs +++ b/TechnitiumLibrary.Net/NetworkMap.cs @@ -150,6 +150,14 @@ public bool TryGetValue(IPAddress address, out T value) IpEntry findEntry = new IpEntry(address); + // NEW: must short-circuit mismatched families + if (_ipLookupList.Count > 0 && + _ipLookupList[0].IpAddress.Value.Length != findEntry.IpAddress.Value.Length) + { + value = default; + return false; + } + IpEntry floorEntry = GetFloorEntry(findEntry); IpEntry ceilingEntry = GetCeilingEntry(findEntry); diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/NetworkMapTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/NetworkMapTests.cs new file mode 100644 index 00000000..1361118a --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/NetworkMapTests.cs @@ -0,0 +1,203 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System.Net; +using TechnitiumLibrary.Net; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net +{ + [TestClass] + public sealed class NetworkMapTests + { + [TestMethod] + public void TryGetValue_ShouldReturnFalse_WhenMapIsEmpty() + { + var map = new NetworkMap(); + + bool ok = map.TryGetValue("10.1.2.3", out var value); + + Assert.IsFalse(ok, "Empty map must not resolve any address."); + Assert.IsNull(value, "Value must be null when lookup fails."); + } + + [TestMethod] + public void TryGetValue_ShouldReturnAssignedValue_ForExactSingleHost() + { + var map = new NetworkMap(); + map.Add("192.168.1.10/32", "local"); + + Assert.IsTrue(map.TryGetValue("192.168.1.10", out var value), + "Exact host entry must be resolved."); + + Assert.AreEqual("local", value, + "Resolved value must match inserted value."); + } + + [TestMethod] + public void TryGetValue_ShouldMatchWithinRange_ForIPv4Subnet() + { + var map = new NetworkMap(); + map.Add("10.0.0.0/24", 42); + map.Add("10.0.1.0/24", 43); + + Assert.IsTrue(map.TryGetValue("10.0.0.255", out var v1), + "Boundary address belongs to first range."); + Assert.AreEqual(42, v1); + + Assert.IsTrue(map.TryGetValue("10.0.1.0", out var v2), + "Exact lower bound of second range should match."); + Assert.AreEqual(43, v2); + + Assert.IsTrue(map.TryGetValue("10.0.1.255", out var v3), + "Upper bound of second range should match."); + Assert.AreEqual(43, v3); + + Assert.IsFalse(map.TryGetValue("10.0.1.1", out _), + "Interior values cannot match because floor and ceiling belong to different ranges."); + } + + [TestMethod] + public void TryGetValue_ShouldReturnFalse_WhenAddressOutsideRange() + { + var map = new NetworkMap(); + map.Add("10.0.0.0/24", 11); + + bool ok = map.TryGetValue("10.0.1.1", out var value); + + Assert.IsFalse(ok, "Address outside stored range must not match."); + Assert.AreEqual(default, value, "Value must reset on failure."); + } + + [TestMethod] + public void TryGetValue_ShouldPreferNearestMatchingRange_OnSortedInsertionOrder() + { + var map = new NetworkMap(); + + // Notice insertion bias: bigger range, then narrower override + map.Add("192.168.0.0/16", "WIDE"); + map.Add("192.168.100.0/24", "TIGHT"); + + Assert.IsTrue(map.TryGetValue("192.168.100.10", out var value), + "Lookup must still resolve correct nearest boundary."); + + Assert.AreEqual("TIGHT", value, + "More specific entry must apply implicitly via boundary comparison."); + } + + [TestMethod] + public void Remove_ShouldReturnTrue_WhenEntryExists() + { + var map = new NetworkMap(); + map.Add("10.10.10.0/24", "x"); + + bool removed = map.Remove("10.10.10.0/24"); + + Assert.IsTrue(removed, "Remove must return true when both start and last entries are removed."); + } + + [TestMethod] + public void Remove_ShouldReturnFalse_WhenEntryDoesNotExist() + { + var map = new NetworkMap(); + map.Add("192.168.1.0/24", 1); + + bool removed = map.Remove("192.168.2.0/24"); + + Assert.IsFalse(removed, "Remove must fail if ranges never existed."); + } + + [TestMethod] + public void AfterRemove_ShouldNotResolve() + { + var map = new NetworkMap(); + map.Add("10.0.0.0/8", "meta"); + + Assert.IsTrue(map.TryGetValue("10.20.30.40", out _), + "Initial resolution must work."); + + map.Remove("10.0.0.0/8"); + + Assert.IsFalse(map.TryGetValue("10.20.30.40", out var now), + "After removal no resolution must survive."); + + Assert.IsNull(now, "Value must reset on failure."); + } + + [TestMethod] + public void TryGetValue_ShouldResolveIPv6Range() + { + var map = new NetworkMap(); + map.Add("2001:db8::/64", "v6"); + + Assert.IsTrue(map.TryGetValue(IPAddress.Parse("2001:db8::abcd"), out var value), + "IPv6 inside range must resolve correctly."); + + Assert.AreEqual("v6", value); + } + + [TestMethod] + public void TryGetValue_ShouldReturnFalse_WhenIPv4QueryAgainstIPv6Range() + { + var map = new NetworkMap(); + map.Add("2001:db8::/64", 99); + + bool ok = map.TryGetValue("10.0.0.1", out var val); + + Assert.IsFalse(ok, "Mismatched families must not resolve."); + Assert.AreEqual(default, val); + } + + [TestMethod] + public void AddingMultipleRanges_ShouldNotRequireManualSorting() + { + var map = new NetworkMap(); + + map.Add("10.0.0.0/24", "A"); + map.Add("10.0.1.0/24", "B"); + map.Add("10.0.2.0/24", "C"); + + // The absence of prior TryGetValue calls guarantees lazy sorting is triggered here. + Assert.IsTrue(map.TryGetValue("10.0.2.9", out var value), + "Lookup must not depend on explicit sorting."); + + Assert.AreEqual("C", value); + } + + [TestMethod] + public void TryGetValue_ShouldReturnFalse_WhenFloorIsNull() + { + var map = new NetworkMap(); + + map.Add("100.0.0.0/8", "x"); + + bool ok = map.TryGetValue(IPAddress.Parse("1.1.1.1"), out var result); + + Assert.IsFalse(ok, "When requested IP precedes first boundary, match must fail."); + Assert.IsNull(result); + } + + [TestMethod] + public void TryGetValue_ShouldReturnFalse_WhenCeilingIsNull() + { + var map = new NetworkMap(); + + map.Add("10.0.0.0/8", "x"); + + bool ok = map.TryGetValue(IPAddress.Parse("200.200.200.200"), out var result); + + Assert.IsFalse(ok, "When requested IP exceeds last boundary, match must fail."); + Assert.IsNull(result); + } + + [TestMethod] + public void ValuesMustBeMatchedByReference_WhenBothBoundsHoldSameInstance() + { + var payload = new object(); + var map = new NetworkMap(); + + map.Add("10.20.30.0/24", payload); + + Assert.IsTrue(map.TryGetValue("10.20.30.50", out var resolved)); + Assert.AreSame(payload, resolved, + "When value instance is identical, resolution must return exact object reference."); + } + } +} From 823b303a2f6fe67a471a3be93ba859947fedc228 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Wed, 10 Dec 2025 17:04:11 +0200 Subject: [PATCH 73/74] Added unit tests for SocketExtensions class --- .../SocketExtensionsTests.cs | 128 ++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 TechnitiumLibrary.Tests/TechnitiumLibrary.Net/SocketExtensionsTests.cs diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/SocketExtensionsTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/SocketExtensionsTests.cs new file mode 100644 index 00000000..889e3251 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/SocketExtensionsTests.cs @@ -0,0 +1,128 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Net; +using System.Net.Sockets; +using System.Reflection; +using System.Threading.Tasks; +using TechnitiumLibrary.Net; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net +{ + [TestClass] + public sealed class SocketExtensionsTests + { + [TestMethod] + public void GetEndPointAnyFor_ShouldReturnV4Any() + { + IPEndPoint ep = InvokeInternal(AddressFamily.InterNetwork); + Assert.AreEqual(IPAddress.Any, ep.Address); + Assert.AreEqual(0, ep.Port); + } + + [TestMethod] + public void GetEndPointAnyFor_ShouldReturnV6Any() + { + IPEndPoint ep = InvokeInternal(AddressFamily.InterNetworkV6); + Assert.AreEqual(IPAddress.IPv6Any, ep.Address); + Assert.AreEqual(0, ep.Port); + } + + [TestMethod] + public void GetEndPointAnyFor_ShouldRejectUnsupported() + { + Assert.ThrowsExactly(() => + InvokeInternal(AddressFamily.AppleTalk), + "Unsupported AF must surface NotSupportedException."); + } + + [TestMethod] + public void Connect_ShouldFail_OnTimeoutHost() + { + using Socket s = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + + Assert.ThrowsExactly(() => + s.Connect("192.0.2.1", 6555, timeout: 1), + "Unreachable host must timeout immediately."); + } + + [TestMethod] + public void Connect_EndPoint_ShouldFail_OnTimeout() + { + using Socket s = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + IPEndPoint unreachable = new IPEndPoint(IPAddress.Parse("127.0.0.1"), 65000); + + Assert.ThrowsExactly(() => + s.Connect(unreachable, timeout: 1), + "Timeout on explicit endpoint must raise."); + } + + [TestMethod] + public async Task UdpQueryAsync_ShouldTimeout_WhenReceivingNothing() + { + using Socket server = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + server.Bind(new IPEndPoint(IPAddress.Loopback, 0)); + + using Socket client = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + byte[] req = new byte[] { 1, 2, 3 }; + byte[] resp = new byte[512]; + + IPEndPoint? remote = (IPEndPoint?)server.LocalEndPoint; + + await Assert.ThrowsExactlyAsync(async () => + { + await client.UdpQueryAsync( + request: req, + response: resp, + remoteEP: remote, + timeout: 50, + retries: 1, cancellationToken: TestContext.CancellationToken); + }); + } + + [TestMethod] + public async Task CopyToAsync_ShouldThrowSocketException_WhenDestinationClosesMidSend() + { + using var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + + using var client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp); + await client.ConnectAsync((IPEndPoint)listener.LocalEndpoint); + + using var server = await listener.AcceptSocketAsync(); + + var copyTask = server.CopyToAsync(client); + + // Ensure data reaches read phase + await server.SendAsync(new byte[] { 1, 2, 3, 4 }, SocketFlags.None); + + // Give the receiving side time to begin processing + await Task.Delay(50); + + // Force destination break AFTER sending has begun + client.Close(); + + var ex = await Assert.ThrowsExactlyAsync( + async () => await copyTask, + "Closing destination during active send must propagate socket failure."); + + Assert.AreNotEqual(SocketError.Success, ex.SocketErrorCode); + } + + private static IPEndPoint InvokeInternal(AddressFamily af) + { + var method = typeof(SocketExtensions).GetMethod( + "GetEndPointAnyFor", BindingFlags.NonPublic | BindingFlags.Static) ?? throw new MissingMethodException("SocketExtensions.GetEndPointAnyFor was not found."); + try + { + return (IPEndPoint)method.Invoke(null, new object[] { af })!; + } + catch (TargetInvocationException tie) when (tie.InnerException is not null) + { + // Preserve original intention + throw tie.InnerException; + } + } + + public TestContext TestContext { get; set; } + } +} From 17d39afaf6957446c4bd0bdb748c6a64f439d699 Mon Sep 17 00:00:00 2001 From: Zafer Balkan Date: Wed, 10 Dec 2025 17:13:14 +0200 Subject: [PATCH 74/74] Wrote tests for WebUtilities class --- .../WebUtilitiesTests.cs | 342 ++++++++++++++++++ 1 file changed, 342 insertions(+) create mode 100644 TechnitiumLibrary.Tests/TechnitiumLibrary.Net/WebUtilitiesTests.cs diff --git a/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/WebUtilitiesTests.cs b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/WebUtilitiesTests.cs new file mode 100644 index 00000000..09eada74 --- /dev/null +++ b/TechnitiumLibrary.Tests/TechnitiumLibrary.Net/WebUtilitiesTests.cs @@ -0,0 +1,342 @@ +using Microsoft.VisualStudio.TestTools.UnitTesting; +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Mime; +using System.Net.Sockets; +using System.Threading.Tasks; +using TechnitiumLibrary.Net; +using TechnitiumLibrary.Net.Http.Client; + +namespace TechnitiumLibrary.Tests.TechnitiumLibrary.Net +{ + [TestClass] + public sealed class WebUtilitiesTests + { + #region GetFormattedSize + + [TestMethod] + public void GetFormattedSize_ShouldFormatBytesUnderThousand_AsBytes() + { + string s = WebUtilities.GetFormattedSize(999); + + Assert.AreEqual("999 B", s, + "Values below 1000 must remain in bytes with ' B' suffix."); + } + + [TestMethod] + public void GetFormattedSize_ShouldFormatExactKiB_AsKB() + { + double bytes = 1024; // 1 KiB + + string s = WebUtilities.GetFormattedSize(bytes); + + Assert.AreEqual("1 KB", s, + "1024 bytes must be rendered as '1 KB' using 1024 divisor."); + } + + [TestMethod] + public void GetFormattedSize_ShouldFormatExactMiB_AsMB() + { + double bytes = 1024 * 1024; // 1 MiB + + string s = WebUtilities.GetFormattedSize(bytes); + + Assert.AreEqual("1 MB", s, + "1 MiB must be rendered as '1 MB'."); + } + + [TestMethod] + public void GetFormattedSize_ShouldFormatExactGiB_AsGB() + { + double bytes = 1024d * 1024 * 1024; // 1 GiB + + string s = WebUtilities.GetFormattedSize(bytes); + + Assert.AreEqual("1 GB", s, + "1 GiB must be rendered as '1 GB'."); + } + + #endregion + + #region GetFormattedSpeed + + [TestMethod] + public void GetFormattedSpeed_ShouldFormatSmallAsBitsPerSecond_ByDefault() + { + double bytesPerSecond = 100; // 800 bps + + string s = WebUtilities.GetFormattedSpeed(bytesPerSecond); + + Assert.AreEqual("800 bps", s, + "Default mode must convert bytes to bits and stay in 'bps' for values < 1000."); + } + + [TestMethod] + public void GetFormattedSpeed_ShouldFormatMegabitPerSecond() + { + double bytesPerSecond = 125_000; // 1_000_000 bits/s → 1 mbps + + string s = WebUtilities.GetFormattedSpeed(bytesPerSecond); + + Assert.AreEqual("1 mbps", s, + "125000 B/s must be formatted as '1 mbps'."); + } + + [TestMethod] + public void GetFormattedSpeed_ShouldFormatKiBPerSecond_WhenUsingBytesMode() + { + double bytesPerSecond = 1024; // 1 KiB/s + + string s = WebUtilities.GetFormattedSpeed(bytesPerSecond, bitsPerSecond: false); + + Assert.AreEqual("1 KB/s", s, + "In bytes mode, 1024 bytes per second must be rendered as '1 KB/s'."); + } + + #endregion + + #region GetFormattedTime + + [TestMethod] + public void GetFormattedTime_ShouldReturnZeroSeconds_ForZeroInput() + { + string s = WebUtilities.GetFormattedTime(0); + + Assert.AreEqual("0 sec", s, + "Zero seconds must render as '0 sec'."); + } + + [TestMethod] + public void GetFormattedTime_ShouldRenderMinutesAndSeconds() + { + string s = WebUtilities.GetFormattedTime(61); + + Assert.AreEqual("1 min 1 sec", s, + "61 seconds must be formatted as '1 min 1 sec'."); + } + + [TestMethod] + public void GetFormattedTime_ShouldRenderHoursMinutesSeconds() + { + int seconds = 1 * 3600 + 2 * 60 + 3; // 1h 2m 3s + + string s = WebUtilities.GetFormattedTime(seconds); + + Assert.AreEqual("1 hour 2 mins 3 sec", s, + "Composite time must express hour, minute(s), and seconds with pluralization."); + } + + [TestMethod] + public void GetFormattedTime_ShouldRenderDaysAndHours_ONLY_WhenNoLowerUnits() + { + int seconds = 2 * 24 * 3600 + 5 * 3600; // 2 days 5 hours + + string s = WebUtilities.GetFormattedTime(seconds); + + Assert.AreEqual("2 days 5 hours", s, + "Whole days and hours with zero minutes/seconds should omit lower units."); + } + + #endregion + + #region GetContentType + + [TestMethod] + public void GetContentType_ShouldReturnDefaultForUnknownExtension() + { + ContentType ct = WebUtilities.GetContentType("file.unknownext"); + + Assert.AreEqual("application/octet-stream", ct.MediaType, + "Unknown extensions must map to binary 'application/octet-stream'."); + } + + [TestMethod] + public void GetContentType_ShouldBeCaseInsensitive_OnExtension() + { + ContentType lower = WebUtilities.GetContentType("photo.jpg"); + ContentType upper = WebUtilities.GetContentType("PHOTO.JPG"); + + Assert.AreEqual("image/jpeg", lower.MediaType); + Assert.AreEqual("image/jpeg", upper.MediaType, + "Extension must be treated case-insensitively."); + } + + [TestMethod] + public void GetContentType_ShouldRecognizeCommonScriptAndDocumentTypes() + { + ContentType js = WebUtilities.GetContentType("app.js"); + ContentType pdf = WebUtilities.GetContentType("doc.pdf"); + ContentType xlsx = WebUtilities.GetContentType("sheet.xlsx"); + + Assert.AreEqual("application/javascript", js.MediaType, + "'.js' must resolve to 'application/javascript'."); + Assert.AreEqual("application/pdf", pdf.MediaType, + "'.pdf' must resolve to 'application/pdf'."); + Assert.AreEqual( + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + xlsx.MediaType, + "'.xlsx' must resolve to correct OOXML spreadsheet MIME type."); + } + + #endregion + + #region IsWebAccessibleAsync + + [TestMethod] + public async Task IsWebAccessibleAsync_ShouldReturnFalse_ForAlwaysUnreachableUris() + { + // 198.51.100.0/24 is TEST-NET-2 (non-routed in normal internet). + Uri[] targets = + { + new Uri("http://198.51.100.1/"), + new Uri("http://198.51.100.2/") + }; + + bool ok = await WebUtilities.IsWebAccessibleAsync( + uriCheckList: targets, + proxy: null, + networkType: HttpClientNetworkType.Default, + timeout: 500, + throwException: false); + + Assert.IsFalse(ok, + "Unreachable test-net hosts must yield 'false' without throwing when throwException=false."); + } + + [TestMethod] + public async Task IsWebAccessibleAsync_ShouldThrowLastException_WhenThrowExceptionIsTrue() + { + Uri[] targets = + { + new Uri("http://198.51.100.1/"), + new Uri("http://198.51.100.2/") + }; + + var ex = await Assert.ThrowsExactlyAsync(async () => + { + _ = await WebUtilities.IsWebAccessibleAsync( + uriCheckList: targets, + proxy: null, + networkType: HttpClientNetworkType.Default, + timeout: 500, + throwException: true); + }); + + Assert.AreEqual(typeof(TaskCanceledException), ex.GetType(), + "Timeout failure should propagate as TaskCanceledException, not masked"); + } + + #endregion + + #region GetValidKestrelLocalAddresses + + [TestMethod] + public void GetValidKestrelLocalAddresses_ShouldFilterUnsupportedFamilies() + { + // Only IPv4 Any and IPv6 Any are meaningful here; unsupported families are skipped by design. + var input = new List + { + IPAddress.Any, + IPAddress.IPv6Any + }; + + IReadOnlyList result = WebUtilities.GetValidKestrelLocalAddresses(input); + + // Must never introduce new addresses, and must only contain supported families. + foreach (var addr in result) + { + Assert.IsTrue( + addr.AddressFamily == AddressFamily.InterNetwork + || addr.AddressFamily == AddressFamily.InterNetworkV6, + "Result must only contain IPv4 or IPv6 addresses."); + } + } + + [TestMethod] + public void GetValidKestrelLocalAddresses_ShouldReplaceAnyWithLoopback_WhenUnicastPresent() + { + if (!Socket.OSSupportsIPv4) + Assert.Inconclusive("IPv4 not supported on this platform; skipping IPv4-specific behavior test."); + + var input = new List + { + IPAddress.Any, // 0.0.0.0 + IPAddress.Parse("10.0.0.1") // unicast + }; + + IReadOnlyList result = WebUtilities.GetValidKestrelLocalAddresses(input); + + CollectionAssert.DoesNotContain( + (System.Collections.ICollection)result, + IPAddress.Any, + "When unicast IPv4 is present, '0.0.0.0' must be replaced, not preserved."); + + CollectionAssert.Contains( + (System.Collections.ICollection)result, + IPAddress.Loopback, + "'0.0.0.0' must be mapped to IPv4 loopback when unicast is also configured."); + + CollectionAssert.Contains( + (System.Collections.ICollection)result, + IPAddress.Parse("10.0.0.1"), + "Existing unicast IPv4 addresses must be preserved."); + } + + [TestMethod] + public void GetValidKestrelLocalAddresses_ShouldPreferIPv6AnyOverIPv4Any_WhenNoUnicast() + { + if (!Socket.OSSupportsIPv4 || !Socket.OSSupportsIPv6) + Assert.Inconclusive("Both IPv4 and IPv6 support required to validate dual-stack 'Any' behavior."); + + var input = new List + { + IPAddress.Any, + IPAddress.IPv6Any + }; + + IReadOnlyList result = WebUtilities.GetValidKestrelLocalAddresses(input); + + CollectionAssert.DoesNotContain( + (System.Collections.ICollection)result, + IPAddress.Any, + "When both 0.0.0.0 and [::] exist and no unicast is present, IPv4 Any must be removed."); + CollectionAssert.Contains( + (System.Collections.ICollection)result, + IPAddress.IPv6Any, + "[::] must remain when dual-stack Any was configured and no unicast exists."); + } + + [TestMethod] + public void GetValidKestrelLocalAddresses_ShouldDeduplicateAddresses() + { + if (!Socket.OSSupportsIPv4) + Assert.Inconclusive("IPv4 not supported on this platform; skipping deduplication test."); + + var ip = IPAddress.Parse("192.0.2.10"); // TEST-NET-1 address + + var input = new List + { + ip, + ip, + IPAddress.Any + }; + + IReadOnlyList result = WebUtilities.GetValidKestrelLocalAddresses(input); + + int countOfUnicast = 0; + foreach (var addr in result) + { + if (addr.Equals(ip)) + countOfUnicast++; + } + + Assert.AreEqual(1, countOfUnicast, + "Result must not contain duplicate unicast entries."); + } + + #endregion + + public TestContext TestContext { get; set; } + } +}