From 4dacddd85e07af165df5093e14f3f1a767cf63d1 Mon Sep 17 00:00:00 2001 From: Feoramund <161657516+Feoramund@users.noreply.github.com> Date: Thu, 20 Jun 2024 22:57:55 -0400 Subject: [PATCH] Add `core:encoding/uuid` --- core/encoding/uuid/LICENSE | 28 ++++ core/encoding/uuid/definitions.odin | 59 +++++++ core/encoding/uuid/doc.odin | 15 ++ core/encoding/uuid/generation.odin | 159 +++++++++++++++++++ core/encoding/uuid/reading.odin | 97 +++++++++++ core/encoding/uuid/writing.odin | 61 +++++++ examples/all/all_main.odin | 2 + tests/core/encoding/uuid/test_core_uuid.odin | 118 ++++++++++++++ tests/core/normal.odin | 1 + 9 files changed, 540 insertions(+) create mode 100644 core/encoding/uuid/LICENSE create mode 100644 core/encoding/uuid/definitions.odin create mode 100644 core/encoding/uuid/doc.odin create mode 100644 core/encoding/uuid/generation.odin create mode 100644 core/encoding/uuid/reading.odin create mode 100644 core/encoding/uuid/writing.odin create mode 100644 tests/core/encoding/uuid/test_core_uuid.odin diff --git a/core/encoding/uuid/LICENSE b/core/encoding/uuid/LICENSE new file mode 100644 index 000000000..e4e21e62d --- /dev/null +++ b/core/encoding/uuid/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2024, Feoramund + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/core/encoding/uuid/definitions.odin b/core/encoding/uuid/definitions.odin new file mode 100644 index 000000000..5bb104cb2 --- /dev/null +++ b/core/encoding/uuid/definitions.odin @@ -0,0 +1,59 @@ +package uuid + +// A RFC 4122 Universally Unique Identifier +Identifier :: struct #raw_union { + integer: u128be, + bytes: [16]u8, +} + +EXPECTED_LENGTH :: 8 + 4 + 4 + 4 + 12 + 4 + +VERSION_BYTE_INDEX :: 6 +VARIANT_BYTE_INDEX :: 8 + +Read_Error :: enum { + None, + Invalid_Length, + Invalid_Hexadecimal, + Invalid_Separator, +} + +Variant_Type :: enum { + Unknown, + Reserved_Apollo_NCS, // 0b0xx + RFC_4122, // 0b10x + Reserved_Microsoft_COM, // 0b110 + Reserved_Future, // 0b111 +} + +// Name string is a URL. +Namespace_DNS := Identifier { + bytes = { + 0x6b, 0xa7, 0xb8, 0x10, 0x9d, 0xad, 0x11, 0xd1, + 0x80, 0xb4, 0x00, 0xc0, 0x4f, 0xd4, 0x30, 0xc8, + }, +} + +// Name string is a fully-qualified domain name. +Namespace_URL := Identifier { + bytes = { + 0x6b, 0xa7, 0xb8, 0x11, 0x9d, 0xad, 0x11, 0xd1, + 0x80, 0xb4, 0x00, 0xc0, 0x4f, 0xd4, 0x30, 0xc8, + }, +} + +// Name string is an ISO OID. +Namespace_OID := Identifier { + bytes = { + 0x6b, 0xa7, 0xb8, 0x12, 0x9d, 0xad, 0x11, 0xd1, + 0x80, 0xb4, 0x00, 0xc0, 0x4f, 0xd4, 0x30, 0xc8, + }, +} + +// Name string is an X.500 DN (in DER or a text output format). +Namespace_X500 := Identifier { + bytes = { + 0x6b, 0xa7, 0xb8, 0x14, 0x9d, 0xad, 0x11, 0xd1, + 0x80, 0xb4, 0x00, 0xc0, 0x4f, 0xd4, 0x30, 0xc8, + }, +} diff --git a/core/encoding/uuid/doc.odin b/core/encoding/uuid/doc.odin new file mode 100644 index 000000000..a05698955 --- /dev/null +++ b/core/encoding/uuid/doc.odin @@ -0,0 +1,15 @@ +/* +package uuid implements Universally Unique Identifiers according to the +standard outlined in RFC 4122. + +See here for more information: https://www.rfc-editor.org/rfc/rfc4122.html + +Generation of versions 1 and 2 (the MAC address-based versions) are not yet +implemented. + +The UUIDs are textually represented and read in the following string format: +`00000000-0000-4000-8000-000000000000` + +Outside of string representations, they are represented in memory by a 128-bit structure. +*/ +package uuid diff --git a/core/encoding/uuid/generation.odin b/core/encoding/uuid/generation.odin new file mode 100644 index 000000000..fe05d3ebd --- /dev/null +++ b/core/encoding/uuid/generation.odin @@ -0,0 +1,159 @@ +package uuid + +import "core:crypto/legacy/md5" +import "core:crypto/legacy/sha1" +import "core:math/rand" +import "core:mem" + +/* +Generate a version 3 UUID. + +This UUID is generated from a name within a namespace. +MD5 is used to hash the name with the namespace to produce the UUID. + +Inputs: +- namespace: Another `Identifier` that is used to represent the underlying namespace. + This can be any one of the `Namespace_*` values provided in this package. +- name: The byte slice used to generate the name on top of the namespace. + +Returns: +- result: The generated UUID. +*/ +generate_v3_bytes :: proc( + namespace: Identifier, + name: []byte, +) -> ( + result: Identifier, +) { + namespace := namespace + + ctx: md5.Context + md5.init(&ctx) + md5.update(&ctx, namespace.bytes[:]) + md5.update(&ctx, name) + md5.final(&ctx, result.bytes[:]) + + result.bytes[VERSION_BYTE_INDEX] &= 0x0F + result.bytes[VERSION_BYTE_INDEX] |= 0x30 + + result.bytes[VARIANT_BYTE_INDEX] &= 0x3F + result.bytes[VARIANT_BYTE_INDEX] |= 0x80 + + return +} + +/* +Generate a version 3 UUID. + +This UUID is generated from a name within a namespace. +MD5 is used to hash the name with the namespace to produce the UUID. + +Inputs: +- namespace: Another `Identifier` that is used to represent the underlying namespace. + This can be any one of the `Namespace_*` values provided in this package. +- name: The string used to generate the name on top of the namespace. + +Returns: +- result: The generated UUID. +*/ +generate_v3_string :: proc( + namespace: Identifier, + name: string, +) -> ( + result: Identifier, +) { + return generate_v3_bytes(namespace, transmute([]byte)name) +} + +generate_v3 :: proc { + generate_v3_bytes, + generate_v3_string, +} + +/* +Generate a version 4 UUID. + +This UUID will be pseudorandom, save for 6 pre-determined version and variant bits. + +Returns: +- result: The generated UUID. +*/ +generate_v4 :: proc() -> (result: Identifier) { + result.integer = transmute(u128be)rand.uint128() + + result.bytes[VERSION_BYTE_INDEX] &= 0x0F + result.bytes[VERSION_BYTE_INDEX] |= 0x40 + + result.bytes[VARIANT_BYTE_INDEX] &= 0x3F + result.bytes[VARIANT_BYTE_INDEX] |= 0x80 + + return +} + +/* +Generate a version 5 UUID. + +This UUID is generated from a name within a namespace. +SHA1 is used to hash the name with the namespace to produce the UUID. + +Inputs: +- namespace: Another `Identifier` that is used to represent the underlying namespace. + This can be any one of the `Namespace_*` values provided in this package. +- name: The byte slice used to generate the name on top of the namespace. + +Returns: +- result: The generated UUID. +*/ +generate_v5_bytes :: proc( + namespace: Identifier, + name: []byte, +) -> ( + result: Identifier, +) { + namespace := namespace + digest: [sha1.DIGEST_SIZE]byte + + ctx: sha1.Context + sha1.init(&ctx) + sha1.update(&ctx, namespace.bytes[:]) + sha1.update(&ctx, name) + sha1.final(&ctx, digest[:]) + + mem.copy_non_overlapping(&result.bytes, &digest, 16) + + result.bytes[VERSION_BYTE_INDEX] &= 0x0F + result.bytes[VERSION_BYTE_INDEX] |= 0x50 + + result.bytes[VARIANT_BYTE_INDEX] &= 0x3F + result.bytes[VARIANT_BYTE_INDEX] |= 0x80 + + return +} + +/* +Generate a version 5 UUID. + +This UUID is generated from a name within a namespace. +SHA1 is used to hash the name with the namespace to produce the UUID. + +Inputs: +- namespace: Another `Identifier` that is used to represent the underlying namespace. + This can be any one of the `Namespace_*` values provided in this package. +- name: The string used to generate the name on top of the namespace. + +Returns: +- result: The generated UUID. +*/ +generate_v5_string :: proc( + namespace: Identifier, + name: string, +) -> ( + result: Identifier, +) { + return generate_v5_bytes(namespace, transmute([]byte)name) +} + +generate_v5 :: proc { + generate_v5_bytes, + generate_v5_string, +} diff --git a/core/encoding/uuid/reading.odin b/core/encoding/uuid/reading.odin new file mode 100644 index 000000000..7f3d30ab2 --- /dev/null +++ b/core/encoding/uuid/reading.odin @@ -0,0 +1,97 @@ +package uuid + +/* +Convert a string to a UUID. + +Inputs: +- str: A string in the 8-4-4-4-12 format. + +Returns: +- id: The converted identifier, or `nil` if there is an error. +- error: A description of the error, or `nil` if successful. +*/ +read :: proc "contextless" (str: string) -> (id: Identifier, error: Read_Error) #no_bounds_check { + // Only exact-length strings are acceptable. + if len(str) != EXPECTED_LENGTH { + return {}, .Invalid_Length + } + + // Check ahead to see if the separators are in the right places. + if str[8] != '-' || str[13] != '-' || str[18] != '-' || str[23] != '-' { + return {}, .Invalid_Separator + } + + read_nibble :: proc "contextless" (nibble: u8) -> u8 { + switch nibble { + case '0' ..= '9': + return nibble - '0' + case 'A' ..= 'F': + return nibble - 'A' + 10 + case 'a' ..= 'f': + return nibble - 'a' + 10 + case: + // Return an error value. + return 0xFF + } + } + + index := 0 + octet_index := 0 + + CHUNKS :: [5]int{8, 4, 4, 4, 12} + + for chunk in CHUNKS { + for i := index; i < index + chunk; i += 2 { + high := read_nibble(str[i]) + low := read_nibble(str[i + 1]) + + if high | low > 0xF { + return {}, .Invalid_Hexadecimal + } + + id.bytes[octet_index] = low | high << 4 + octet_index += 1 + } + + index += chunk + 1 + } + + return +} + +/* +Get the version of a UUID. + +Inputs: +- id: The identifier. + +Returns: +- number: The version number. +*/ +version :: proc "contextless" (id: Identifier) -> (number: int) #no_bounds_check { + return cast(int)(id.bytes[VERSION_BYTE_INDEX] & 0xF0 >> 4) +} + +/* +Get the variant of a UUID. + +Inputs: +- id: The identifier. + +Returns: +- variant: The variant type. +*/ +variant :: proc "contextless" (id: Identifier) -> (variant: Variant_Type) #no_bounds_check { + switch { + case id.bytes[VARIANT_BYTE_INDEX] & 0x80 == 0: + return .Reserved_Apollo_NCS + case id.bytes[VARIANT_BYTE_INDEX] & 0xC0 == 0x80: + return .RFC_4122 + case id.bytes[VARIANT_BYTE_INDEX] & 0xE0 == 0xC0: + return .Reserved_Microsoft_COM + case id.bytes[VARIANT_BYTE_INDEX] & 0xF0 == 0xE0: + return .Reserved_Future + case: + return .Unknown + } +} diff --git a/core/encoding/uuid/writing.odin b/core/encoding/uuid/writing.odin new file mode 100644 index 000000000..c13d700a8 --- /dev/null +++ b/core/encoding/uuid/writing.odin @@ -0,0 +1,61 @@ +package uuid + +import "base:runtime" +import "core:io" +import "core:strconv" +import "core:strings" + +/* +Write a UUID in the 8-4-4-4-12 format. + +Inputs: +- w: A writable stream. +- id: The identifier to convert. +*/ +write :: proc(w: io.Writer, id: Identifier) #no_bounds_check { + write_octet :: proc (w: io.Writer, octet: u8) { + high_nibble := octet >> 4 + low_nibble := octet & 0xF + + io.write_byte(w, strconv.digits[high_nibble]) + io.write_byte(w, strconv.digits[low_nibble]) + } + + for index in 0 ..< 4 { write_octet(w, id.bytes[index]) } + io.write_byte(w, '-') + for index in 4 ..< 6 { write_octet(w, id.bytes[index]) } + io.write_byte(w, '-') + for index in 6 ..< 8 { write_octet(w, id.bytes[index]) } + io.write_byte(w, '-') + for index in 8 ..< 10 { write_octet(w, id.bytes[index]) } + io.write_byte(w, '-') + for index in 10 ..< 16 { write_octet(w, id.bytes[index]) } +} + +/* +Convert a UUID to a string in the 8-4-4-4-12 format. + +*Allocates Using Provided Allocator* + +Inputs: +- id: The identifier to convert. +- allocator: (default: context.allocator) +- loc: The caller location for debugging purposes (default: #caller_location) + +Returns: +- str: The allocated and converted string. +- error: An optional allocator error if one occured, `nil` otherwise. +*/ +to_string :: proc( + id: Identifier, + allocator := context.allocator, + loc := #caller_location, +) -> ( + str: string, + error: runtime.Allocator_Error, +) #optional_allocator_error { + buf := make([]byte, EXPECTED_LENGTH, allocator, loc) or_return + builder := strings.builder_from_bytes(buf[:]) + write(strings.to_writer(&builder), id) + return strings.to_string(builder), nil +} diff --git a/examples/all/all_main.odin b/examples/all/all_main.odin index 5202d72be..d39fbe79f 100644 --- a/examples/all/all_main.odin +++ b/examples/all/all_main.odin @@ -62,6 +62,7 @@ import varint "core:encoding/varint" import xml "core:encoding/xml" import endian "core:encoding/endian" import cbor "core:encoding/cbor" +import uuid "core:encoding/uuid" import fmt "core:fmt" import hash "core:hash" @@ -237,6 +238,7 @@ _ :: datetime _ :: flags _ :: sysinfo _ :: unicode +_ :: uuid _ :: utf8 _ :: utf8string _ :: utf16 diff --git a/tests/core/encoding/uuid/test_core_uuid.odin b/tests/core/encoding/uuid/test_core_uuid.odin new file mode 100644 index 000000000..3d1eb1db0 --- /dev/null +++ b/tests/core/encoding/uuid/test_core_uuid.odin @@ -0,0 +1,118 @@ +package test_core_uuid + +import "core:testing" +import "core:encoding/uuid" + +@(test) +test_version_and_variant :: proc(t: ^testing.T) { + v3 := uuid.generate_v3(uuid.Namespace_DNS, "") + v4 := uuid.generate_v4() + v5 := uuid.generate_v5(uuid.Namespace_DNS, "") + + testing.expect_value(t, uuid.version(v3), 3) + testing.expect_value(t, uuid.variant(v3), uuid.Variant_Type.RFC_4122) + testing.expect_value(t, uuid.version(v4), 4) + testing.expect_value(t, uuid.variant(v4), uuid.Variant_Type.RFC_4122) + testing.expect_value(t, uuid.version(v5), 5) + testing.expect_value(t, uuid.variant(v5), uuid.Variant_Type.RFC_4122) +} + +@(test) +test_namespaced_uuids :: proc(t: ^testing.T) { + TEST_NAME :: "0123456789ABCDEF0123456789ABCDEF" + + Expected_Result :: struct { + namespace: uuid.Identifier, + v3, v5: string, + } + + Expected_Results := [?]Expected_Result { + { uuid.Namespace_DNS, "80147f37-36db-3b82-b78f-810c3c6504ba", "18394c41-13a2-593f-abf2-a63e163c2860" }, + { uuid.Namespace_URL, "8136789b-8e16-3fbd-800b-1587e2f22521", "07337422-eb77-5fd3-99af-c7f59e641e13" }, + { uuid.Namespace_OID, "adbb95bc-ea50-3226-9a75-20c34a6030f8", "24db9b0f-70b8-53c4-a301-f695ce17276d" }, + { uuid.Namespace_X500, "a8965ad1-0e54-3d65-b933-8b7cca8e8313", "3012bf2d-fac4-5187-9825-493e6636b936" }, + } + + for exp in Expected_Results { + v3 := uuid.generate_v3(exp.namespace, TEST_NAME) + v5 := uuid.generate_v5(exp.namespace, TEST_NAME) + + v3_str := uuid.to_string(v3) + defer delete(v3_str) + + v5_str := uuid.to_string(v5) + defer delete(v5_str) + + testing.expect_value(t, v3_str, exp.v3) + testing.expect_value(t, v5_str, exp.v5) + } +} + +@(test) +test_writing :: proc(t: ^testing.T) { + id: uuid.Identifier + + for &b, i in id.bytes { + b = u8(i) + } + + s := uuid.to_string(id) + defer delete(s) + + testing.expect_value(t, s, "00010203-0405-0607-0809-0a0b0c0d0e0f") +} + +@(test) +test_reading :: proc(t: ^testing.T) { + id, err := uuid.read("00010203-0405-0607-0809-0a0b0c0d0e0f") + testing.expect_value(t, err, nil) + + for b, i in id.bytes { + testing.expect_value(t, b, u8(i)) + } +} + +@(test) +test_reading_errors :: proc(t: ^testing.T) { + { + BAD_STRING :: "|.......@....@....@....@............" + _, err := uuid.read(BAD_STRING) + testing.expect_value(t, err, uuid.Read_Error.Invalid_Separator) + } + + { + BAD_STRING :: "|.......-....-....-....-............" + _, err := uuid.read(BAD_STRING) + testing.expect_value(t, err, uuid.Read_Error.Invalid_Hexadecimal) + } + + { + BAD_STRING :: ".......-....-....-....-............" + _, err := uuid.read(BAD_STRING) + testing.expect_value(t, err, uuid.Read_Error.Invalid_Length) + } + + { + BAD_STRING :: "|.......-....-....-....-............|" + _, err := uuid.read(BAD_STRING) + testing.expect_value(t, err, uuid.Read_Error.Invalid_Length) + } + + { + BAD_STRING :: "00000000-0000-0000-0000-0000000000001" + _, err := uuid.read(BAD_STRING) + testing.expect_value(t, err, uuid.Read_Error.Invalid_Length) + } + + { + BAD_STRING :: "00000000000000000000000000000000" + _, err := uuid.read(BAD_STRING) + testing.expect_value(t, err, uuid.Read_Error.Invalid_Length) + } + + { + OK_STRING :: "00000000-0000-0000-0000-000000000000" + _, err := uuid.read(OK_STRING) + testing.expect_value(t, err, nil) + } +} diff --git a/tests/core/normal.odin b/tests/core/normal.odin index a84420cca..065090be3 100644 --- a/tests/core/normal.odin +++ b/tests/core/normal.odin @@ -17,6 +17,7 @@ download_assets :: proc() { @(require) import "encoding/hex" @(require) import "encoding/hxa" @(require) import "encoding/json" +@(require) import "encoding/uuid" @(require) import "encoding/varint" @(require) import "encoding/xml" @(require) import "flags"