diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bdff676..f33e6f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,26 +1,25 @@ -name: nuid ci +name: CI on: push: branches: [main] pull_request: - branches: [main] -env: - OTP-VERSION: 25.2.3 - REBAR3-VERSION: 3.20.0 +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true jobs: check: runs-on: ubuntu-latest + timeout-minutes: 15 strategy: matrix: combo: - - otp-version: '25.2' - rebar3-version: '3.18.0' - - otp-version: '26.2' - rebar3-version: '3.23.0' - - otp-version: '27.2' + - otp-version: '27.0' rebar3-version: '3.24.0' - otp-version: '28.0' rebar3-version: '3.25.0' @@ -28,7 +27,7 @@ jobs: steps: - uses: actions/checkout@v4 - - uses: erlef/setup-beam@v1.18 + - uses: erlef/setup-beam@v1.24.0 with: otp-version: ${{ matrix.combo.otp-version }} rebar3-version: ${{ matrix.combo.rebar3-version }} @@ -39,12 +38,7 @@ jobs: path: | ~/.cache/rebar3 _build - key: ${{ runner.os }}-${{ runner.arch }}-${{ matrix.combo.otp-version }}-${{ matrix.combo.rebar3-version }}-${{ hashFiles('rebar.lock') }} - - - name: Compile - run: | - rebar3 clean - rebar3 compile + key: ${{ runner.os }}-${{ runner.arch }}-${{ matrix.combo.otp-version }}-${{ matrix.combo.rebar3-version }}-${{ hashFiles('rebar.config', 'rebar.lock') }} - run: rebar3 check diff --git a/.github/workflows/hex-publish.yml b/.github/workflows/hex-publish.yml new file mode 100644 index 0000000..5a95028 --- /dev/null +++ b/.github/workflows/hex-publish.yml @@ -0,0 +1,50 @@ +name: Publish to Hex + +on: + release: + types: [published] + +permissions: + contents: read + +env: + OTP_VERSION: 27.2 + REBAR3_VERSION: 3.24.0 + +jobs: + publish: + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v4 + + - uses: erlef/setup-beam@v1.24.0 + with: + otp-version: ${{ env.OTP_VERSION }} + rebar3-version: ${{ env.REBAR3_VERSION }} + + - uses: actions/cache@v4 + with: + path: | + ~/.cache/rebar3 + _build + key: ${{ runner.os }}-${{ runner.arch }}-${{ env.OTP_VERSION }}-${{ env.REBAR3_VERSION }}-${{ hashFiles('rebar.config', 'rebar.lock') }} + + - name: Verify tag matches app version + run: | + TAG="${GITHUB_REF_NAME#v}" + APP_VSN=$(awk -F'"' '/{vsn,/ {print $2}' src/nuid.app.src) + if [ "$TAG" != "$APP_VSN" ]; then + echo "Release tag '$TAG' does not match app vsn '$APP_VSN'" + exit 1 + fi + + - run: rebar3 check + + - run: rebar3 test + + - name: Publish package and docs + env: + HEX_API_KEY: ${{ secrets.HEX_API_KEY }} + run: rebar3 hex publish --repo hexpm --yes diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..41f1d63 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,21 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2026-06-15 + +Initial public release. + +### Added + +- RFC 4122 UUIDs: versions 1, 3 (MD5), 4, and 5 (SHA-1) +- RFC 9562 UUIDs: versions 6, 7, and 8 +- Nil and max UUIDs +- `nuid1`: hex-timestamped identifier with 128 bits of randomness, sortable and ordered after RFC 9562 version 6 UUIDs +- `nuid2`: timestamped, node-tagged identifier with 128 bits of randomness, sortable and URL-safe +- Sortable, URL-safe base64 codec (`nuid_base64`) +- Time and node recovery via `uuid1_info/1`, `uuid6_info/1`, `uuid7_info/1`, `nuid1_info/1`, `nuid2_info/1` +- Property-based test suites backed by triq diff --git a/README.md b/README.md index 7eccad4..f41338d 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,85 @@ # nuid -[![nuid](https://github.com/nomasystems/nuid/actions/workflows/ci.yml/badge.svg)](https://github.com/nomasystems/nuid/actions/workflows/ci.yml) -`nuid` is an OTP library to generate unique identifiers. +[![Hex.pm](https://img.shields.io/hexpm/v/nuid.svg)](https://hex.pm/packages/nuid) +[![CI](https://github.com/nomasystems/nuid/actions/workflows/ci.yml/badge.svg)](https://github.com/nomasystems/nuid/actions/workflows/ci.yml) -## Setup +Unique identifier generation for Erlang/OTP 27+: RFC 4122 and RFC 9562 UUIDs, plus sortable `nuid` identifiers. -Add `nuid` to your project dependencies. +## Getting started -```erl -%%% e.g., rebar.config -{deps, [ - {nuid, {git, "git@github.com:nomasystems/nuid.git", {branch, "main"}}} -]}. +```erlang +%% rebar.config +{deps, [nuid]}. +``` + +```erlang +1> nuid:uuid4(). +<<"37a9e737-f680-44a9-b83d-a517ec758b75">> +2> nuid:uuid7(). +<<"018b3d7a-9f9a-7577-adb2-08761e3d87f7">> +3> nuid:nuid2(). +<<"OHtpP-----Fkn3F6JaT5Kxnm_NAiDzFgGMzc">> +4> nuid:uuid7_info(nuid:uuid7()). +{{2023,10,17},{11,52,8}} ``` ## Features -Traditionally we've been using uuid1 as defined on [rfc4122](https://datatracker.ietf.org/doc/html/rfc4122) and later we transitioned to uuid6 as defined on [draft-peabody-dispatch-new-uuid-format](https://datatracker.ietf.org/doc/html/draft-peabody-dispatch-new-uuid-format). +- **RFC 4122 UUIDs** versions 1, 3 (MD5), 4, and 5 (SHA-1) ([RFC 4122](https://www.rfc-editor.org/rfc/rfc4122)) +- **RFC 9562 UUIDs** versions 6, 7, and 8 ([RFC 9562](https://www.rfc-editor.org/rfc/rfc9562)) +- **Nil and max UUIDs** +- **`nuid1` and `nuid2`** sortable identifiers with 128 bits of cryptographically strong randomness +- **Info recovery** creation time and originating node from generated identifiers +- **No dependencies** only OTP `kernel`, `stdlib`, and `crypto` + +## API + +| Function | Description | +| -------- | ----------- | +| `nuid:uuid1/0` | RFC 4122 UUID v1 | +| `nuid:uuid3/2` | RFC 4122 UUID v3 (MD5, name-based) | +| `nuid:uuid4/0` | RFC 4122 UUID v4 (random) | +| `nuid:uuid5/2` | RFC 4122 UUID v5 (SHA-1, name-based) | +| `nuid:uuid6/0` | RFC 9562 UUID v6 (time-ordered) | +| `nuid:uuid7/0` | RFC 9562 UUID v7 (time-ordered) | +| `nuid:uuid8/1`, `nuid:uuid8/3` | RFC 9562 UUID v8 (vendor-specific) | +| `nuid:nil_uuid/0` | RFC 4122 nil UUID | +| `nuid:max_uuid/0` | RFC 9562 max UUID | +| `nuid:nuid1/0` | `nuid1` identifier | +| `nuid:nuid2/0` | `nuid2` identifier | +| `nuid:uuid1_info/1`, `nuid:uuid6_info/1` | Generation time, node, and counter | +| `nuid:uuid7_info/1`, `nuid:nuid1_info/1` | Generation time | +| `nuid:nuid2_info/1` | Generation time and node | + +## The nuid identifiers -The latest is preferred since it can be ordered by the time of creation. +We have historically used UUID v1 ([RFC 4122](https://datatracker.ietf.org/doc/html/rfc4122)) +and later UUID v6 ([RFC 9562](https://www.rfc-editor.org/rfc/rfc9562)), +preferring v6 because it can be ordered by creation time. -Recently we had the necessity of having identifiers with more entropy. Specifically, after a security revision, we've been requested to use at least the same random bits as uuid4 (122). With that in mind, we started searching for standards covering these requirements: +A later security review required identifiers with at least as many +cryptographically strong random bits as UUID v4 (122). The requirements were: -Must be able to order identifiers by the time of creation. -Identifiers must have at least 122 bits of cryptographically strong random numbers. +- Orderable by creation time. +- At least 122 bits of cryptographically strong randomness. -- Some optional requirements would make the identifiers more appealing: -- Being unique (at least having a low collision probability). -- Carry information about where the identifier has been created. +With, optionally: -We found these two great de-facto standards: -- [ulid](https://github.com/ulid/spec) -- [ksuid](https://github.com/segmentio/ksuid) +- Uniqueness (or at least a low collision probability). +- Information about where the identifier was created. -But they lack the second requirement, the one about 122 bits of cryptographically strong random numbers. So we came up with nuid2. +The two de facto standards we looked at, [ulid](https://github.com/ulid/spec) +and [ksuid](https://github.com/segmentio/ksuid), do not meet the randomness +requirement, so we defined `nuid2`. -nuid2 has these properties: +`nuid2` properties: - Lexicographically sortable. -- Sixteen cryptographically strong random bytes. (128bits) -- It is unique (at least has a low collision probability). -- It carries 3 bytes of information reserved for origin information. -- No longer than uuid (36 bytes) -- URL safe - +- Sixteen cryptographically strong random bytes (128 bits). +- Unique (or at least a low collision probability). +- Carries 3 bytes reserved for origin information. +- No longer than a UUID (36 characters). +- URL-safe. ``` 0 1 2 3 @@ -64,14 +100,11 @@ nuid2 has these properties: | | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ - Figure 1: nuid2 Field and bit Layout + Figure 1: nuid2 field and bit layout ``` -We encode this information on what we'll call base64'. That is a url safe, sortable base64 -representation. - - -This is base64' alphabet in order: +We encode this in what we call base64': a URL-safe, sortable base64 +representation. Its alphabet, in order: ``` -, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, @@ -80,15 +113,14 @@ _, a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t, u, v, w, x, y, z ``` -We took Erlang/OTP base64 module and modified it to meet these properties. -It's a pretty amazing code. Thanks. +It is the Erlang/OTP base64 module reordered to preserve byte ordering. -We also came up with nuid1, having just these properties - -- Lexicographically sortable. It can be used on systems that uuid6 were used. All nuid1 identifiers generated after a uuid6 has been generated will be lexicographically greater. -- 16 cryptographically strong random bytes. (128bits) -- It is unique (at least has a low collision probability). +`nuid1` is a lighter identifier with these properties: +- Lexicographically sortable. It can replace UUID v6: every `nuid1` + generated after a UUID v6 sorts after it. +- Sixteen cryptographically strong random bytes (128 bits). +- Unique (or at least a low collision probability). ``` 0 1 2 3 @@ -97,89 +129,24 @@ We also came up with nuid1, having just these properties | Hex unique time in us |-| base64' 16 random bytes | +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+ - Figure 2: nuid1 Field and Byte Layout - -(*) where dash at byte 14 is the 45 ASCII character + Figure 2: nuid1 field and byte layout -``` - - -## API -`nuid` exposes utilities via its API that allows you to: - -| Function | Description | -| -------- | ------------ | -| `nuid:uuid1/0` | Generates RFC 4122 uuid v1 | -| `nuid:uuid3/2` | Generates RFC 4122 uuid v3 | -| `nuid:uuid4/0` | Generates RFC 4122 uuid v4 | -| `nuid:uuid5/2` | Generates RFC 4122 uuid v5 | -| `nuid:uuid6/0` | Generates draft-ietf-uuidrev-rfc4122bis uuid v6 | -| `nuid:uuid7/0` | Generates draft-ietf-uuidrev-rfc4122bis uuid v7 | -| `nuid:uuid8/1` | Generates draft-ietf-uuidrev-rfc4122bis uuid v8 | -| `nuid:uuid8/3` | Generates draft-ietf-uuidrev-rfc4122bis uuid v8 | -| `nuid:nil_uuid/0` | Generates RFC 4122 nil uuid | -| `nuid:max_uuid/0` | Generates draft-ietf-uuidrev-rfc4122bis max uuid | -| `nuid:uuid1_info/1` | Gets info from uuid1 (generation time, node, unique) | -| `nuid:uuid6_info/1` | Gets info from uuid6 (generation time, node, unique) | -| `nuid:uuid7_info/1` | Gets info from uuid7 (generation time) | -| `nuid:nuid1/0` | Generates Noma nuid1 | -| `nuid:nuid2/0` | Generates Noma nuid2 | -| `nuid:nuid1_info/1` | Gets info from nuid1 (generation time) | -| `nuid:nuid2_info/1` | Gets info from nuid2 (generation time, node) | - - -## Implementation - - -## A simple example - -```erl -1> nuid:uuid1(). -<<"07e826fe-ed86-1060-8000-00001430cc44">> -2> nuid:uuid3(url, <<"https://www.nomasystems.com">>). -<<"67805345-d49e-3058-9ad3-160686e8ee2a">> -3> nuid:uuid4(). -<<"37a9e737-f680-44a9-b83d-a517ec758b75">> -4> nuid:uuid5(dns, <<"nomasystems.com">>). -<<"cefe05b2-95ca-5b0a-ad06-9b3f2b38e532">> -5> nuid:uuid6(). -<<"0607e826-ff71-6410-8000-00002430cc44">> -6> nuid:uuid7(). -<<"018b3d7a-9f9a-7577-adb2-08761e3d87f7">> -7> nuid:uuid8(<<16#f, 16#e, 16#d, 16#c, 16#b, 16#a, 16#9, 16#8, 16#7, 16#6, 16#5, 16#4, 16#3, 16#2, 16#1, 16#0>>). -<<"0f0e0d0c-0b0a-8908-8706-050403020100">> -8> nuid:nuid1(). -<<"607e826ff7388-4WX7g2peZpWw9QAIpkRp-F">> -9> nuid:nuid2(). -<<"OHtpP-----Fkn3F6JaT5Kxnm_NAiDzFgGMzc">> -10> nuid:uuid1_info(nuid:uuid1()). -{uuidInfo,{{2023,10,17},{11,52,8}},5,nonode@nohost} -11> nuid:uuid6_info(nuid:uuid6()). -{uuidInfo,{{2023,10,17},{11,52,8}},6,nonode@nohost} -12> nuid:nuid1_info(nuid:nuid1()). -{{2023,10,17},{11,52,8}} -13> nuid:nuid2_info(nuid:nuid2()). -{nonode@nohost,{{2023,10,17},{11,52,8}}} -14> nuid:nil_uuid(nuid:nuid2()). -<<"00000000-0000-0000-0000-000000000000">> -15> nuid:max_uuid(nuid:nuid2()). -<<"FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF">>. +(*) the dash at byte 14 is ASCII 45 ``` ## Benchmarks -Run a rebar3 shell using the `bench` profile: +Run a `rebar3` shell using the `bench` profile: + ```sh rebar3 as bench shell ``` -Run the following command: -```erl + +```erlang 1> nuid_bench:bench(). ``` -This benchmark compares different unique identifiers generated by `nuid`. - -### Results +This benchmark compares the different identifiers generated by `nuid`. ``` nuid1 creation time: 1.83 (us) @@ -190,6 +157,10 @@ uuid6 creation time: 0.91 (us) uuid7 creation time: 1.82 (us) ``` -## Support +## Documentation + +[nuid on HexDocs](https://hexdocs.pm/nuid) + +## License -Any doubt or suggestion? Please, check out [our issue tracker](https://github.com/nomasystems/nuid/issues). +Apache License 2.0 diff --git a/include/nuid.hrl b/include/nuid.hrl index 53e0866..f29020d 100644 --- a/include/nuid.hrl +++ b/include/nuid.hrl @@ -16,24 +16,21 @@ -define(nuid, true). %%% TYPES --type day() :: 1..31. -type month() :: 1..12. +-type day() :: 1..31. -type hour() :: 0..23. -type mins() :: 0..59. -type secs() :: 0..59. -type date() :: {non_neg_integer(), month(), day()}. --type datetime() :: {date(), time()}. --type ip() :: {0..255, 0..255, 0..255, 0..255}. --type id() :: non_neg_integer(). -type time() :: {hour(), mins(), secs()}. --type guid() :: binary(). +-type datetime() :: {date(), time()}. %%% RECORDS -record(uuidInfo, { date :: datetime(), id :: integer(), - node :: atom() + node :: node() }). % -ifndef(nuid) diff --git a/rebar.config b/rebar.config index 932e0ee..f13bac1 100644 --- a/rebar.config +++ b/rebar.config @@ -1,56 +1,105 @@ -{minimum_otp_vsn, "24"}. +{minimum_otp_vsn, "27"}. + {erl_opts, [ + debug_info, + warn_missing_spec, warnings_as_errors, {i, "include"} ]}. {deps, []}. -{shell, [nuid]}. - {project_plugins, [ erlfmt, - {gradualizer, {git, "https://github.com/josefs/Gradualizer.git", {tag, "0.3.0"}}} + rebar3_hank, + rebar3_ex_doc, + rebar3_hex ]}. {erlfmt, [write]}. +{hank, [ + {ignore, [ + %% Test files use CT callbacks with unused args + "test/**", + %% Benchmark harness, not shipped in the package + "bench/**", + %% Public header: the uuidInfo record ships for callers to match on + {"include/nuid.hrl", [single_use_hrl_attrs]} + ]} +]}. + +{hex, [ + {doc, ex_doc}, + {build_tools, ["rebar3"]}, + {files, [ + "src", + "include", + "rebar.config", + "README.md", + "LICENSE", + "CHANGELOG.md" + ]} +]}. + +{ex_doc, [ + {extras, [ + {<<"README.md">>, #{title => <<"Overview">>}}, + {<<"CHANGELOG.md">>, #{title => <<"Changelog">>}}, + {<<"LICENSE">>, #{title => <<"License">>}} + ]}, + {main, <<"README.md">>}, + {homepage_url, <<"https://github.com/nomasystems/nuid">>}, + {source_url, <<"https://github.com/nomasystems/nuid">>}, + {api_reference, true}, + {groups_for_modules, [ + {<<"Public API">>, [nuid]}, + {<<"Internal">>, [nuid_base64]} + ]} +]}. + +{xref_checks, [ + undefined_function_calls, + undefined_functions, + locals_not_used, + deprecated_function_calls, + deprecated_functions +]}. + +{dialyzer, [ + {plt_extra_apps, [crypto]} +]}. + +{cover_enabled, true}. +{cover_opts, [verbose, {min_coverage, 85}]}. +{cover_excl_mods, [nuid_base64]}. + +{ct_opts, [ + {verbose, true}, + {dir, "test"} +]}. + +{alias, [ + {check, [compile, {fmt, "--check"}, xref, dialyzer, hank]}, + {test, [ct, cover]} +]}. + +{shell, [{apps, [nuid]}]}. + {profiles, [ {test, [ - {erl_opts, [nowarn_export_all]}, {deps, [ - {nct_util, {git, "https://github.com/nomasystems/nct_util.git", {branch, "main"}}}, {triq, {git, "https://github.com/nomasystems/triq.git", {branch, "master"}}} - ]} + ]}, + {erl_opts, [nowarn_missing_spec, nowarn_export_all]}, + {extra_src_dirs, ["test/property_test"]} ]}, {bench, [ {deps, [ {eflambe, {git, "https://github.com/Stratus3D/eflambe.git", {branch, "master"}}}, {erlperf, {git, "https://github.com/max-au/erlperf.git", {branch, "master"}}} ]}, + {erl_opts, [nowarn_missing_spec]}, {extra_src_dirs, [{"bench", [{recursive, false}]}]} ]} ]}. - -{alias, [ - {check, [ - {fmt, "--check"}, - xref, - dialyzer, - gradualizer - ]}, - {test, [ - {ct, "--spec test/conf/test.spec --cover --readable true --verbose"}, - {cover, "-m 95"} - ]}, - {ci_test, [ - {ct, "--spec test/conf/ci_test.spec --cover --readable true"}, - {cover, "-m 95"} - ]} -]}. - -{cover_opts, [verbose]}. -{cover_excl_mods, [nuid_base64]}. -{cover_enabled, true}. - -{xref_ignores, [nuid]}. diff --git a/src/nuid.app.src b/src/nuid.app.src index e3d89e6..ec2fd60 100644 --- a/src/nuid.app.src +++ b/src/nuid.app.src @@ -1,14 +1,20 @@ {application, nuid, [ - {description, "OTP library to generate unique identifiers"}, + {description, + "Unique identifier generation for Erlang/OTP: RFC 4122/9562 UUIDs and sortable nuids"}, {vsn, "1.0.0"}, {registered, []}, {applications, [ kernel, - stdlib + stdlib, + crypto ]}, {env, []}, {modules, []}, - - {licenses, ["Apache 2.0"]}, - {links, []} + {licenses, ["Apache-2.0"]}, + {maintainers, ["Nomasystems"]}, + {minimum_otp_vsn, "27"}, + {links, [ + {"GitHub", "https://github.com/nomasystems/nuid"}, + {"Changelog", "https://github.com/nomasystems/nuid/blob/main/CHANGELOG.md"} + ]} ]}. diff --git a/src/nuid.erl b/src/nuid.erl index cbd0949..196efb1 100644 --- a/src/nuid.erl +++ b/src/nuid.erl @@ -13,6 +13,42 @@ %% limitations under the License. %%% -module(nuid). +-moduledoc """ +Unique identifier generation. + +This module produces two families of identifiers as `t:t/0` binaries: + +- **UUIDs.** Versions 1, 3, 4, and 5 per + [RFC 4122](https://www.rfc-editor.org/rfc/rfc4122), and versions 6, 7, + and 8 per [RFC 9562](https://www.rfc-editor.org/rfc/rfc9562), plus the + nil and max UUIDs. Rendered as the canonical 36-character hyphenated + form, e.g. `<<"018b3d7a-9f9a-7577-adb2-08761e3d87f7">>`. +- **nuids.** `nuid1/0` and `nuid2/0`, two Nomasystems identifiers that + are lexicographically sortable by creation time and carry 128 bits of + cryptographically strong randomness. They are encoded with a URL-safe, + sortable base64 variant (see `m:nuid_base64`). + +Time-based and random identifiers (`uuid1/0`, `uuid4/0`, `uuid6/0`, +`uuid7/0`, `nuid1/0`, `nuid2/0`) are non-deterministic. The name-based +identifiers (`uuid3/2`, `uuid5/2`) are deterministic: the same +`t:namespace/0` and name always produce the same UUID. + +The `*_info/1` functions recover the creation time (and, where encoded, +the originating node) from an identifier. + +## Examples + +```erlang +1> nuid:uuid4(). +<<"37a9e737-f680-44a9-b83d-a517ec758b75">> +2> nuid:uuid5(dns, <<"nomasystems.com">>). +<<"cefe05b2-95ca-5b0a-ad06-9b3f2b38e532">> +3> nuid:uuid7(). +<<"018b3d7a-9f9a-7577-adb2-08761e3d87f7">> +4> nuid:nuid2(). +<<"OHtpP-----Fkn3F6JaT5Kxnm_NAiDzFgGMzc">> +``` +""". %%% INCLUDE FILES -include_lib("nuid/include/nuid.hrl"). @@ -20,7 +56,7 @@ %%% EXTERNAL EXPORTS %RFC 4122 -export([uuid1/0, uuid3/2, uuid4/0, uuid5/2]). -% New UUID Formats. draft-ietf-uuidrev-rfc4122bis +% RFC 9562 (formerly draft-ietf-uuidrev-rfc4122bis) -export([uuid6/0, uuid7/0, uuid8/1, uuid8/3]). -export([uuid1_info/1, uuid6_info/1, uuid7_info/1]). @@ -29,6 +65,24 @@ -export([nuid1/0, nuid1_info/1]). -export([nuid2/0, nuid2_info/1]). +%%% TYPES +-export_type([t/0, namespace/0, uuid_info/0, datetime/0]). + +-doc "A generated identifier as a printable binary.". +-type t() :: binary(). + +-doc """ +Namespace for name-based UUIDs. The atoms select the predefined RFC 4122 +namespace UUIDs; a binary is used verbatim as a custom namespace. +""". +-type namespace() :: dns | url | oid | x500 | nil | binary(). + +-doc """ +Decoded creation time, embedded counter, and originating node of a +time-based UUID. Defined by the `uuidInfo` record in `nuid.hrl`. +""". +-type uuid_info() :: #uuidInfo{}. + %%% MACROS -define(JANUARY_1ST_1970, 62167219200). -define(INTEGER_38_BIT_WRAP, 274877906944). @@ -53,6 +107,8 @@ %%%----------------------------------------------------------------------------- %%% EXTERNAL EXPORTS %%%----------------------------------------------------------------------------- +-doc "Generate a time-based RFC 4122 version 1 UUID.". +-spec uuid1() -> t(). uuid1() -> Timestamp = erlang:system_time(micro_seconds), Unique = erlang:unique_integer([positive, monotonic]), @@ -68,9 +124,18 @@ uuid1() -> [] ). +-doc "Recover creation time, counter, and node from a version 1 UUID.". +-spec uuid1_info(t()) -> uuid_info(). uuid1_info(Bin) -> uuid_info(Bin, uuid1). +-doc """ +Generate a name-based RFC 4122 version 3 UUID (MD5). + +Deterministic: the same `t:namespace/0` and name always yield the same +UUID. +""". +-spec uuid3(namespace(), binary()) -> t(). uuid3(dns, Name) when is_binary(Name) -> compose_uuid(md5, ?V3, <<16#6ba7b8109dad11d180b400c04fd430c8:128, Name/binary>>); uuid3(url, Name) when is_binary(Name) -> @@ -84,11 +149,20 @@ uuid3(nil, Name) when is_binary(Name) -> uuid3(NameSpace, Name) when is_binary(NameSpace), is_binary(Name) -> compose_uuid(md5, ?V3, <>). +-doc "Generate a random RFC 4122 version 4 UUID (122 random bits).". +-spec uuid4() -> t(). uuid4() -> <> = crypto:strong_rand_bytes(16), format_uuid(<>, 0, []). +-doc """ +Generate a name-based RFC 4122 version 5 UUID (SHA-1). + +Deterministic: the same `t:namespace/0` and name always yield the same +UUID. +""". +-spec uuid5(namespace(), binary()) -> t(). uuid5(dns, Name) when is_binary(Name) -> compose_uuid(sha, ?V5, <<16#6ba7b8109dad11d180b400c04fd430c8:128, Name/binary>>); uuid5(url, Name) when is_binary(Name) -> @@ -102,6 +176,13 @@ uuid5(nil, Name) when is_binary(Name) -> uuid5(NameSpace, Name) when is_binary(NameSpace), is_binary(Name) -> compose_uuid(sha, ?V5, <>). +-doc """ +Generate a time-ordered RFC 9562 version 6 UUID. + +The most significant bits hold the timestamp, so version 6 UUIDs sort +lexicographically by creation time. +""". +-spec uuid6() -> t(). uuid6() -> Timestamp = erlang:system_time(micro_seconds), Unique = erlang:unique_integer([positive, monotonic]), @@ -117,35 +198,70 @@ uuid6() -> [] ). +-doc "Recover creation time, counter, and node from a version 6 UUID.". +-spec uuid6_info(t()) -> uuid_info(). uuid6_info(Bin) -> uuid_info(Bin, uuid6). +-doc """ +Generate a time-ordered RFC 9562 version 7 UUID. + +A 48-bit Unix millisecond timestamp followed by 74 random bits. Sorts +lexicographically by creation time. +""". +-spec uuid7() -> t(). uuid7() -> Timestamp = erlang:system_time(milli_seconds), <> = crypto:strong_rand_bytes(10), format_uuid(<>, 0, []). +-doc "Recover the creation time from a version 7 UUID.". +-spec uuid7_info(t()) -> datetime(). uuid7_info(Bin) -> uuid_info(Bin, uuid7). +-doc """ +Generate a vendor-specific RFC 9562 version 8 UUID from a 128-bit binary. + +The version and variant bits in the input are overwritten; all other +bits are preserved. +""". +-spec uuid8(binary()) -> t(). uuid8(<>) -> uuid8(CustomA, CustomB, CustomC). +-doc """ +Generate a vendor-specific RFC 9562 version 8 UUID from its three custom +fields (48, 12, and 62 bits). +""". +-spec uuid8(non_neg_integer(), non_neg_integer(), non_neg_integer()) -> t(). uuid8(CustomA, CustomB, CustomC) -> format_uuid(<>, 0, []). %%%----------------------------------------------------------------------------- %%% EXTERNAL NIL and ZERO FUNCTIONS %%%----------------------------------------------------------------------------- +-doc "Return the RFC 4122 nil UUID (all zero bits).". +-spec nil_uuid() -> t(). nil_uuid() -> <<"00000000-0000-0000-0000-000000000000">>. +-doc "Return the RFC 9562 max UUID (all one bits).". +-spec max_uuid() -> t(). max_uuid() -> <<"FFFFFFFF-FFFF-FFFF-FFFF-FFFFFFFFFFFF">>. %%%----------------------------------------------------------------------------- %%% EXTERNAL PROPOSED FUNCTIONS %%%----------------------------------------------------------------------------- +-doc """ +Generate a `nuid1` identifier. + +A hex microsecond timestamp, a separator, and 16 cryptographically +strong random bytes in sortable base64. Lexicographically sortable and +greater than any previously generated version 6 UUID. +""". +-spec nuid1() -> t(). nuid1() -> Timestamp = erlang:system_time(micro_seconds), Unique = erlang:unique_integer([positive, monotonic]), @@ -156,6 +272,8 @@ nuid1() -> RandBase64 = nuid_base64:encode(Rand), <>. +-doc "Recover the creation time from a `nuid1` identifier.". +-spec nuid1_info(t()) -> datetime(). nuid1_info(<>) -> RawTime = erlang:binary_to_integer(HexTime, 16), case RawTime of @@ -165,6 +283,14 @@ nuid1_info(<>) -> erlang:throw({error, badarg}) end. +-doc """ +Generate a `nuid2` identifier. + +A POSIX-second timestamp, a sortable counter, 3 bytes of node origin, and +16 cryptographically strong random bytes, all in sortable base64. +Lexicographically sortable, URL-safe, and no longer than a UUID. +""". +-spec nuid2() -> t(). nuid2() -> Timestamp = erlang:system_time(seconds), Unique = erlang:unique_integer([positive, monotonic]), @@ -172,6 +298,13 @@ nuid2() -> Rand = crypto:strong_rand_bytes(?RAND_BYTES), nuid_base64:encode(<>). +-doc """ +Recover the originating node and creation time from a `nuid2` identifier. + +The node is resolved against the currently connected nodes; it is +`undefined` if the originating node is not reachable. +""". +-spec nuid2_info(t()) -> {node() | undefined, datetime()}. nuid2_info(Id) when is_binary(Id) -> <> = nuid_base64:decode(Id), Node = proplists:get_value( diff --git a/src/nuid_base64.erl b/src/nuid_base64.erl index bba2e38..09d393b 100644 --- a/src/nuid_base64.erl +++ b/src/nuid_base64.erl @@ -17,9 +17,31 @@ %% %% %CopyrightEnd% %% +%% Modifications: +%% Copyright Nomasystems 2022-2024. Licensed under Apache-2.0. +%% Derived from the OTP `base64` module. The alphabet has been +%% reordered so the encoding preserves input byte ordering, and +%% padding has been removed. +%% %% Description: Implements base 64 encode and decode. See RFC4648. -module(nuid_base64). +-moduledoc """ +Sortable, URL-safe base64 codec. + +A variant of the OTP `base64` module ([RFC 4648](https://www.rfc-editor.org/rfc/rfc4648)) +whose alphabet is reordered so that the encoding preserves the byte +ordering of the input: if `A < B` then `encode(A) < encode(B)`. The +alphabet, in order, is: + +``` +-, 0-9, A-Z, _, a-z +``` + +This is the encoding used by `nuid:nuid1/0` and `nuid:nuid2/0`. There is +no padding. +""". +-moduledoc #{authors => ["Ericsson AB", "Nomasystems"]}. -export([ encode/1, diff --git a/test/conf/ci_test.spec b/test/conf/ci_test.spec deleted file mode 100644 index 0a5dde7..0000000 --- a/test/conf/ci_test.spec +++ /dev/null @@ -1,5 +0,0 @@ -{logdir, "log"}. -{config, "test.cfg"}. -{define, 'TestDir', ".."}. -{suites, 'TestDir', all}. -{ct_hooks, [{cth_surefire, [{path, "report.xml"}]}]}. diff --git a/test/conf/test.cfg b/test/conf/test.cfg deleted file mode 100644 index c0ec67a..0000000 --- a/test/conf/test.cfg +++ /dev/null @@ -1 +0,0 @@ -{apps, [nuid]}. diff --git a/test/conf/test.spec b/test/conf/test.spec deleted file mode 100644 index 0a5dde7..0000000 --- a/test/conf/test.spec +++ /dev/null @@ -1,5 +0,0 @@ -{logdir, "log"}. -{config, "test.cfg"}. -{define, 'TestDir', ".."}. -{suites, 'TestDir', all}. -{ct_hooks, [{cth_surefire, [{path, "report.xml"}]}]}. diff --git a/test/nuid_SUITE.erl b/test/nuid_SUITE.erl index ab1b885..89e0f03 100644 --- a/test/nuid_SUITE.erl +++ b/test/nuid_SUITE.erl @@ -27,29 +27,12 @@ all() -> %%% INIT SUITE EXPORTS %%%----------------------------------------------------------------------------- init_per_suite(Conf) -> - Config = nct_util:setup_suite(Conf), - ct_property_test:init_per_suite(Config). + ct_property_test:init_per_suite(Conf). %%%----------------------------------------------------------------------------- %%% END SUITE EXPORTS %%%----------------------------------------------------------------------------- end_per_suite(Conf) -> - nct_util:teardown_suite(Conf). - -%%%----------------------------------------------------------------------------- -%%% INIT CASE EXPORTS -%%%----------------------------------------------------------------------------- -init_per_testcase(Case, Conf) -> - ct:print("Starting test case ~p", [Case]), - nct_util:init_traces(Case), - Conf. - -%%%----------------------------------------------------------------------------- -%%% END CASE EXPORTS -%%%----------------------------------------------------------------------------- -end_per_testcase(Case, Conf) -> - nct_util:end_traces(Case), - ct:print("Test case ~p completed", [Case]), Conf. %%%----------------------------------------------------------------------------- diff --git a/test/nuid_base64_SUITE.erl b/test/nuid_base64_SUITE.erl index 49a711a..03216f1 100644 --- a/test/nuid_base64_SUITE.erl +++ b/test/nuid_base64_SUITE.erl @@ -33,29 +33,12 @@ all() -> %%% INIT SUITE EXPORTS %%%----------------------------------------------------------------------------- init_per_suite(Conf) -> - Config = nct_util:setup_suite(Conf), - ct_property_test:init_per_suite(Config). + ct_property_test:init_per_suite(Conf). %%%----------------------------------------------------------------------------- %%% END SUITE EXPORTS %%%----------------------------------------------------------------------------- end_per_suite(Conf) -> - nct_util:teardown_suite(Conf). - -%%%----------------------------------------------------------------------------- -%%% INIT CASE EXPORTS -%%%----------------------------------------------------------------------------- -init_per_testcase(Case, Conf) -> - ct:print("Starting test case ~p", [Case]), - nct_util:init_traces(Case), - Conf. - -%%%----------------------------------------------------------------------------- -%%% END CASE EXPORTS -%%%----------------------------------------------------------------------------- -end_per_testcase(Case, Conf) -> - nct_util:end_traces(Case), - ct:print("Test case ~p completed", [Case]), Conf. properties() ->