diff --git a/spec/std/random_spec.cr b/spec/std/random_spec.cr index 3895aff8903f..b7bc761002e6 100644 --- a/spec/std/random_spec.cr +++ b/spec/std/random_spec.cr @@ -260,11 +260,4 @@ describe "Random" do hex.should eq("9fd857f462831002ffffffffffffffff0000000000000000e88d3a30db4e730021b8a5e33b020000362f518e0700000062da") end end - - describe "uuid" do - it "gets uuid" do - uuid = TestRNG.new(RNG_DATA_8).uuid - uuid.should eq("ea990000-7f80-4fff-aa99-00007f80ffff") - end - end end diff --git a/spec/std/uuid_spec.cr b/spec/std/uuid_spec.cr new file mode 100644 index 000000000000..5b6bf7e02029 --- /dev/null +++ b/spec/std/uuid_spec.cr @@ -0,0 +1,122 @@ +require "spec" +require "uuid" + +describe "UUID" do + describe "random initialize" do + it "works with no options" do + subject = UUID.random + subject.variant.should eq UUID::Variant::RFC4122 + subject.version.should eq UUID::Version::V4 + end + + it "works with variant" do + subject = UUID.random(variant: UUID::Variant::NCS) + subject.variant.should eq UUID::Variant::NCS + subject.version.should eq UUID::Version::V4 + end + + it "works with version" do + subject = UUID.random(version: UUID::Version::V3) + subject.variant.should eq UUID::Variant::RFC4122 + subject.version.should eq UUID::Version::V3 + end + end + + describe "initialize from static array" do + it "works with static array only" do + subject = UUID.new(StaticArray(UInt8, 16).new(0_u8)) + subject.to_s.should eq "00000000-0000-0000-0000-000000000000" + end + + it "works with static array and variant" do + subject = UUID.new(StaticArray(UInt8, 16).new(0_u8), variant: UUID::Variant::RFC4122) + subject.to_s.should eq "00000000-0000-0000-8000-000000000000" + subject.variant.should eq UUID::Variant::RFC4122 + end + + it "works with static array and version" do + subject = UUID.new(StaticArray(UInt8, 16).new(0_u8), version: UUID::Version::V3) + subject.to_s.should eq "00000000-0000-3000-0000-000000000000" + subject.version.should eq UUID::Version::V3 + end + + it "works with static array, variant and version" do + subject = UUID.new(StaticArray(UInt8, 16).new(0_u8), variant: UUID::Variant::Microsoft, version: UUID::Version::V3) + subject.to_s.should eq "00000000-0000-3000-c000-000000000000" + subject.variant.should eq UUID::Variant::Microsoft + subject.version.should eq UUID::Version::V3 + end + end + + it "initializes with slice" do + subject = UUID.new(Slice(UInt8).new(16, 0_u8), variant: UUID::Variant::RFC4122, version: UUID::Version::V4) + subject.to_s.should eq "00000000-0000-4000-8000-000000000000" + subject.variant.should eq UUID::Variant::RFC4122 + subject.version.should eq UUID::Version::V4 + end + + describe "initialize with String" do + it "works with static array only" do + subject = UUID.new("00000000-0000-0000-0000-000000000000") + subject.to_s.should eq "00000000-0000-0000-0000-000000000000" + end + + it "works with static array and variant" do + subject = UUID.new("00000000-0000-0000-0000-000000000000", variant: UUID::Variant::Future) + subject.to_s.should eq "00000000-0000-0000-e000-000000000000" + subject.variant.should eq UUID::Variant::Future + end + + it "works with static array and version" do + subject = UUID.new("00000000-0000-0000-0000-000000000000", version: UUID::Version::V5) + subject.to_s.should eq "00000000-0000-5000-0000-000000000000" + subject.version.should eq UUID::Version::V5 + end + + it "can be built from strings" do + UUID.new("c20335c3-7f46-4126-aae9-f665434ad12b").to_s.should eq("c20335c3-7f46-4126-aae9-f665434ad12b") + UUID.new("c20335c37f464126aae9f665434ad12b").to_s.should eq("c20335c3-7f46-4126-aae9-f665434ad12b") + UUID.new("C20335C3-7F46-4126-AAE9-F665434AD12B").to_s.should eq("c20335c3-7f46-4126-aae9-f665434ad12b") + UUID.new("C20335C37F464126AAE9F665434AD12B").to_s.should eq("c20335c3-7f46-4126-aae9-f665434ad12b") + UUID.new("urn:uuid:1ed1ee2f-ef9a-4f9c-9615-ab14d8ef2892").to_s.should eq("1ed1ee2f-ef9a-4f9c-9615-ab14d8ef2892") + end + end + + it "initializes from UUID" do + uuid = UUID.new("50a11da6-377b-4bdf-b9f0-076f9db61c93") + uuid = UUID.new(uuid, version: UUID::Version::V2, variant: UUID::Variant::Microsoft) + uuid.version.should eq UUID::Version::V2 + uuid.variant.should eq UUID::Variant::Microsoft + uuid.to_s.should eq "50a11da6-377b-2bdf-d9f0-076f9db61c93" + end + + it "initializes zeroed UUID" do + UUID.empty.should eq UUID.new(StaticArray(UInt8, 16).new(0_u8), UUID::Variant::NCS, UUID::Version::V4) + UUID.empty.to_s.should eq "00000000-0000-4000-0000-000000000000" + UUID.empty.variant.should eq UUID::Variant::NCS + UUID.empty.version.should eq UUID::Version::V4 + end + + describe "supports different string formats" do + it "normal output" do + UUID.new("ee843b2656d8472bb3430b94ed9077ff").to_s.should eq "ee843b26-56d8-472b-b343-0b94ed9077ff" + end + + it "hexstring" do + UUID.new("3e806983-eca4-4fc5-b581-f30fb03ec9e5").hexstring.should eq "3e806983eca44fc5b581f30fb03ec9e5" + end + + it "urn" do + UUID.new("1ed1ee2f-ef9a-4f9c-9615-ab14d8ef2892").urn.should eq "urn:uuid:1ed1ee2f-ef9a-4f9c-9615-ab14d8ef2892" + end + end + + it "fails on invalid arguments when creating" do + expect_raises(ArgumentError) { UUID.new "25d6f843?cf8e-44fb-9f84-6062419c4330" } + expect_raises(ArgumentError) { UUID.new "67dc9e24-0865 474b-9fe7-61445bfea3b5" } + expect_raises(ArgumentError) { UUID.new "5942cde5-10d1-416b+85c4-9fc473fa1037" } + expect_raises(ArgumentError) { UUID.new "0f02a229-4898-4029-926f=94be5628a7fd" } + expect_raises(ArgumentError) { UUID.new "cda08c86-6413-474f-8822-a6646e0fb19G" } + expect_raises(ArgumentError) { UUID.new "2b1bfW06368947e59ac07c3ffdaf514c" } + end +end diff --git a/src/random.cr b/src/random.cr index 117582daff53..8cf8f53e0ebb 100644 --- a/src/random.cr +++ b/src/random.cr @@ -373,34 +373,6 @@ module Random random_bytes(n).hexstring end - # Generates a UUID (Universally Unique Identifier). - # - # It generates a random v4 UUID. See - # [RFC 4122 Section 4.4](https://tools.ietf.org/html/rfc4122#section-4.4) - # for the used algorithm and its implications. - # - # ``` - # Random::Secure.uuid # => "a4e319dd-a778-4a51-804e-66a07bc63358" - # ``` - # - # It is recommended to use the secure `Random::Secure` as a source or another - # cryptographically quality PRNG such as `Random::ISAAC` or ChaCha20. - def uuid : String - bytes = random_bytes(16) - bytes[6] = (bytes[6] & 0x0f) | 0x40 - bytes[8] = (bytes[8] & 0x3f) | 0x80 - - String.new(36) do |buffer| - buffer[8] = buffer[13] = buffer[18] = buffer[23] = 45_u8 - bytes[0, 4].hexstring(buffer + 0) - bytes[4, 2].hexstring(buffer + 9) - bytes[6, 2].hexstring(buffer + 14) - bytes[8, 2].hexstring(buffer + 19) - bytes[10, 6].hexstring(buffer + 24) - {36, 36} - end - end - # See `#rand`. def self.rand : Float64 DEFAULT.rand diff --git a/src/uuid.cr b/src/uuid.cr new file mode 100644 index 000000000000..3ad277c9d7b1 --- /dev/null +++ b/src/uuid.cr @@ -0,0 +1,209 @@ +# Represents a UUID (Universally Unique IDentifier). +struct UUID + enum Variant # variants with 16 bytes. + Unknown # Unknown (ie. custom, your own). + NCS # Reserved by the NCS for backward compatibility. + RFC4122 # Reserved for RFC4122 Specification (default). + Microsoft # Reserved by Microsoft for backward compatibility. + Future # Reserved for future expansion. + end + + enum Version # RFC4122 UUID versions. + Unknown = 0 # Unknown version. + V1 = 1 # date-time and MAC address. + V2 = 2 # DCE security. + V3 = 3 # MD5 hash and namespace. + V4 = 4 # random. + V5 = 5 # SHA1 hash and namespace. + end + + protected getter bytes : StaticArray(UInt8, 16) + + # Generates UUID from *bytes*, applying *version* and *variant* to the UUID if + # present. + def initialize(@bytes : StaticArray(UInt8, 16), variant : UUID::Variant? = nil, version : UUID::Version? = nil) + case variant + when nil + # do nothing + when Variant::NCS + @bytes[8] = (@bytes[8] & 0x7f) + when Variant::RFC4122 + @bytes[8] = (@bytes[8] & 0x3f) | 0x80 + when Variant::Microsoft + @bytes[8] = (@bytes[8] & 0x1f) | 0xc0 + when Variant::Future + @bytes[8] = (@bytes[8] & 0x1f) | 0xe0 + else + raise ArgumentError.new "Can't set unknown variant" + end + + if version + raise ArgumentError.new "Can't set unknown version" if version.unknown? + @bytes[6] = (@bytes[6] & 0xf) | (version.to_u8 << 4) + end + end + + # Creates UUID from 16-bytes slice. Raises if *slice* isn't 16 bytes long. See + # `#initialize` for *variant* and *version*. + def self.new(slice : Slice(UInt8), variant = nil, version = nil) + raise ArgumentError.new "Invalid bytes length #{slice.size}, expected 16" unless slice.size == 16 + + bytes = uninitialized UInt8[16] + slice.copy_to(bytes.to_slice) + + new(bytes, variant, version) + end + + # Creates another `UUID` which is a copy of *uuid*, but allows overriding + # *variant* or *version*. + def self.new(uuid : UUID, variant = nil, version = nil) + new(uuid.bytes, variant, version) + end + + # Creates new UUID by decoding `value` string from hyphenated (ie. `ba714f86-cac6-42c7-8956-bcf5105e1b81`), + # hexstring (ie. `89370a4ab66440c8add39e06f2bb6af6`) or URN (ie. `urn:uuid:3f9eaf9e-cdb0-45cc-8ecb-0e5b2bfb0c20`) + # format. + def self.new(value : String, variant = nil, version = nil) + bytes = uninitialized UInt8[16] + + case value.size + when 36 # Hyphenated + [8, 13, 18, 23].each do |offset| + if value[offset] != '-' + raise ArgumentError.new "Invalid UUID string format, expected hyphen at char #{offset}" + end + end + [0, 2, 4, 6, 9, 11, 14, 16, 19, 21, 24, 26, 28, 30, 32, 34].each_with_index do |offset, i| + string_has_hex_pair_at! value, offset + bytes[i] = value[offset, 2].to_u8(16) + end + when 32 # Hexstring + 16.times do |i| + string_has_hex_pair_at! value, i * 2 + bytes[i] = value[i * 2, 2].to_u8(16) + end + when 45 # URN + raise ArgumentError.new "Invalid URN UUID format, expected string starting with \":urn:uuid:\"" unless value.starts_with? "urn:uuid:" + [9, 11, 13, 15, 18, 20, 23, 25, 28, 30, 33, 35, 37, 39, 41, 43].each_with_index do |offset, i| + string_has_hex_pair_at! value, offset + bytes[i] = value[offset, 2].to_u8(16) + end + else + raise ArgumentError.new "Invalid string length #{value.size} for UUID, expected 32 (hexstring), 36 (hyphenated) or 46 (urn)" + end + + new(bytes, variant, version) + end + + # Raises `ArgumentError` if string `value` at index `i` doesn't contain hex + # digit followed by another hex digit. + private def self.string_has_hex_pair_at!(value : String, i) + unless value[i, 2].to_u8(16, whitespace: false, underscore: false, prefix: false) + raise ArgumentError.new [ + "Invalid hex character at position #{i * 2} or #{i * 2 + 1}", + "expected '0' to '9', 'a' to 'f' or 'A' to 'F'", + ].join(", ") + end + end + + # Generates RFC 4122 v4 UUID. + # + # It is strongly recommended to use a cryptographically random source for + # *random*, such as `Random::Secure`. + def self.random(random = Random::Secure, variant = Variant::RFC4122, version = Version::V4) + new_bytes = uninitialized UInt8[16] + random.random_bytes(new_bytes.to_slice) + + new(new_bytes, variant, version) + end + + def self.empty + new(StaticArray(UInt8, 16).new(0_u8), UUID::Variant::NCS, UUID::Version::V4) + end + + # Returns UUID variant. + def variant + case + when @bytes[8] & 0x80 == 0x00 + Variant::NCS + when @bytes[8] & 0xc0 == 0x80 + Variant::RFC4122 + when @bytes[8] & 0xe0 == 0xc0 + Variant::Microsoft + when @bytes[8] & 0xe0 == 0xe0 + Variant::Future + else + Variant::Unknown + end + end + + # Returns version based on RFC4122 format. See also `#variant`. + def version + case @bytes[6] >> 4 + when 1 then Version::V1 + when 2 then Version::V2 + when 3 then Version::V3 + when 4 then Version::V4 + when 5 then Version::V5 + else Version::Unknown + end + end + + # Returns 16-byte slice. + def to_slice + @bytes.to_slice + end + + # Returns unsafe pointer to 16-bytes. + def to_unsafe + @bytes.to_unsafe + end + + # Returns `true` if `other` UUID represents the same UUID, `false` otherwise. + def ==(other : UUID) + to_slice == other.to_slice + end + + def to_s(io : IO) + slice = to_slice + + buffer = uninitialized UInt8[36] + buffer_ptr = buffer.to_unsafe + + buffer_ptr[8] = buffer_ptr[13] = buffer_ptr[18] = buffer_ptr[23] = '-'.ord.to_u8 + slice[0, 4].hexstring(buffer_ptr + 0) + slice[4, 2].hexstring(buffer_ptr + 9) + slice[6, 2].hexstring(buffer_ptr + 14) + slice[8, 2].hexstring(buffer_ptr + 19) + slice[10, 6].hexstring(buffer_ptr + 24) + + io.write(buffer.to_slice) + end + + def hexstring + to_slice.hexstring + end + + def urn + String.build(45) do |str| + str << "urn:uuid:" + to_s(str) + end + end + + {% for v in %w(1 2 3 4 5) %} + # Returns `true` if UUID looks is a V{{ v.id }}, `false` otherwise. + def v{{ v.id }}? + variant == Variant::RFC4122 && version == RFC4122::Version::V{{ v.id }} + end + + # Returns `true` if UUID looks is a V{{ v.id }}, raises `Error` otherwise. + def v{{ v.id }}! + unless v{{ v.id }}? + raise Error.new("Invalid UUID variant #{variant} version #{version}, expected RFC 4122 V{{ v.id }}") + else + true + end + end + {% end %} +end