Skip to content

Commit

Permalink
UUID revamp (#4453)
Browse files Browse the repository at this point in the history
* Add `UUID` struct.
* Support versions and variants.
* Remove `uuid` method from `Random` module.
* Create UUIDs from `String`, `Slice`, `StaticArray` and other UUIDs
  • Loading branch information
jkthorne authored and Martin Verzilli committed Nov 24, 2017
1 parent 7a589f7 commit 1b669c1
Show file tree
Hide file tree
Showing 4 changed files with 331 additions and 35 deletions.
7 changes: 0 additions & 7 deletions spec/std/random_spec.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
122 changes: 122 additions & 0 deletions spec/std/uuid_spec.cr
Original file line number Diff line number Diff line change
@@ -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
28 changes: 0 additions & 28 deletions src/random.cr
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
209 changes: 209 additions & 0 deletions src/uuid.cr
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit 1b669c1

Please sign in to comment.