Skip to content

Commit

Permalink
Add UUID.v1, .v2, .v3, .v4, .v5 (#13693)
Browse files Browse the repository at this point in the history
Co-authored-by: Quinton Miller <nicetas.c@gmail.com>
  • Loading branch information
threez and HertzDevil committed Oct 20, 2023
1 parent 9e84f6e commit 008d76a
Show file tree
Hide file tree
Showing 3 changed files with 238 additions and 3 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/wasm32.yml
Expand Up @@ -40,7 +40,7 @@ jobs:
rm wasm32-wasi-libs.tar.gz
- name: Build spec/wasm32_std_spec.cr
run: bin/crystal build spec/wasm32_std_spec.cr -o wasm32_std_spec.wasm --target wasm32-wasi -Duse_pcre
run: bin/crystal build spec/wasm32_std_spec.cr -o wasm32_std_spec.wasm --target wasm32-wasi -Duse_pcre -Dwithout_openssl
env:
CRYSTAL_LIBRARY_PATH: ${{ github.workspace }}/wasm32-wasi-libs

Expand Down
90 changes: 90 additions & 0 deletions spec/std/uuid_spec.cr
Expand Up @@ -162,10 +162,30 @@ describe "UUID" do
expect_raises(ArgumentError) { UUID.new "xyz:uuid:1ed1ee2f-ef9a-4f9c-9615-ab14d8ef2892" }
end

describe "v1" do
it "returns true if UUID is v1, false otherwise" do
uuid = UUID.v1
uuid.v1?.should eq(true)
uuid = UUID.v1(node_id: StaticArray(UInt8, 6).new { |i| (i*10).to_u8 })
uuid.to_s[24..36].should eq("000a141e2832")
end
end

describe "v2" do
it "returns true if UUID is v2, false otherwise" do
uuid = UUID.v2(UUID::Domain::Person, 42)
uuid.v2?.should eq(true)
uuid = UUID.v2(UUID::Domain::Person, 42, node_id: StaticArray(UInt8, 6).new { |i| (i*10).to_u8 })
uuid.to_s[24..36].should eq("000a141e2832")
end
end

describe "v4?" do
it "returns true if UUID is v4, false otherwise" do
uuid = UUID.random
uuid.v4?.should eq(true)
uuid = UUID.v4
uuid.v4?.should eq(true)
uuid = UUID.new("00000000-0000-0000-0000-000000000000", version: UUID::Version::V5)
uuid.v4?.should eq(false)
end
Expand All @@ -175,8 +195,78 @@ describe "UUID" do
it "returns true if UUID is v4, raises otherwise" do
uuid = UUID.random
uuid.v4!.should eq(true)
uuid = UUID.v4
uuid.v4!.should eq(true)
uuid = UUID.new("00000000-0000-0000-0000-000000000000", version: UUID::Version::V5)
expect_raises(UUID::Error) { uuid.v4! }
end
end

describe "v3" do
it "generates DNS based names correctly" do
data = "crystal-lang.org"
expected = "60a4b7b5-3333-3f1e-a2cd-30d8a2d0b83b"
UUID.v3(data, UUID::Namespace::DNS).to_s.should eq(expected)
UUID.v3_dns(data).to_s.should eq(expected)
UUID.v3_dns(data).v3?.should eq(true)
end

it "generates URL based names correctly" do
data = "https://crystal-lang.org"
expected = "c25c7b79-5f5f-3844-98a4-2548f5d0e7f9"
UUID.v3(data, UUID::Namespace::URL).to_s.should eq(expected)
UUID.v3_url(data).to_s.should eq(expected)
UUID.v3_url(data).v3?.should eq(true)
end

it "generates OID based names correctly" do
data = "1.3.6.1.4.1.343"
expected = "77bc1dc3-0a9f-3e7e-bfa5-3f611a660c80"
UUID.v3(data, UUID::Namespace::OID).to_s.should eq(expected)
UUID.v3_oid(data).to_s.should eq(expected)
UUID.v3_oid(data).v3?.should eq(true)
end

it "generates X500 based names correctly" do
data = "cn=John Doe, ou=People, o=example, c=com"
expected = "fcab1a4c-fc81-3ebc-9874-9a8b931911d3"
UUID.v3(data, UUID::Namespace::X500).to_s.should eq(expected)
UUID.v3_x500(data).to_s.should eq(expected)
UUID.v3_x500(data).v3?.should eq(true)
end
end

describe "v5" do
it "generates DNS based names correctly" do
data = "crystal-lang.org"
expected = "11caf27c-b803-5e62-9c4b-15332b04047e"
UUID.v5(data, UUID::Namespace::DNS).to_s.should eq(expected)
UUID.v5_dns(data).to_s.should eq(expected)
UUID.v5_dns(data).v5?.should eq(true)
end

it "generates URL based names correctly" do
data = "https://crystal-lang.org"
expected = "29fec3f0-9ad0-5e8a-a42e-214ff695f50e"
UUID.v5(data, UUID::Namespace::URL).to_s.should eq(expected)
UUID.v5_url(data).to_s.should eq(expected)
UUID.v5_url(data).v5?.should eq(true)
end

it "generates OID based names correctly" do
data = "1.3.6.1.4.1.343"
expected = "6aab0456-7392-582a-b92a-ba5a7096945d"
UUID.v5(data, UUID::Namespace::OID).to_s.should eq(expected)
UUID.v5_oid(data).to_s.should eq(expected)
UUID.v5_oid(data).v5?.should eq(true)
end

it "generates X500 based names correctly" do
data = "cn=John Doe, ou=People, o=example, c=com"
expected = "bc10b2d9-f370-5c65-9561-5e3f6d9b236d"
UUID.v5(data, UUID::Namespace::X500).to_s.should eq(expected)
UUID.v5_x500(data).to_s.should eq(expected)
UUID.v5_x500(data).v5?.should eq(true)
end
end
end
149 changes: 147 additions & 2 deletions src/uuid.cr
@@ -1,3 +1,14 @@
require "time"
require "io"

{% if flag?(:without_openssl) %}
require "crystal/digest/sha1"
require "crystal/digest/md5"
{% else %}
require "digest/sha1"
require "digest/md5"
{% end %}

# Represents a UUID (Universally Unique IDentifier).
#
# NOTE: To use `UUID`, you must explicitly import it with `require "uuid"`
Expand All @@ -10,7 +21,7 @@ struct UUID
Unknown
# Reserved by the NCS for backward compatibility.
NCS
# Reserved for RFC4122 Specification (default).
# Reserved for RFC 4122 Specification (default).
RFC4122
# Reserved by Microsoft for backward compatibility.
Microsoft
Expand All @@ -22,7 +33,7 @@ struct UUID
enum Version
# Unknown version.
Unknown = 0
# Date-time and MAC address.
# Date-time and NodeID address.
V1 = 1
# DCE security.
V2 = 2
Expand All @@ -34,6 +45,31 @@ struct UUID
V5 = 5
end

# A Domain represents a Version 2 domain (DCE security).
enum Domain
Person = 0
Group = 1
Org = 2
end

# MAC address to be used as NodeID.
alias MAC = UInt8[6]

# Namespaces as defined per in the RFC 4122 Appendix C.
#
# They are used with the functions `v3` amd `v5` to generate
# a `UUID` based on a `name`.
module Namespace
# A UUID is generated using the provided `name`, which is assumed to be a fully qualified domain name.
DNS = UUID.new("6ba7b810-9dad-11d1-80b4-00c04fd430c8")
# A UUID is generated using the provided `name`, which is assumed to be a URL.
URL = UUID.new("6ba7b811-9dad-11d1-80b4-00c04fd430c8")
# A UUID is generated using the provided `name`, which is assumed to be an ISO OID.
OID = UUID.new("6ba7b812-9dad-11d1-80b4-00c04fd430c8")
# A UUID is generated using the provided `name`, which is assumed to be a X.500 DN in DER or a text output format.
X500 = UUID.new("6ba7b814-9dad-11d1-80b4-00c04fd430c8")
end

@bytes : StaticArray(UInt8, 16)

# Generates UUID from *bytes*, applying *version* and *variant* to the UUID if
Expand Down Expand Up @@ -176,6 +212,115 @@ struct UUID
new(new_bytes, variant, version)
end

# Generates RFC 4122 v1 UUID.
#
# The traditional method for generating a `node_id` involves using the machine’s MAC address.
# However, this approach is only effective if there is only one process running on the machine
# and if privacy is not a concern. In modern languages, the default is to prioritize security
# and privacy. Therefore, a pseudo-random `node_id` is generated as described in section 4.5 of
# the RFC.
#
# The sequence number `clock_seq` is used to generate the UUID. This number should be
# monotonically increasing, with only 14 bits of the clock sequence being used effectively.
# The clock sequence should be stored in a stable location, such as a file. If it is not
# stored, a random value is used by default. If not provided the current time milliseconds
# are used. In case the traditional MAC address based approach should be taken the
# `node_id` can be provided. Otherwise secure random is used.
def self.v1(*, clock_seq : UInt16? = nil, node_id : MAC? = nil) : self
tl = Time.local
now = (tl.to_unix_ns / 100).to_u64 + 122192928000000000
seq = ((clock_seq || (tl.nanosecond/1000000).to_u16) & 0x3fff) | 0x8000

time_low = UInt32.new(now & 0xffffffff)
time_mid = UInt16.new((now >> 32) & 0xffff)
time_hi = UInt16.new((now >> 48) & 0x0fff)
time_hi |= 0x1000 # Version 1

uuid = uninitialized UInt8[16]
IO::ByteFormat::BigEndian.encode(time_low, uuid.to_slice[0..3])
IO::ByteFormat::BigEndian.encode(time_mid, uuid.to_slice[4..5])
IO::ByteFormat::BigEndian.encode(time_hi, uuid.to_slice[6..7])
IO::ByteFormat::BigEndian.encode(seq, uuid.to_slice[8..9])

if node_id
6.times do |i|
uuid.to_slice[10 + i] = node_id[i]
end
else
Random::Secure.random_bytes(uuid.to_slice[10..15])
# set multicast bit as recommended per section 4.5 of the RFC 4122 spec
# to not conflict with real MAC addresses
uuid[10] |= 0x01_u8
end

new(uuid, version: UUID::Version::V1, variant: UUID::Variant::RFC4122)
end

# Generates RFC 4122 v2 UUID.
#
# Version 2 UUIDs are generated using the current time, the local machine’s MAC address,
# and the local user or group ID. However, they are not widely used due to their limitations.
# For a given domain/id pair, the same token may be returned for a duration of up to 7 minutes
# and 10 seconds.
#
# The `id` depends on the `domain`, for the `Domain::Person` usually the local user id (uid) is
# used, for `Domain::Group` usually the local group id (gid) is used. In case the traditional
# MAC address based approach should be taken the `node_id` can be provided. Otherwise secure
# random is used.
def self.v2(domain : Domain, id : UInt32, node_id : MAC? = nil) : self
uuid = v1(node_id: node_id).bytes
uuid[6] = (uuid[6] & 0x0f) | 0x20 # Version 2
uuid[9] = domain.to_u8
IO::ByteFormat::BigEndian.encode(id, uuid.to_slice[0..3])
new(uuid, version: UUID::Version::V2, variant: UUID::Variant::RFC4122)
end

# Generates RFC 4122 v3 UUID using the `name` to generate the UUID, it can be a string of any size.
# The `namespace` specifies the type of the name, usually one of `Namespace`.
def self.v3(name : String, namespace : UUID) : self
klass = {% if flag?(:without_openssl) %}::Crystal::Digest::MD5{% else %}::Digest::MD5{% end %}
hash = klass.digest do |ctx|
ctx.update namespace.bytes
ctx.update name
end
new(hash[0...16], version: UUID::Version::V3, variant: UUID::Variant::RFC4122)
end

# Generates RFC 4122 v4 UUID.
#
# It is strongly recommended to use a cryptographically random source for
# *random*, such as `Random::Secure`.
def self.v4(random r : Random = Random::Secure) : self
random(r)
end

# Generates RFC 4122 v5 UUID using the `name` to generate the UUID, it can be a string of any size.
# The `namespace` specifies the type of the name, usually one of `Namespace`.
def self.v5(name : String, namespace : UUID) : self
klass = {% if flag?(:without_openssl) %}::Crystal::Digest::SHA1{% else %}::Digest::SHA1{% end %}
hash = klass.digest do |ctx|
ctx.update namespace.bytes
ctx.update name
end
new(hash[0...16], version: UUID::Version::V5, variant: UUID::Variant::RFC4122)
end

{% for name in %w(DNS URL OID X500).map(&.id) %}
# Generates RFC 4122 v3 UUID with the `Namespace::{{ name }}`.
#
# * `name`: The name used to generate the UUID, it can be a string of any size.
def self.v3_{{ name.downcase }}(name : String)
v3(name, Namespace::{{ name }})
end

# Generates RFC 4122 v5 UUID with the `Namespace::{{ name }}`.
#
# * `name`: The name used to generate the UUID, it can be a string of any size.
def self.v5_{{ name.downcase }}(name : String)
v5(name, Namespace::{{ name }})
end
{% end %}

# Generates an empty UUID.
#
# ```
Expand Down

0 comments on commit 008d76a

Please sign in to comment.