diff --git a/Gemfile.lock b/Gemfile.lock index cd1d894a2a2dabf31d6e189dd37467c6078a5f37..2ebf4e3439c79e5c51138c90379c7a7039aa388f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -111,7 +111,7 @@ PATH PATH remote: gems/gitlab-utils specs: - gitlab-utils (0.1.0) + gitlab-utils (0.2.0) actionview (>= 6.1.7.2) activesupport (>= 6.1.7.2) addressable (~> 2.8) diff --git a/Gemfile.next.lock b/Gemfile.next.lock index 01d0cf73247b5176e882f02cf2013ad0aa0f0259..27ae2209bade64dbda94671fbd5bb2034909ca77 100644 --- a/Gemfile.next.lock +++ b/Gemfile.next.lock @@ -111,7 +111,7 @@ PATH PATH remote: gems/gitlab-utils specs: - gitlab-utils (0.1.0) + gitlab-utils (0.2.0) actionview (>= 6.1.7.2) activesupport (>= 6.1.7.2) addressable (~> 2.8) diff --git a/gems/gitlab-database-load_balancing/Gemfile.lock b/gems/gitlab-database-load_balancing/Gemfile.lock index f9ae8f9022d1c5fc150598294e950679cb035a59..affdc63fe4f5347fb8d3ac926e73bc579c436532 100644 --- a/gems/gitlab-database-load_balancing/Gemfile.lock +++ b/gems/gitlab-database-load_balancing/Gemfile.lock @@ -22,7 +22,7 @@ PATH PATH remote: ../gitlab-utils specs: - gitlab-utils (0.1.0) + gitlab-utils (0.2.0) actionview (>= 6.1.7.2) activesupport (>= 6.1.7.2) addressable (~> 2.8) @@ -406,7 +406,7 @@ CHECKSUMS gitlab-rspec (0.1.0) gitlab-safe_request_store (0.1.0) gitlab-styles (13.1.0) sha256=46c7c5729616355868b7b40a4ffcd052b36346076042abe8cafaee1688cbf2c1 - gitlab-utils (0.1.0) + gitlab-utils (0.2.0) globalid (1.2.1) sha256=70bf76711871f843dbba72beb8613229a49429d1866828476f9c9d6ccc327ce9 i18n (1.12.0) sha256=91e3cc1b97616d308707eedee413d82ee021d751c918661fb82152793e64aced io-console (0.8.1) sha256=1e15440a6b2f67b6ea496df7c474ed62c860ad11237f29b3bd187f054b925fcb diff --git a/gems/gitlab-http/Gemfile.lock b/gems/gitlab-http/Gemfile.lock index d850b203a5c0d9e8a9fe9aa9ddc067e6dda56350..e6e72e9bf4bc01c32b95403641e9be8abdd79ca2 100644 --- a/gems/gitlab-http/Gemfile.lock +++ b/gems/gitlab-http/Gemfile.lock @@ -9,7 +9,7 @@ PATH PATH remote: ../gitlab-utils specs: - gitlab-utils (0.1.0) + gitlab-utils (0.2.0) actionview (>= 6.1.7.2) activesupport (>= 6.1.7.2) addressable (~> 2.8) @@ -259,7 +259,7 @@ CHECKSUMS gitlab-http (0.1.0) gitlab-rspec (0.1.0) gitlab-styles (13.0.1) sha256=bf1840fe97b215ab76fe1f1a83af0aee30d33ded905415918462b832004b68bd - gitlab-utils (0.1.0) + gitlab-utils (0.2.0) hashdiff (1.0.1) sha256=2cd4d04f5080314ecc8403c4e2e00dbaa282dff395e2d031bc16c8d501bdd6db httparty (0.22.0) sha256=78652a5c9471cf0093d3b2083c2295c9c8f12b44c65112f1846af2b71430fa6c i18n (1.14.1) sha256=9d03698903547c060928e70a9bc8b6b87fda674453cda918fc7ab80235ae4a61 diff --git a/gems/gitlab-utils/Gemfile.lock b/gems/gitlab-utils/Gemfile.lock index 48464bd1a383c8dc82d862ba9b9de7e8a365ca36..f0d6445bdd4b9aae72a053576bebf1ec928db872 100644 --- a/gems/gitlab-utils/Gemfile.lock +++ b/gems/gitlab-utils/Gemfile.lock @@ -9,7 +9,7 @@ PATH PATH remote: . specs: - gitlab-utils (0.1.0) + gitlab-utils (0.2.0) actionview (>= 6.1.7.2) activesupport (>= 6.1.7.2) addressable (~> 2.8) @@ -250,7 +250,7 @@ CHECKSUMS factory_bot_rails (6.2.0) sha256=278b969666b078e76e1c972c501da9b1fac15e5b0ff328cc7ce400366164d0a1 gitlab-rspec (0.1.0) gitlab-styles (10.1.0) sha256=f42745f5397d042fe24cf2d0eb56c995b37f9f43d8fb79b834d197a1cafdc84a - gitlab-utils (0.1.0) + gitlab-utils (0.2.0) i18n (1.14.1) sha256=9d03698903547c060928e70a9bc8b6b87fda674453cda918fc7ab80235ae4a61 json (2.6.3) sha256=86aaea16adf346a2b22743d88f8dcceeb1038843989ab93cda44b5176c845459 loofah (2.21.3) sha256=43d21a8bb96c380199a8f66e0298649eaa7362fcd32f3a6114f39775e524e4dc diff --git a/gems/gitlab-utils/README.md b/gems/gitlab-utils/README.md index f7c7d83888bb70103710793f97d011cee8061484..4e3d5ab3a26d278f809589f427505a53cb9af22b 100644 --- a/gems/gitlab-utils/README.md +++ b/gems/gitlab-utils/README.md @@ -6,3 +6,7 @@ or business logic and provides a generic functions like: - safe parsing of YAML - version comparisions - `strong_memoize` +- uuid_v7 till we have min ruby >= 3.3.0 +- **Security Note**: UUID v7 provides 74 bits of entropy (vs UUID v4's 122 bits) and exposes +creation timestamps. Do not use for authentication tokens or security-critical identifiers. +Use for database primary keys and non-sensitive identifiers only. diff --git a/gems/gitlab-utils/lib/gitlab/utils.rb b/gems/gitlab-utils/lib/gitlab/utils.rb index b850a130cfe130a044a9d2a345bc739b24455083..beca0a509309e5bedecd0d7a3a0fa488ff9d8a41 100644 --- a/gems/gitlab-utils/lib/gitlab/utils.rb +++ b/gems/gitlab-utils/lib/gitlab/utils.rb @@ -3,6 +3,7 @@ require "addressable/uri" require "active_support/all" require "action_view" +require "securerandom" module Gitlab module Utils @@ -345,5 +346,42 @@ def deep_sort_hashes(item) end end alias_method :deep_sort_hash, :deep_sort_hashes + + # Generate a random v7 UUID (Universally Unique IDentifier). + # Ported from ruby 3.3.0's https://github.com/ruby/securerandom/commit/34ed1a2ec35dc8f00ff69665b373cef7484c937f + # TODO remove when we support min ruby 3.3.0 + def uuid_v7(extra_timestamp_bits: 0) # rubocop:disable Metrics/AbcSize -- Ported as is + case (extra_timestamp_bits = Integer(extra_timestamp_bits)) + when 0 # min timestamp precision + ms = Process.clock_gettime(Process::CLOCK_REALTIME, :millisecond) + rand = SecureRandom.random_bytes(10) + rand.setbyte(0, (rand.getbyte(0) & 0x0f) | 0x70) # version + rand.setbyte(2, (rand.getbyte(2) & 0x3f) | 0x80) # variant + format("%08x-%04x-%s", (ms & 0x0000_ffff_ffff_0000) >> 16, (ms & 0x0000_0000_0000_ffff), + rand.unpack("H4H4H12").join("-")) + + when 12 # max timestamp precision + ms, ns = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond) + .divmod(1_000_000) + extra_bits = ns * 4096 / 1_000_000 + rand = SecureRandom.random_bytes(8) + rand.setbyte(0, (rand.getbyte(0) & 0x3f) | 0x80) # variant + format("%08x-%04x-7%03x-%s", (ms & 0x0000_ffff_ffff_0000) >> 16, (ms & 0x0000_0000_0000_ffff), extra_bits, + rand.unpack("H4H12").join("-")) + + when (0..12) # the generic version is slower than the special cases above + rand_a, rand_b1, rand_b2, rand_b3 = SecureRandom.random_bytes(10).unpack("nnnN") + rand_mask_bits = 12 - extra_timestamp_bits + ms, ns = Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond) + .divmod(1_000_000) + format("%08x-%04x-%04x-%04x-%04x%08x", + (ms & 0x0000_ffff_ffff_0000) >> 16, (ms & 0x0000_0000_0000_ffff), 0x7000 | + ((ns * (1 << extra_timestamp_bits) / 1_000_000) << rand_mask_bits) | + (rand_a & ((1 << rand_mask_bits) - 1)), 0x8000 | (rand_b1 & 0x3fff), rand_b2, rand_b3) + + else + raise ArgumentError, "extra_timestamp_bits must be in 0..12, got: #{extra_timestamp_bits}" + end + end end end diff --git a/gems/gitlab-utils/lib/gitlab/utils/version.rb b/gems/gitlab-utils/lib/gitlab/utils/version.rb index a9afe5bf8455d3f105da2acd21412efdc925e0c4..6d553497b3cce77bbb5a07c1b3903d3aacdd54ae 100644 --- a/gems/gitlab-utils/lib/gitlab/utils/version.rb +++ b/gems/gitlab-utils/lib/gitlab/utils/version.rb @@ -3,7 +3,7 @@ module Gitlab module Utils module Version - VERSION = "0.1.0" + VERSION = "0.2.0" end end end diff --git a/gems/gitlab-utils/spec/gitlab/utils_spec.rb b/gems/gitlab-utils/spec/gitlab/utils_spec.rb index 26ed5df0bf66d8203184b44248700dcf8b6201f0..7dcde12a8f08f3d8275d201fe6397a9c225e0a63 100644 --- a/gems/gitlab-utils/spec/gitlab/utils_spec.rb +++ b/gems/gitlab-utils/spec/gitlab/utils_spec.rb @@ -807,4 +807,103 @@ end end end + + describe '.uuid_v7' do + # Helper method to get current time with precision based on extra_timestamp_bits + def current_uuid7_time(extra_timestamp_bits: 0) + denominator = (1 << extra_timestamp_bits).to_r + Process.clock_gettime(Process::CLOCK_REALTIME, :nanosecond) + .then { |ns| ((ns / 1_000_000r) * denominator).floor / denominator } + .then { |ms| Time.at(ms / 1000r, in: "+00:00") } + end + + # Helper method to extract timestamp from UUID v7 + def get_uuid7_time(uuid, extra_timestamp_bits: 0) + denominator = (1 << extra_timestamp_bits) * 1000r + extra_chars = extra_timestamp_bits / 4 + last_char_bits = extra_timestamp_bits % 4 + extra_chars += 1 if last_char_bits != 0 + timestamp_re = /\A(\h{8})-(\h{4})-7(\h{#{extra_chars}})/ + timestamp_chars = uuid.match(timestamp_re).captures.join + timestamp = timestamp_chars.to_i(16) + timestamp >>= 4 - last_char_bits unless last_char_bits == 0 + timestamp /= denominator + Time.at(timestamp, in: "+00:00") + end + + context 'without extra_timestamp_bits' do + it 'generates a valid UUID v7 with correct format' do + uuid = described_class.uuid_v7 + expect(uuid).to match(/\A\h{8}-\h{4}-7\h{3}-[89ab]\h{3}-\h{12}\z/) + end + + it 'generates UUID with timestamp within expected range' do + t1 = current_uuid7_time + uuid = described_class.uuid_v7 + t3 = current_uuid7_time + + t2 = get_uuid7_time(uuid) + + expect(t1).to be <= t2 + expect(t2).to be <= t3 + end + + it 'generates unique UUIDs' do + uuid1 = described_class.uuid_v7 + uuid2 = described_class.uuid_v7 + + expect(uuid1).not_to eq(uuid2) + end + end + + context 'with extra_timestamp_bits' do + where(:extra_timestamp_bits) do + (0..12).to_a + end + + with_them do + it 'generates valid UUID v7 with correct format' do + uuid = described_class.uuid_v7(extra_timestamp_bits: extra_timestamp_bits) + expect(uuid).to match(/\A\h{8}-\h{4}-7\h{3}-[89ab]\h{3}-\h{12}\z/) + end + + it 'generates UUID with timestamp within expected range' do + t1 = current_uuid7_time(extra_timestamp_bits: extra_timestamp_bits) + uuid = described_class.uuid_v7(extra_timestamp_bits: extra_timestamp_bits) + t3 = current_uuid7_time(extra_timestamp_bits: extra_timestamp_bits) + + t2 = get_uuid7_time(uuid, extra_timestamp_bits: extra_timestamp_bits) + + expect(t1).to be <= t2 + expect(t2).to be <= t3 + end + end + end + + context 'with invalid extra_timestamp_bits' do + it 'raises ArgumentError for negative values' do + expect { described_class.uuid_v7(extra_timestamp_bits: -1) } + .to raise_error(ArgumentError, 'extra_timestamp_bits must be in 0..12, got: -1') + end + + it 'raises ArgumentError for values greater than 12' do + expect { described_class.uuid_v7(extra_timestamp_bits: 13) } + .to raise_error(ArgumentError, 'extra_timestamp_bits must be in 0..12, got: 13') + end + + it 'raises ArgumentError for non-integer values' do + expect { described_class.uuid_v7(extra_timestamp_bits: 'invalid') } + .to raise_error(ArgumentError) + end + end + + context 'with monotonicity' do + it 'maintains order for UUIDs with maximum extra_timestamp_bits' do + uuids = Array.new(5) { described_class.uuid_v7(extra_timestamp_bits: 12) } + timestamps = uuids.map { |uuid| get_uuid7_time(uuid, extra_timestamp_bits: 12) } + + expect(timestamps).to eq(timestamps.sort) + end + end + end end diff --git a/qa/Gemfile.lock b/qa/Gemfile.lock index 144a9ea4e5c49c1eebc7581a8c57e1938358504b..25029887194dc3fc0442a7063a55ca96a3512230 100644 --- a/qa/Gemfile.lock +++ b/qa/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: ../gems/gitlab-utils specs: - gitlab-utils (0.1.0) + gitlab-utils (0.2.0) actionview (>= 6.1.7.2) activesupport (>= 6.1.7.2) addressable (~> 2.8)