From 53d37540a02429b9b387e527b81f45e1f615674d Mon Sep 17 00:00:00 2001 From: amsga <49681949+amsga@users.noreply.github.com> Date: Sun, 8 Nov 2026 14:09:29 +0800 Subject: [PATCH] Added `Uuid.FromGuid(Guid)` to convert from `System.Guid` using its canonical text representation. Changed `Uuid.ToGuid()` to use the canonical text representation explicitly, preserving the UUID value and variant bits. Removed `ToMicrosoftVariant()` and `ToRfc4122Variant(Guid)` because they rewrote UUID variant bits and were not needed for `Guid` conversion. Fixed UUIDv6 random Node IDs to set the required multicast bit. Fixed UUIDv7 Method 1 to seed its counter per timestamp and handle counter exhaustion without wrapping UUID sort order backward. --- CHANGELOG.md | 17 ++++ README.md | 1 - TensionDev.UUID.Tests/UUIDv6Tests.cs | 10 +++ TensionDev.UUID.Tests/UUIDv7Tests.cs | 50 ++++++++++++ TensionDev.UUID.Tests/UuidTests.cs | 39 +++++---- TensionDev.UUID/TensionDev.UUID.csproj | 2 +- TensionDev.UUID/UUIDv6.cs | 2 + TensionDev.UUID/UUIDv7.cs | 107 ++++++++++++++++++------- TensionDev.UUID/Uuid.cs | 32 ++------ 9 files changed, 188 insertions(+), 72 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d2d3c9..4907f53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [v2.6.0] - 2026-11-08 +[v2.6.0](https://github.com/TensionDev/UUID/releases/tag/v2.6.0) + +### Added +- Added `Uuid.FromGuid(Guid)` to convert from `System.Guid` using its canonical text representation. + +### Changed +- Changed `Uuid.ToGuid()` to use the canonical text representation explicitly, preserving the UUID value and variant bits. + +### Removed +- Removed `ToMicrosoftVariant()` and `ToRfc4122Variant(Guid)` because they rewrote UUID variant bits and were not needed for `Guid` conversion. + +### Fixed +- Fixed UUIDv6 random Node IDs to set the required multicast bit. +- Fixed UUIDv7 Method 1 to seed its counter per timestamp and handle counter exhaustion without wrapping UUID sort order backward. + + ## [v2.5.1] - 2026-10-10 [v2.5.1](https://github.com/TensionDev/UUID/releases/tag/v2.5.1) diff --git a/README.md b/README.md index c3fa4d6..ee99487 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,6 @@ This project references the following documents for implementation. - Converters: - `ToGuid()` and `FromGuid(Guid)` - `ToByteArray()` and `FromBytes(byte[])` - - Variant conversions (`ToVariant1`, `ToVariant2`) - Parsing utilities: - `Parse(string)` - `TryParse(string, out Uuid)` diff --git a/TensionDev.UUID.Tests/UUIDv6Tests.cs b/TensionDev.UUID.Tests/UUIDv6Tests.cs index 2e266e1..b875fee 100644 --- a/TensionDev.UUID.Tests/UUIDv6Tests.cs +++ b/TensionDev.UUID.Tests/UUIDv6Tests.cs @@ -26,6 +26,16 @@ public void TestRandomGetNodeID() Assert.NotEqual(nodeID1, nodeID2); } + [Fact] + public void TestRandomNodeIDSetsMulticastBit() + { + byte[] nodeID = UUIDv6.GetNodeID(); + Uuid uuid = UUIDv6.NewUUIDv6(); + + Assert.Equal(0x01, nodeID[0] & 0x01); + Assert.Equal(0x01, uuid.ToByteArray()[10] & 0x01); + } + [Fact] public void TestNewUUIDv6() { diff --git a/TensionDev.UUID.Tests/UUIDv7Tests.cs b/TensionDev.UUID.Tests/UUIDv7Tests.cs index 57b0246..481ae5e 100644 --- a/TensionDev.UUID.Tests/UUIDv7Tests.cs +++ b/TensionDev.UUID.Tests/UUIDv7Tests.cs @@ -129,6 +129,56 @@ public void TestNewUUIDv7() Assert.Equal(expectedUUID, uuid); } + [Fact] + public void TestMethod1CounterIsMonotonicAndHandlesRollover() + { + DateTime timestamp = DateTime.UtcNow.AddYears(1); + Uuid previous = UUIDv7.NewUUIDv7(timestamp, UUIDv7.GenerationMethod.Method1); + DateTime firstTimestamp = UUIDv7.ToDateTime(previous); + Uuid current = UUIDv7.NewUUIDv7(timestamp, UUIDv7.GenerationMethod.Method1); + Assert.True(previous.CompareTo(current) < 0); + + previous = current; + current = UUIDv7.NewUUIDv7(timestamp.AddMilliseconds(1), UUIDv7.GenerationMethod.Method1); + Assert.True(previous.CompareTo(current) < 0); + DateTime advancedTimestamp = UUIDv7.ToDateTime(current); + Assert.Equal(firstTimestamp.AddMilliseconds(1), advancedTimestamp); + + // A backwards requested timestamp must continue from the last emitted timestamp. + previous = current; + current = UUIDv7.NewUUIDv7(timestamp, UUIDv7.GenerationMethod.Method1); + Assert.True(previous.CompareTo(current) < 0); + Assert.Equal(advancedTimestamp, UUIDv7.ToDateTime(current)); + + previous = current; + for (int i = 0; i < 0x1001; ++i) + { + current = UUIDv7.NewUUIDv7(timestamp.AddMilliseconds(1), UUIDv7.GenerationMethod.Method1); + Assert.True(previous.CompareTo(current) < 0); + previous = current; + } + + Assert.True(UUIDv7.ToDateTime(current) > firstTimestamp); + } + + [Fact] + public void TestNewUUIDv7RejectsDateBeforeUnixEpoch() + { + byte[] randomA = [0x00, 0x00]; + byte[] randomB = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]; + + Assert.Throws(() => + UUIDv7.NewUUIDv7(DateTime.UnixEpoch.AddTicks(-1), randomA, randomB)); + } + + [Fact] + public void TestMethod3Generation() + { + Uuid uuid = UUIDv7.NewUUIDv7(DateTime.UtcNow, UUIDv7.GenerationMethod.Method3); + + Assert.True(UUIDv7.IsUUIDv7(uuid)); + } + [Fact] public void TestUUIDVariantField() { diff --git a/TensionDev.UUID.Tests/UuidTests.cs b/TensionDev.UUID.Tests/UuidTests.cs index 857bf5f..7adb463 100644 --- a/TensionDev.UUID.Tests/UuidTests.cs +++ b/TensionDev.UUID.Tests/UuidTests.cs @@ -339,33 +339,44 @@ public void TestToByteArray3() } [Fact] - public void TestToGuid() + public void TestGuidAndUuidUseDifferentByteArrayLayoutsForSameCanonicalValue() { - Guid expected = new Guid("7d444840-9dc0-11d1-b245-5ffdce74fad2"); - Uuid uuid = new Uuid("7d444840-9dc0-11d1-b245-5ffdce74fad2"); + // RFC 9562 Appendix A.3 UUIDv4 test vector; UUID octets use network byte order (Section 4). + const string canonical = "919108f7-52d1-4320-9bac-f847db4148a8"; + Guid guid = new Guid(canonical); + Uuid uuid = new Uuid(canonical); + byte[] expectedUuidBytes = [0x91, 0x91, 0x08, 0xf7, 0x52, 0xd1, 0x43, 0x20, 0x9b, 0xac, 0xf8, 0x47, 0xdb, 0x41, 0x48, 0xa8]; + byte[] expectedGuidBytes = [0xf7, 0x08, 0x91, 0x91, 0xd1, 0x52, 0x20, 0x43, 0x9b, 0xac, 0xf8, 0x47, 0xdb, 0x41, 0x48, 0xa8]; - Guid actual = uuid.ToGuid(); - Assert.Equal(expected, actual); + Assert.Equal(canonical, uuid.ToString("D")); + Assert.Equal(canonical, guid.ToString("D")); + Assert.Equal(expectedUuidBytes, uuid.ToByteArray()); + Assert.Equal(expectedGuidBytes, guid.ToByteArray()); + Assert.NotEqual(uuid.ToByteArray(), guid.ToByteArray()); + Assert.Equal(guid, uuid.ToGuid()); } [Fact] - public void TestToVariant2() + public void TestGuidAndUuidInterpretSameByteSequenceDifferently() { - Guid expected = new Guid("7d444840-9dc0-11d1-d245-5ffdce74fad2"); - Uuid uuid = new Uuid("7d444840-9dc0-11d1-b245-5ffdce74fad2"); + // The RFC 9562 Appendix A.3 test vector in canonical/network byte order. + byte[] bytes = [0x91, 0x91, 0x08, 0xf7, 0x52, 0xd1, 0x43, 0x20, 0x9b, 0xac, 0xf8, 0x47, 0xdb, 0x41, 0x48, 0xa8]; + Uuid uuid = new Uuid(bytes); + Guid guid = new Guid(bytes); - Guid actual = uuid.ToMicrosoftVariant(); - Assert.Equal(expected, actual); + Assert.Equal("919108f7-52d1-4320-9bac-f847db4148a8", uuid.ToString("D")); + Assert.Equal("f7089191-d152-2043-9bac-f847db4148a8", guid.ToString("D")); + Assert.NotEqual(uuid.ToString("D"), guid.ToString("D")); } [Fact] - public void TestToVariant1() + public void TestFromGuidPreservesValue() { - Uuid expected = new Uuid("7d444840-9dc0-11d1-9245-5ffdce74fad2"); Guid guid = new Guid("7d444840-9dc0-11d1-d245-5ffdce74fad2"); - Uuid actual = Uuid.ToRfc4122Variant(guid); - Assert.Equal(expected, actual); + Uuid actual = Uuid.FromGuid(guid); + Assert.Equal(guid.ToString("D"), actual.ToString("D")); + Assert.Equal(guid, actual.ToGuid()); } [Fact] diff --git a/TensionDev.UUID/TensionDev.UUID.csproj b/TensionDev.UUID/TensionDev.UUID.csproj index 3a43d65..fbd0b2b 100644 --- a/TensionDev.UUID/TensionDev.UUID.csproj +++ b/TensionDev.UUID/TensionDev.UUID.csproj @@ -10,7 +10,7 @@ true true TensionDev.UUID - 2.5.1 + 2.6.0 TensionDev amsga TensionDev TensionDev.UUID diff --git a/TensionDev.UUID/UUIDv6.cs b/TensionDev.UUID/UUIDv6.cs index 4490e2e..d64d93c 100644 --- a/TensionDev.UUID/UUIDv6.cs +++ b/TensionDev.UUID/UUIDv6.cs @@ -132,12 +132,14 @@ public static Byte[] GetNodeID() using var randomNumberGenerator = System.Security.Cryptography.RandomNumberGenerator.Create(); Byte[] fakeNode = new Byte[6]; randomNumberGenerator.GetBytes(fakeNode); + fakeNode[0] = (Byte)(fakeNode[0] | 0x01); return fakeNode; #else using (System.Security.Cryptography.RNGCryptoServiceProvider cryptoServiceProvider = new System.Security.Cryptography.RNGCryptoServiceProvider()) { Byte[] fakeNode = new Byte[6]; cryptoServiceProvider.GetBytes(fakeNode); + fakeNode[0] = (Byte)(fakeNode[0] | 0x01); return fakeNode; } #endif diff --git a/TensionDev.UUID/UUIDv7.cs b/TensionDev.UUID/UUIDv7.cs index 37ba971..44de1cd 100644 --- a/TensionDev.UUID/UUIDv7.cs +++ b/TensionDev.UUID/UUIDv7.cs @@ -31,6 +31,8 @@ public static class UUIDv7 #endif private static UInt16 s_counter = 0; + private static Int64 s_method1TimestampMilliseconds = -1; + private static UInt16 s_method1Counter; #if NET9_0_OR_GREATER private static readonly System.Threading.Lock s_counterLock = new System.Threading.Lock(); @@ -86,7 +88,9 @@ public static Uuid NewUUIDv7(DateTime dateTime, GenerationMethod method = Genera return NewUUIDv7(dateTime, GetRandomA(), GetRandomB()); case GenerationMethod.Method1: - return NewUUIDv7(dateTime, GetFixedBitLengthDedicatedCounterA(), GetRandomB()); + Int64 requestedTimestamp = GetUnixTimestampMilliseconds(dateTime); + Int64 effectiveTimestamp = GetMethod1Counter(requestedTimestamp, out Byte[] counterA); + return NewUUIDv7(effectiveTimestamp, counterA, GetRandomB()); case GenerationMethod.Method3: return NewUUIDv7(dateTime, GetIncreasedClockPrecisionA(dateTime), GetRandomB()); @@ -116,20 +120,22 @@ public static Uuid NewUUIDv7(DateTime dateTime, Byte[] randomA, Byte[] randomB) if (randomB.Length < 8) throw new ArgumentException(String.Format("rand_b contains less than 64-bit: {0} bytes", randomB.Length), nameof(randomB)); - TimeSpan timeSince = dateTime.ToUniversalTime() - s_epoch.ToUniversalTime(); - Int64 timeInterval = ((Int64)timeSince.TotalMilliseconds) << 16; + Int64 timeInterval = GetUnixTimestampMilliseconds(dateTime); - Byte[] time = BitConverter.GetBytes(System.Net.IPAddress.HostToNetworkOrder(timeInterval)); + return NewUUIDv7(timeInterval, randomA, randomB); + } - Byte[] hex = new Byte[16]; + private static Uuid NewUUIDv7(Int64 timeInterval, Byte[] randomA, Byte[] randomB) + { - hex[0] = time[0]; - hex[1] = time[1]; - hex[2] = time[2]; - hex[3] = time[3]; + Byte[] hex = new Byte[16]; - hex[4] = time[4]; - hex[5] = time[5]; + hex[0] = (Byte)(timeInterval >> 40); + hex[1] = (Byte)(timeInterval >> 32); + hex[2] = (Byte)(timeInterval >> 24); + hex[3] = (Byte)(timeInterval >> 16); + hex[4] = (Byte)(timeInterval >> 8); + hex[5] = (Byte)timeInterval; hex[6] = (Byte)((randomA[0] & 0x0F) + 0x70); hex[7] = randomA[1]; @@ -171,12 +177,14 @@ public static Byte[] GetRandomA() } /// - /// Initialises the 12-bit rand_a based on Method 1 in Section 6.2 and returns it.
- /// Returns a Fixed-Length Dedicated Counter 16-bit rand_a. + /// Returns the next value from a standalone 12-bit counter helper.
+ /// UUIDv7 Method 1 generation uses a separate timestamp-aware counter with rollover handling. ///
/// A byte-array representing the 16-bit rand_a public static Byte[] GetFixedBitLengthDedicatedCounterA() { + // Retained as a low-level wrapping 12-bit counter helper for compatibility. + // UUIDv7 Method1 uses a timestamp-aware counter with rollover handling below. lock (s_counterLock) { Int16 value = Convert.ToInt16(s_counter); @@ -253,28 +261,69 @@ public static DateTime ToDateTime(Uuid uuid) throw new ArgumentException(String.Format("{0} is not a Version 7 UUID.", uuid), nameof(uuid)); long timeInterval = GetTimeInterval(uuid); - TimeSpan timeSpan = TimeSpan.FromMilliseconds(timeInterval); - - return s_epoch.ToUniversalTime() + timeSpan; + return s_epoch.AddTicks(timeInterval * TimeSpan.TicksPerMillisecond); } private static Int64 GetTimeInterval(Uuid uuid) { Byte[] hex = uuid.ToByteArray(); - Byte[] time = new Byte[8]; - - time[0] = hex[0]; - time[1] = hex[1]; - time[2] = hex[2]; - time[3] = hex[3]; - time[4] = hex[4]; - time[5] = hex[5]; - time[6] = hex[6]; - time[7] = hex[7]; - - Int64 timeInterval = System.Net.IPAddress.NetworkToHostOrder(BitConverter.ToInt64(time, 0)); - timeInterval >>= 16; + + return ((Int64)hex[0] << 40) + | ((Int64)hex[1] << 32) + | ((Int64)hex[2] << 24) + | ((Int64)hex[3] << 16) + | ((Int64)hex[4] << 8) + | hex[5]; + } + + private static Int64 GetUnixTimestampMilliseconds(DateTime dateTime) + { + TimeSpan timeSince = dateTime.ToUniversalTime() - s_epoch; + if (timeSince.Ticks < 0) + throw new ArgumentOutOfRangeException(nameof(dateTime), "The date-time is outside the UUIDv7 48-bit Unix timestamp range."); + + Int64 timeInterval = timeSince.Ticks / TimeSpan.TicksPerMillisecond; + + if (timeInterval < 0 || timeInterval > 0xFFFFFFFFFFFFL) + throw new ArgumentOutOfRangeException(nameof(dateTime), "The date-time is outside the UUIDv7 48-bit Unix timestamp range."); + return timeInterval; } + + private static Int64 GetMethod1Counter(Int64 requestedTimestamp, out Byte[] counterA) + { + lock (s_counterLock) + { + if (s_method1TimestampMilliseconds < 0 || requestedTimestamp > s_method1TimestampMilliseconds) + { + s_method1TimestampMilliseconds = requestedTimestamp; + s_method1Counter = GetRandomCounterA(); + } + else if (s_method1Counter < 0x0FFF) + { + ++s_method1Counter; + } + else + { + // Advance the logical timestamp rather than wrapping the counter and sorting backward. + ++s_method1TimestampMilliseconds; + s_method1Counter = GetRandomCounterA(); + } + + counterA = new Byte[] + { + (Byte)(s_method1Counter >> 8), + (Byte)s_method1Counter + }; + + return s_method1TimestampMilliseconds; + } + } + + private static UInt16 GetRandomCounterA() + { + Byte[] randomA = GetRandomA(); + return (UInt16)(((randomA[0] & 0x0F) << 8) | randomA[1]); + } } } diff --git a/TensionDev.UUID/Uuid.cs b/TensionDev.UUID/Uuid.cs index c192797..1a69704 100644 --- a/TensionDev.UUID/Uuid.cs +++ b/TensionDev.UUID/Uuid.cs @@ -361,39 +361,17 @@ public byte[] ToByteArray() /// A System.Guid object. public Guid ToGuid() { - return new Guid(ToString()); + return new Guid(ToString("D")); } /// - /// Return the Microsoft Variant version in System.Guid. - /// - /// A System.Guid object. - public Guid ToMicrosoftVariant() - { - byte newClockSeq = (byte)(_clock_seq_hi_and_reserved & 0x1F); - newClockSeq = (byte)(newClockSeq | 0xC0); - byte[] array = ToByteArray(); - array[8] = newClockSeq; - Uuid variant2 = new Uuid(array); - - return variant2.ToGuid(); - } - - /// - /// Returns the RFC 4122 Variant version in TensionDev.UUID.Uuid. + /// Creates a Uuid from the canonical text representation of a System.Guid. /// /// The System.Guid object to convert. - /// A TensionDev.UUID.Uuid object. - public static Uuid ToRfc4122Variant(Guid guid) + /// A Uuid object with the same value as the specified Guid. + public static Uuid FromGuid(Guid guid) { - Uuid variant2 = new Uuid(guid.ToString()); - byte newClockSeq = (byte)(variant2._clock_seq_hi_and_reserved & 0x3F); - newClockSeq = (byte)(newClockSeq | 0x80); - byte[] array = variant2.ToByteArray(); - array[8] = newClockSeq; - Uuid variant1 = new Uuid(array); - - return variant1; + return new Uuid(guid.ToString("D")); } ///