diff --git a/spec/std/uuid_spec.cr b/spec/std/uuid_spec.cr new file mode 100644 index 000000000000..b9c632d9504e --- /dev/null +++ b/spec/std/uuid_spec.cr @@ -0,0 +1,80 @@ +require "spec" + +describe "UUID" do + it "has working zero UUID" do + UUID.empty.should eq UUID.empty + UUID.empty.to_s.should eq "00000000-0000-0000-0000-000000000000" + UUID.empty.variant.should eq UUID::Variant::NCS + end + + it "doesn't overwrite empty" do + empty = UUID.empty + empty.should eq empty + empty.decode "a01a5a94-7b52-4ca8-b310-382436650336" + UUID.empty.should_not eq empty + end + + it "can be built from strings" do + UUID.new("c20335c3-7f46-4126-aae9-f665434ad12b").should eq("c20335c3-7f46-4126-aae9-f665434ad12b") + UUID.new("c20335c37f464126aae9f665434ad12b").should eq("c20335c3-7f46-4126-aae9-f665434ad12b") + UUID.new("C20335C3-7F46-4126-AAE9-F665434AD12B").should eq("c20335c3-7f46-4126-aae9-f665434ad12b") + UUID.new("C20335C37F464126AAE9F665434AD12B").should eq("c20335c3-7f46-4126-aae9-f665434ad12b") + end + + it "should have correct variant and version" do + UUID.new("C20335C37F464126AAE9F665434AD12B").variant.should eq UUID::Variant::RFC4122 + UUID.new("C20335C37F464126AAE9F665434AD12B").version.should eq UUID::Version::V4 + end + + it "supports different string formats" do + UUID.new("ee843b2656d8472bb3430b94ed9077ff").to_s.should eq "ee843b26-56d8-472b-b343-0b94ed9077ff" + UUID.new("3e806983-eca4-4fc5-b581-f30fb03ec9e5").to_s(UUID::Format::Hexstring).should eq "3e806983eca44fc5b581f30fb03ec9e5" + UUID.new("1ed1ee2f-ef9a-4f9c-9615-ab14d8ef2892").to_s(UUID::Format::URN).should eq "urn:uuid:1ed1ee2f-ef9a-4f9c-9615-ab14d8ef2892" + end + + it "compares to strings" do + uuid = UUID.new "c3b46146eb794e18877b4d46a10d1517" + ->{ uuid == "c3b46146eb794e18877b4d46a10d1517" }.call.should eq(true) + ->{ uuid == "c3b46146-eb79-4e18-877b-4d46a10d1517" }.call.should eq(true) + ->{ uuid == "C3B46146-EB79-4E18-877B-4D46A10D1517" }.call.should eq(true) + ->{ uuid == "urn:uuid:C3B46146-EB79-4E18-877B-4D46A10D1517" }.call.should eq(true) + ->{ uuid == "urn:uuid:c3b46146-eb79-4e18-877b-4d46a10d1517" }.call.should eq(true) + ->{ UUID.new == "C3B46146-EB79-4E18-877B-4D46A10D1517" }.call.should eq(false) + end + + it "fails on invalid arguments when creating" do + expect_raises(ArgumentError) { UUID.new "" } + 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 + + it "fails when comparing to invalid strings" do + expect_raises(ArgumentError) { UUID.new == "" } + expect_raises(ArgumentError) { UUID.new == "d1fb9189-7013-4915-a8b1-07cfc83bca3U" } + expect_raises(ArgumentError) { UUID.new == "2ab8ffc8f58749e197eda3e3d14e0 6c" } + expect_raises(ArgumentError) { UUID.new == "2ab8ffc8f58749e197eda3e3d14e 06c" } + expect_raises(ArgumentError) { UUID.new == "2ab8ffc8f58749e197eda3e3d14e-76c" } + end + + it "should handle variant" do + uuid = UUID.new + expect_raises(ArgumentError) { uuid.variant = UUID::Variant::Unknown } + {% for variant in %w(NCS RFC4122 Microsoft Future) %} + uuid.variant = UUID::Variant::{{ variant.id }} + uuid.variant.should eq UUID::Variant::{{ variant.id }} + {% end %} + end + + it "should handle version" do + uuid = UUID.new + expect_raises(ArgumentError) { uuid.version = UUID::Version::Unknown } + {% for version in %w(1 2 3 4 5) %} + uuid.version = UUID::Version::V{{ version.id }} + uuid.version.should eq UUID::Version::V{{ version.id }} + {% end %} + end +end diff --git a/src/uuid.cr b/src/uuid.cr new file mode 100644 index 000000000000..48a373249684 --- /dev/null +++ b/src/uuid.cr @@ -0,0 +1,62 @@ +require "secure_random" +require "./uuid/*" + +# Universally Unique IDentifier. +# +# Supports custom variants with arbitrary 16 bytes as well as (RFC 4122)[https://www.ietf.org/rfc/rfc4122.txt] variant +# versions. +struct UUID + # Internal representation. + @data = StaticArray(UInt8, 16).new + + # Generates RFC 4122 v4 UUID. + def initialize + initialize Version::V4 + end + + # Generates UUID from static 16-`bytes`. + def initialize(bytes : StaticArray(UInt8, 16)) + @data = bytes + end + + # Creates UUID from 16-`bytes` slice. + def initialize(bytes : Slice(UInt8)) + raise ArgumentError.new "Invalid bytes length #{bytes.size}, expected 16." if bytes.size != 16 + @data.to_unsafe.copy_from bytes + end + + # Creates UUID from string `value`. See `UUID#decode(value : String)` for details on supported string formats. + def initialize(value : String) + decode value + end + + # Returns 16-byte slice. + def to_slice + Slice(UInt8).new to_unsafe, 16 + end + + # Returns unsafe pointer to 16-bytes. + def to_unsafe + @data.to_unsafe + end + + # Writes hyphenated format string to `io`. + def to_s(io : IO) + io << to_s + end + + # Returns `true` if `other` string represents the same UUID, `false` otherwise. + def ==(other : String) + self == UUID.new other + end + + # Returns `true` if `other` 16-byte slice represents the same UUID, `false` otherwise. + def ==(other : Slice(UInt8)) + to_slice == other + end + + # Returns `true` if `other` static 16 bytes represent the same UUID, `false` otherwise. + def ==(other : StaticArray(UInt8, 16)) + self.==(Slice(UInt8).new other.to_unsafe, 16) + end +end diff --git a/src/uuid/empty.cr b/src/uuid/empty.cr new file mode 100644 index 000000000000..f83524cbd24a --- /dev/null +++ b/src/uuid/empty.cr @@ -0,0 +1,14 @@ +struct UUID + # Empty UUID. + @@empty_bytes = StaticArray(UInt8, 16).new { 0_u8 } + + # Returns empty UUID (aka nil UUID where all bytes are set to `0`). + def self.empty + UUID.new @@empty_bytes + end + + # Resets UUID to an empty one. + def empty! + @bytes = @@empty_bytes + end +end diff --git a/src/uuid/rfc4122.cr b/src/uuid/rfc4122.cr new file mode 100644 index 000000000000..500d92f5aae3 --- /dev/null +++ b/src/uuid/rfc4122.cr @@ -0,0 +1,85 @@ +# Support for RFC 4122 UUID variant. +struct UUID + # RFC 4122 UUID variant versions. + enum Version + # Unknown version. + Unknown = 0 + + # Version 1 - date-time and MAC address. + V1 = 1 + + # Version 2 - DCE security. + V2 = 2 + + # Version 3 - MD5 hash and namespace. + V3 = 3 + + # Version 4 - random. + V4 = 4 + + # Version 5 - SHA1 hash and namespace. + V5 = 5 + end + + # Generates RFC 4122 UUID `variant` with specified `version`. + def initialize(version : Version) + case version + when Version::V4 + @data = SecureRandom.random_bytes(16) + variant = Variant::RFC4122 + version = Version::V4 + else + raise ArgumentError.new "Creating #{version} not supported." + end + end + + # Returns version based on provided 6th `byte` (0-indexed). + def self.byte_version(byte : UInt8) + case byte >> 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 byte with encoded `version` for provided 6th `byte` (0-indexed) for known versions. + # For `Version::Unknown` `version` raises `ArgumentError`. + def self.byte_version(byte : UInt8, version : Version) : UInt8 + if version != Version::Unknown + (byte & 0xf) | (version.to_u8 << 4) + else + raise ArgumentError.new "Can't set unknown version." + end + end + + # Returns version based on RFC 4122 format. See also `UUID#variant`. + def version + UUID.byte_version @data[6] + end + + # Sets variant to a specified `value`. Doesn't set variant (see `UUID#variant=(value : Variant)`). + def version=(value : Version) + @data[6] = UUID.byte_version @data[6], value + end + + {% for v in %w(1 2 3 4 5) %} + + # Returns `true` if UUID looks like V{{ v.id }}, `false` otherwise. + def v{{ v.id }}? + variant == Variant::RFC4122 && version == RFC4122::Version::V{{ v.id }} + end + + # Returns `true` if UUID looks like 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 diff --git a/src/uuid/string.cr b/src/uuid/string.cr new file mode 100644 index 000000000000..05fa73369405 --- /dev/null +++ b/src/uuid/string.cr @@ -0,0 +1,98 @@ +struct UUID + # Raises `ArgumentError` if string `value` at index `i` doesn't contain hex digit followed by another hex digit. + # TODO: Move to String#digits?(base, offset, size) or introduce strict String#[index, size].to_u8(base)! which doesn't + # allow non-digits. The problem it solves is that " 1".to_u8(16) is fine but if it appears inside hexstring + # it's not correct and there should be stdlib function to support it, without a need to build this kind of + # helpers. + def self.string_has_hex_pair_at!(value : String, i) + unless value[i].hex? && value[i + 1].hex? + 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 + + # String format. + enum Format + Hyphenated + Hexstring + URN + 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 decode(value : String) + 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| + ::UUID.string_has_hex_pair_at! value, offset + @data[i] = value[offset, 2].to_u8(16) + end + when 32 # Hexstring + 16.times do |i| + ::UUID.string_has_hex_pair_at! value, i * 2 + @data[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| + ::UUID.string_has_hex_pair_at! value, offset + @data[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 + end + + #  Returns string in specified `format`. + def to_s(format = Format::Hyphenated) + slice = to_slice + case format + when Format::Hyphenated + String.new(36) do |buffer| + buffer[8] = buffer[13] = buffer[18] = buffer[23] = 45_u8 + slice[0, 4].hexstring(buffer + 0) + slice[4, 2].hexstring(buffer + 9) + slice[6, 2].hexstring(buffer + 14) + slice[8, 2].hexstring(buffer + 19) + slice[10, 6].hexstring(buffer + 24) + {36, 36} + end + when Format::Hexstring + slice.hexstring + when Format::URN + String.new(45) do |buffer| + buffer.copy_from "urn:uuid:".to_unsafe, 9 + (buffer + 9).copy_from to_s.to_unsafe, 36 + {45, 45} + end + else + raise ArgumentError.new "Unexpected format #{format}." + end + end + + # Same as `UUID#decode(value : String)`, returns `self`. + def <<(value : String) + decode value + self + end + + # Same as `UUID#variant=(value : Variant)`, returns `self`. + def <<(value : Variant) + variant = value + self + end + + # Same as `UUID#version=(value : Version)`, returns `self`. + def <<(value : Version) + version = value + self + end +end diff --git a/src/uuid/variant.cr b/src/uuid/variant.cr new file mode 100644 index 000000000000..4a1b5d5d5816 --- /dev/null +++ b/src/uuid/variant.cr @@ -0,0 +1,62 @@ +struct UUID + # UUID variants. + enum Variant + # Unknown (ie. custom, your own). + Unknown + + # Reserved by the NCS for backward compatibility. + NCS + + # As described in the RFC4122 Specification (default). + RFC4122 + + # Reserved by Microsoft for backward compatibility. + Microsoft + + # Reserved for future expansion. + Future + end + + # Returns UUID variant based on provided 8th `byte` (0-indexed). + def self.byte_variant(byte : UInt8) + case + when byte & 0x80 == 0x00 + Variant::NCS + when byte & 0xc0 == 0x80 + Variant::RFC4122 + when byte & 0xe0 == 0xc0 + Variant::Microsoft + when byte & 0xe0 == 0xe0 + Variant::Future + else + Variant::Unknown + end + end + + # Returns byte with encoded `variant` based on provided 8th `byte` (0-indexed) for known variants. + # For `Variant::Unknown` `variant` raises `ArgumentError`. + def self.byte_variant(byte : UInt8, variant : Variant) : UInt8 + case variant + when Variant::NCS + byte & 0x7f + when Variant::RFC4122 + (byte & 0x3f) | 0x80 + when Variant::Microsoft + (byte & 0x1f) | 0xc0 + when Variant::Future + (byte & 0x1f) | 0xe0 + else + raise ArgumentError.new "Can't set unknown variant." + end + end + + # Returns UUID variant. + def variant + UUID.byte_variant @data[8] + end + + # Sets UUID variant to specified `value`. + def variant=(value : Variant) + @data[8] = UUID.byte_variant @data[8], value + end +end