From a9019b6a6cbd656ad55da016d8353726f2aabff9 Mon Sep 17 00:00:00 2001 From: Mirek Rusin Date: Fri, 3 Jun 2016 18:11:25 -0300 Subject: [PATCH] Updates. --- spec/std/uuid_spec.cr | 44 +++++++++++++++++++ src/uuid.cr | 67 ++++++++++++++++++++++------- src/uuid/empty.cr | 14 +++++++ src/uuid/rfc4122.cr | 88 ++++++++++++++++++++++++++++---------- src/uuid/string.cr | 98 +++++++++++++++++++++++++++++++++++++++++++ src/uuid/uuid.cr | 90 --------------------------------------- src/uuid/variant.cr | 62 +++++++++++++++++++++++++++ 7 files changed, 338 insertions(+), 125 deletions(-) create mode 100644 src/uuid/empty.cr create mode 100644 src/uuid/string.cr delete mode 100644 src/uuid/uuid.cr create mode 100644 src/uuid/variant.cr diff --git a/spec/std/uuid_spec.cr b/spec/std/uuid_spec.cr index 6cf3771b68b8..b9c632d9504e 100644 --- a/spec/std/uuid_spec.cr +++ b/spec/std/uuid_spec.cr @@ -1,6 +1,19 @@ 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") @@ -8,11 +21,24 @@ describe "UUID" do 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 @@ -33,4 +59,22 @@ describe "UUID" do 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 index b2ca2cd3bbe1..48a373249684 100644 --- a/src/uuid.cr +++ b/src/uuid.cr @@ -5,19 +5,58 @@ require "./uuid/*" # # Supports custom variants with arbitrary 16 bytes as well as (RFC 4122)[https://www.ietf.org/rfc/rfc4122.txt] variant # versions. -module UUID - # Raises `ArgumentError` if string `value` at index `i` doesn't contain hex digit followed by another hex digit. - # TODO: Move to String#digit?(...) - 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 - - def self.new(*args) - ::UUID::UUID.new *args +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 index ecea8bb845fe..500d92f5aae3 100644 --- a/src/uuid/rfc4122.cr +++ b/src/uuid/rfc4122.cr @@ -1,35 +1,81 @@ # Support for RFC 4122 UUID variant. -module UUID::RFC4122 - enum RFC4122Version - Unknown - V1 - V2 - V3 - V4 - V5 +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 UUID variant with specified format `version`. - def initialize(version : RFC4122Version) + # Generates RFC 4122 UUID `variant` with specified `version`. + def initialize(version : Version) case version - when RFC4122Version::V4 - @data.to_unsafe.copy_from SecureRandom.random_bytes(16).to_unsafe, 16 - @data[6] = (@data[6] & 0x0f) | 0x40 - @data[8] = (@data[8] & 0x3f) | 0x80 + 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 "Unsupported version #{version}." + raise ArgumentError.new "Can't set unknown version." end end - {% for version in %w(1 2 3 4 5) %} + # 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) %} - def v{{ version.id }}? - rfc4122_version == RFC4122Version::V{{ version.id }} + # 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 - def v{{ version.id }}! - unless v{{ version.id }}? - raise Error.new("Invalid RFC 4122 UUID version #{rfc_4122}, expected V{{ version.id }}.") + # 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 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/uuid.cr b/src/uuid/uuid.cr deleted file mode 100644 index 548317b29d03..000000000000 --- a/src/uuid/uuid.cr +++ /dev/null @@ -1,90 +0,0 @@ -module UUID - struct UUID - include ::UUID::RFC4122 - - # Internal representation. - @data = StaticArray(UInt8, 16).new - - # Generates UUID (RFC 4122 v4). - def initialize - initialize RFC4122Version::V4 - end - - # Creates UUID from any 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 (optionally hyphenated) string `value`. - def initialize(value : String) - case value.size - when 36 # with hyphens - [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 # without hyphens - 16.times do |i| - ::UUID.string_has_hex_pair_at! value, i * 2 - @data[i] = value[i * 2, 2].to_u8(16) - end - else - raise ArgumentError.new "Invalid string length #{value.size} for UUID, expected 32 (hex) or 36 (hyphenated hex)." - end - end - - # Returns 16-byte slice of this UUID. - def to_slice - Slice(UInt8).new to_unsafe, 16 - end - - # Returns unsafe pointer to 16-byte slice of this UUID. - def to_unsafe - @data.to_unsafe - end - - # Writes hyphenated string representation for this UUID. - def to_s(io : IO) - io << to_s(true) - end - - # Returns (optionally `hyphenated`) string representation. - def to_s(hyphenated = true) - slice = to_slice - if 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 - else - slice.hexstring - end - 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 -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