├── .formatter.exs ├── .gitignore ├── .gitmodules ├── .mix_tasks ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── lib ├── puid.ex └── puid │ ├── bits.ex │ ├── chars.ex │ ├── decoder │ └── ascii.ex │ ├── encoder │ ├── ascii.ex │ └── utf8.ex │ ├── entropy.ex │ ├── error.ex │ ├── info.ex │ └── util.ex ├── mix.exs └── test ├── chars_test.exs ├── data.exs ├── entropy_test.exs ├── histogram.exs ├── puid_test.exs ├── test_helper.exs └── timing.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | _build 2 | cover 3 | deps 4 | doc 5 | mix.lock 6 | .elixir_ls 7 | .vscode 8 | password 9 | *~ 10 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "test/data"] 2 | path = test/data 3 | url = git@github.com:puid/data.git 4 | -------------------------------------------------------------------------------- /.mix_tasks: -------------------------------------------------------------------------------- 1 | app.config:Configures all registered apps 2 | app.start:Starts all registered apps 3 | app.tree:Prints the application tree 4 | archive:Lists installed archives 5 | archive.build:Archives this project into a .ez file 6 | archive.install:Installs an archive locally 7 | archive.uninstall:Uninstalls archives 8 | clean:Deletes generated application files 9 | cmd:Executes the given command 10 | compile:Compiles source files 11 | deps:Lists dependencies and their status 12 | deps.clean:Deletes the given dependencies' files 13 | deps.compile:Compiles dependencies 14 | deps.get:Gets all out of date dependencies 15 | deps.tree:Prints the dependency tree 16 | deps.unlock:Unlocks the given dependencies 17 | deps.update:Updates the given dependencies 18 | do:Executes the tasks separated by comma 19 | docs:Generate documentation for the project 20 | escript:Lists installed escripts 21 | escript.build:Builds an escript for the project 22 | escript.install:Installs an escript locally 23 | escript.uninstall:Uninstalls escripts 24 | format:Formats the given files/patterns 25 | help:Prints help information for tasks 26 | hex:Prints Hex help information 27 | hex.audit:Shows retired Hex deps for the current project 28 | hex.build:Builds a new package version locally 29 | hex.config:Reads, updates or deletes local Hex config 30 | hex.docs:Fetches or opens documentation of a package 31 | hex.info:Prints Hex information 32 | hex.organization:Manages Hex.pm organizations 33 | hex.outdated:Shows outdated Hex deps for the current project 34 | hex.owner:Manages Hex package ownership 35 | hex.package:Fetches or diffs packages 36 | hex.publish:Publishes a new package version 37 | hex.registry:Manages local Hex registries 38 | hex.repo:Manages Hex repositories 39 | hex.retire:Retires a package version 40 | hex.search:Searches for package names 41 | hex.sponsor:Show Hex packages accepting sponsorships 42 | hex.user:Manages your Hex user account 43 | loadconfig:Loads and persists the given configuration 44 | local:Lists local tasks 45 | local.hex:Installs Hex locally 46 | local.public_keys:Manages public keys 47 | local.rebar:Installs Rebar locally 48 | new:Creates a new Elixir project 49 | nimble_parsec.compile:Compiles a parser and injects its content into the parser file 50 | profile.cprof:Profiles the given file or expression with cprof 51 | profile.eprof:Profiles the given file or expression with eprof 52 | profile.fprof:Profiles the given file or expression with fprof 53 | release:Assembles a self-contained release 54 | release.init:Generates sample files for releases 55 | run:Starts and runs the current application 56 | test:Runs a project's tests 57 | test.coverage:Build report from exported test coverage 58 | xref:Prints cross reference information 59 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: elixir 2 | 3 | elixir: 4 | - '1.8' 5 | otp_release: 6 | - '21.2' 7 | 8 | sudo: false 9 | 10 | env: 11 | - ELIXIR_ASSERT_TIMEOUT=2000 12 | 13 | script: 14 | - mix test 15 | 16 | after_script: 17 | - mix deps.get --only docs 18 | - MIX_ENV=docs mix inch.report 19 | 20 | notifications: 21 | recipients: 22 | - paul@knoxen.com 23 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v2.3.2 (2024-12-24) 4 | 5 | ### Fix Elixir 1.18 Range warning by explicitly declaring downward stepping on bit_shifts range 6 | 7 | ## v2.3.1 (2024-11-22) 8 | 9 | ### Minor mods for deprecations raised by Elixir 1.17 10 | 11 | ## v2.3.0 (2023-10-30) 12 | 13 | ### Add functions to `Puid` generated modules 14 | 15 | - `total/1` and `risk/1` 16 | - `encode/1` and `decode/1` 17 | 18 | ## v2.2.0 (2023-08-23) 19 | 20 | ### Add predefined chars 21 | 22 | - :base16 23 | - :crockford32 24 | - :wordSafe32 25 | 26 | ### Minor 27 | 28 | - Move `FixedBytes` from `Puid.Test` to `Puid.Util` 29 | - Add bits per character to doc for predefined chars 30 | - Use sigil_c for charlists 31 | - Add a few more tests 32 | 33 | ## v2.1.0 (2023-01-29) 34 | 35 | ### Improve bit slicing optimization 36 | 37 | - Bit slice shifts for a few character counts were missing a single bit optimization 38 | - Update effected fixed byte tests and add histogram tests for correctness validation 39 | - This change is an optimization and does not effect previous correctness 40 | 41 | Note: This change warrants a minor version bump as any fixed byte testing using effected character counts must be updated. Any non-fixed-byte entropy source usage is uneffected. 42 | 43 | ### Address issue #13 44 | 45 | - Add specified comparison notes 46 | - Revamp README comparisons 47 | 48 | ## v2.0.6 (2023-01-06) 49 | 50 | ### Improve code point validation 51 | 52 | - Reject utf8 code points between tilde and inverse bang 53 | - General code cleanup regarding code point validation 54 | 55 | ## v2.0.5 (2022-12-19) 56 | 57 | ### Further prevention of issue #12 58 | 59 | - Further prevention of macro generated `encode` function dialyzer warnings. 60 | - All combinations of pairs/single/unchunked encoding chunk sizes are now covered. 61 | - This change does not effect functionality. 62 | 63 | ## v2.0.4 (2022-12-10) 64 | 65 | ### Fix issue #12 66 | 67 | - Prevent macro generated `encode` function from encoding bit chunks known to be `<<>>`. Prior code resulted in dialyzer warnings. 68 | - This change does not effect functionality. 69 | 70 | ## v2.0.3 (2022-08-21) 71 | 72 | ### Fix 73 | 74 | - Fix FixBytes test helper. Only effects deterministic "random" bytes testing. 75 | - This change does not effect functionality. 76 | 77 | ### Add 78 | 79 | - Add cross-repo data for testing. This allows for easier, systematic histogram testing. 80 | - Check for invalid ascii in `Puid.Chars.charlist/1` and `Puid.Chars.charlist!/1` calls 81 | 82 | ## v2.0.2 (2022-07-07) 83 | 84 | ### Fix 85 | 86 | - Issue #10: Error 1st argument not a bitstring raised when just defining 87 | 88 | ### Testing 89 | 90 | - Added tests for above fix 91 | - Reworked fixed bytes mock entropy source 92 | - Added **MODULE**.Bits.reset/1 to facilitate fixed bytes testing 93 | 94 | ## v2.0.1 (2022-07-01) 95 | 96 | ### Tests 97 | 98 | - Added test for 100% coverage. 99 | 100 | ## v2.0.0 (2022-06-30) 101 | 102 | ### Added 103 | 104 | - ASCII encoding optimization 105 | - Use cross-product character pairs to encode larger bit chunks 106 | - Encode remaining bits via single character strategy 107 | - Unicode and/or ASCII-Unicode mix encoding optimization 108 | - Encode bits via single character strategy 109 | - Optimize bit filtering in selection of random indexes 110 | - Minimize bit shifting for out-of-range index slices 111 | - Store unused source entropy bits between `puid` generation calls per `Puid` module 112 | - Speed and efficiency are independent of pre-defined vs custom characters, including Unicode 113 | - Simplify module creation API 114 | - `chars` option can be a pre-defined atom, a string or a charlist 115 | - Pre-defined :symbol characters 116 | - Add chi square tests of random ID character histograms 117 | - CHANGELOG 118 | 119 | ### Changes 120 | 121 | - Remove `CryptoRand` dependency 122 | - Functionality superseded by new, in-project optimizations 123 | - Update timing tests 124 | - README 125 | 126 | ### Breaking Changes 127 | 128 | - Removed `charset` option for pre-defined characters 129 | - Use the `chars` option instead 130 | - Removed pre-defined `printable_ascii` 131 | - Replaced by `safe_ascii` (no backslash, backtick, single-quote or double-quote) 132 | - Reverse argument order for `Puid.Entropy` utility functions 133 | - Allows idiomatic Elixir use. Note these functions are rarely used directly. 134 | 135 | ### Deprecated 136 | 137 | - Removed deprecated functions 138 | - `Puid.Entropy.bits_for_length/2` 139 | - `Puid.Entropy.bits_for_length!/2` 140 | 141 | ## v1.1.2 (2021-09-15) 142 | 143 | ### Added 144 | 145 | - Resolve Elixir 1.11 compilation warnings 146 | 147 | ### Changes 148 | 149 | - Project file structure 150 | 151 | ### Fixes 152 | 153 | - Correct `Error.reason()` in function specs 154 | 155 | ## v1.1.1 (2020-01-15) 156 | 157 | ### Deprecated 158 | 159 | - `Puid.Entropy.bits_for_length/2` 160 | - `Puid.Entropy.bits_for_length!/2` 161 | 162 | ## v1.1.0 (2020-01-14) 163 | 164 | ### Added 165 | 166 | - Refactor 167 | - `Puid.Entropy.bits_for_length/2` -> `Puid.Entropy.bits_for_len/2` 168 | - `Puid.Entropy.bits_for_length!/2` -> `Puid.Entropy.bits_for_len!/2` 169 | 170 | ### Changes 171 | 172 | - Timing tests 173 | - README 174 | 175 | ## v1.0.0 (2019-05-02) 176 | 177 | Initial release 178 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Knoxen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Puid 2 | 3 | Simple, fast, flexible and efficient generation of probably unique identifiers (`puid`, aka random strings) of intuitively specified entropy using pre-defined or custom characters. 4 | 5 | ```elixir 6 | iex> defmodule(RandId, do: use(Puid, chars: :alpha, total: 1.0e5, risk: 1.0e12)) 7 | iex> RandId.generate() 8 | "YAwrpLRqXGlny" 9 | ``` 10 | 11 | [![Hex Version](https://img.shields.io/hexpm/v/puid.svg "Hex Version")](https://hex.pm/packages/puid)   [![License: MIT](https://img.shields.io/npm/l/express.svg)]() 12 | 13 | ## TOC 14 | 15 | - [Overview](#Overview) 16 | - [Usage](#Usage) 17 | - [Installation](#Installation) 18 | - [Module API](#ModuleAPI) 19 | - [Characters](#Characters) 20 | - [Comparisons](#Comparisons) 21 | - [Common Solution](#Common_Solution) 22 | - [gen_reference](#gen_reference) 23 | - [misc_random](#misc_random) 24 | - [nanoid](#nanoid) 25 | - [Randomizer](#Randomizer) 26 | - [rand_str](#rand_str) 27 | - [SecureRandom](#SecureRandom) 28 | - [ulid](#ulid) 29 | - [UUID](#UUID) 30 | 31 | ## Overview 32 | 33 | **Puid** provides a means to create modules for generating random IDs. Specifically, **Puid** allows full control over all three key characteristics of generating random strings: entropy source, ID characters and ID randomness. 34 | 35 | A [general overview](https://github.com/puid/.github/blob/2381099d7f92bda47c35e8b5ae1085119f2a919c/profile/README.md) provides information relevant to the use of **Puid** for random IDs. 36 | 37 | [TOC](#TOC) 38 | 39 | ### Usage 40 | 41 | `Puid` is used to create individual modules for random ID generation. Creating a random ID generator module is a simple as: 42 | 43 | ```elixir 44 | iex> defmodule(SessionId, do: use(Puid)) 45 | iex> SessionId.generate() 46 | "8nGA2UaIfaawX-Og61go5A" 47 | ``` 48 | 49 | The code above use default parameters, so `Puid` creates a module suitable for generating session IDs (ID entropy for the default module is 132 bits). Options allow easy and complete control of all three of the important facets of ID generation. 50 | 51 | **Entropy Source** 52 | 53 | `Puid` uses [:crypto.strong_rand_bytes/1](https://www.erlang.org/doc/man/crypto.html#strong_rand_bytes-1) as the default entropy source. The `rand_bytes` option can be used to specify any function of the form `(non_neg_integer) -> binary` as the source: 54 | 55 | ```elixir 56 | iex > defmodule(PrngId, do: use(Puid, rand_bytes: &:rand.bytes/1)) 57 | iex> PrngId.generate() 58 | "bIkrSeU6Yr8_1WHGvO0H3M" 59 | ``` 60 | 61 | **Characters** 62 | 63 | By default, `Puid` use the [RFC 4648](https://tools.ietf.org/html/rfc4648#section-5) file system & URL safe characters. The `chars` option can by used to specify any of 16 [pre-defined character sets](#Chars) or custom characters, including Unicode: 64 | 65 | ```elixir 66 | iex> defmodule(HexId, do: use(Puid, chars: :hex)) 67 | iex> HexId.generate() 68 | "13fb81e35cb89e5daa5649802ad4bbbd" 69 | 70 | iex> defmodule(DingoskyId, do: use(Puid, chars: "dingosky")) 71 | iex> DingoskyId.generate() 72 | "yiidgidnygkgydkodggysonydodndsnkgksgonisnko" 73 | 74 | iex> defmodule(DingoskyUnicodeId, do: use(Puid, chars: "dîñgø$kyDÎÑGØßK¥", total: 2.5e6, risk: 1.0e15)) 75 | iex> DingoskyUnicodeId.generate() 76 | "øßK$ggKñø$dyGîñdyØøØÎîk" 77 | 78 | ``` 79 | 80 | **Captured Entropy** 81 | 82 | Generated IDs have at least 128-bit entropy by default. `Puid` provides a simple, intuitive way to specify ID randomness by declaring a `total` number of possible IDs with a specified `risk` of a repeat in that many IDs: 83 | 84 | To generate up to _10 million_ random IDs with _1 in a trillion_ chance of repeat: 85 | 86 | ```elixir 87 | iex> defmodule(MyPuid, do: use(Puid, total: 10.0e6, risk: 1.0e15)) 88 | iex> MyPuid.generate() 89 | "T0bFZadxBYVKs5lA" 90 | ``` 91 | 92 | The `bits` option can be used to directly specify an amount of ID randomness: 93 | 94 | ```elixir 95 | iex> defmodule(Token, do: use(Puid, bits: 256, chars: :hex_upper)) 96 | iex> Token.generate() 97 | "6E908C2A1AA7BF101E7041338D43B87266AFA73734F423B6C3C3A17599F40F2A" 98 | ``` 99 | 100 | Note this is much more intuitive than guess, or simply not knowing, how much entropy your random IDs actually have. 101 | 102 | 103 | ### General Note 104 | 105 | The mathematical approximations used by **Puid** always favor conservative estimatation: 106 | 107 | - overestimate the **bits** needed for a specified **total** and **risk** 108 | - overestimate the **risk** of generating a **total** number of **puid**s 109 | - underestimate the **total** number of **puid**s that can be generated at a specified **risk** 110 | 111 | 112 | 113 | [TOC](#TOC) 114 | 115 | ### Installation 116 | 117 | Add `puid` to `mix.exs` dependencies: 118 | 119 | ```elixir 120 | def deps, 121 | do: [ 122 | {:puid, "~> 2.1"} 123 | ] 124 | ``` 125 | 126 | Update dependencies 127 | 128 | ```bash 129 | mix deps.get 130 | ``` 131 | 132 | ### Module API 133 | 134 | `Puid` modules have the following functions: 135 | 136 | - **generate/0**: Generate a random **puid** 137 | - **total/1**: total **puid**s which can be generated at a specified `risk` 138 | - **risk/1**: risk of generating `total` **puid**s 139 | - **encode/1**: Encode `bytes` into a **puid** 140 | - **decode/1**: Decode a `puid` into **bytes** 141 | - **info/0**: Module information 142 | 143 | The `total/1`, `risk/1` functions provide approximations to the **risk** of a repeat in some **total** number of generated **puid**s. The mathematical approximations used purposely _overestimate_ **risk** and _underestimate_ **total**. 144 | 145 | The `encode/1`, `decode/1` functions convert `String.t()` **puid**s to and from `bitstring` **bits** to facilitate binary data storage, e.g. as an **Ecto** type. 146 | 147 | The `info/0` function returns a `Puid.Info` structure consisting of: 148 | 149 | - source characters 150 | - name of pre-defined `Puid.Chars` or `:custom` 151 | - entropy bits per character 152 | - total entropy bits 153 | - may be larger than the specified `bits` since it is a multiple of the entropy bits per 154 | character 155 | - entropy representation efficiency 156 | - ratio of the **puid** entropy to the bits required for **puid** string representation 157 | - entropy source function 158 | - **puid** string length 159 | 160 | #### Example 161 | 162 | ```elixir 163 | iex> defmodule(SafeId, do: use(Puid)) 164 | 165 | iex> SafeId.generate() 166 | "CSWEPL3AiethdYFlCbSaVC" 167 | 168 | iex> SafeId.total(1_000_000) 169 | 104350568690606000 170 | 171 | iex> SafeId.risk(1.0e12) 172 | 9007199254740992 173 | 174 | iex> SafeId.decode("CSWEPL3AiethdYFlCbSaVC") 175 | <<9, 37, 132, 60, 189, 192, 137, 235, 97, 117, 129, 101, 9, 180, 154, 84, 32>> 176 | 177 | iex> SafeId.encode(<<9, 37, 132, 60, 189, 192, 137, 235, 97, 117, 129, 101, 9, 180, 154, 84, 2::size(4)>>) 178 | "CSWEPL3AiethdYFlCbSaVC" 179 | 180 | iex> SafeId.info() 181 | %Puid.Info{ 182 | characters: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_", 183 | char_set: :safe64, 184 | entropy_bits: 132.0, 185 | entropy_bits_per_char: 6.0, 186 | ere: 0.75, 187 | length: 22, 188 | rand_bytes: &:crypto.strong_rand_bytes/1 189 | } 190 | ``` 191 | 192 | ### Characters 193 | 194 | There are 19 pre-defined character sets: 195 | 196 | | Name | Characters | 197 | | :---------------- | :-------------------------------------------------------------------------------------------- | 198 | | :alpha | ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz | 199 | | :alpha_lower | abcdefghijklmnopqrstuvwxyz | 200 | | :alpha_upper | ABCDEFGHIJKLMNOPQRSTUVWXYZ | 201 | | :alphanum | ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 | 202 | | :alphanum_lower | abcdefghijklmnopqrstuvwxyz0123456789 | 203 | | :alphanum_upper | ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 | 204 | | :base16 | 0123456789ABCDEF | 205 | | :base32 | ABCDEFGHIJKLMNOPQRSTUVWXYZ234567 | 206 | | :base32_hex | 0123456789abcdefghijklmnopqrstuv | 207 | | :base32_hex_upper | 0123456789ABCDEFGHIJKLMNOPQRSTUV | 208 | | :crockford32 | 0123456789ABCDEFGHJKMNPQRSTVWXYZ | 209 | | :decimal | 0123456789 | 210 | | :hex | 0123456789abcdef | 211 | | :hex_upper | 0123456789ABCDEF | 212 | | :safe_ascii | !#$%&()\*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]^\_abcdefghijklmnopqrstuvwxyz{\|}~ | 213 | | :safe32 | 2346789bdfghjmnpqrtBDFGHJLMNPQRT | 214 | | :safe64 | ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-\_ | 215 | | :symbol | !#$%&()\*+,-./:;<=>?@[]^\_{\|}~ | 216 | | :wordSafe32 | 23456789CFGHJMPQRVWXcfghjmpqrvwx | 217 | 218 | Any `String` of up to 256 unique characters can be used for **`puid`** generation, with custom characters optimized in the same manner as the pre-defined character sets. The characters must be unique. This isn't strictly a technical requirement, **PUID** could handle duplicate characters, but the resulting randomness of the IDs is maximal when the characters are unique, so **PUID** enforces that restriction. 219 | 220 | #### Description of non-obvious character sets 221 | 222 | | Name | Description | 223 | | :---------------- | :--------------------------------------------------------- | 224 | | :base16 | https://datatracker.ietf.org/doc/html/rfc4648#section-8 | 225 | | :base32 | https://datatracker.ietf.org/doc/html/rfc4648#section-6 | 226 | | :base32_hex | Lowercase of :base32_hex_upper | 227 | | :base32_hex_upper | https://datatracker.ietf.org/doc/html/rfc4648#section-7 | 228 | | :crockford32 | https://www.crockford.com/base32.html | 229 | | :safe_ascii | Printable ascii that does not require escape in String | 230 | | :safe32 | Alpha and numbers picked to reduce chance of English words | 231 | | :safe64 | https://datatracker.ietf.org/doc/html/rfc4648#section-5 | 232 | | :wordSafe32 | Alpha and numbers picked to reduce chance of English words | 233 | 234 | Note: :safe32 and :wordSafe32 are two different strategies for the same goal. 235 | 236 | [TOC](#TOC) 237 | 238 | ## Comparisons 239 | 240 | As described in the [overview](https://github.com/puid/.github/blob/2381099d7f92bda47c35e8b5ae1085119f2a919c/profile/README.md), **PUID** aims to be a general, flexible mechanism for creating random string for use as random IDs. The following comparisons to other Elixir random ID generators is with respect to the issues of random ID generation described in that overview. 241 | 242 | [TOC](#TOC) 243 | 244 | ### [Common Solution](https://gist.github.com/dingosky/86328fc8b51d6b3037087ab1a8d14b4f#file-common_id-ex) 245 | 246 | #### Comments 247 | 248 | - Entropy source: Generating indexes via a PRNG is straightforward, though wasteful when compared to bit slicing. Generating indexes via a CSPRNG is not straightforward except for hex characters. 249 | - Characters: Full control 250 | - Captured entropy: Indirectly specified via ID length 251 | 252 | #### Timing 253 | 254 | **PUID** is much faster. 255 | 256 | ``` 257 | Generate 100000 random IDs with 128 bits of entropy using alphanumeric characters 258 | 259 | Common Solution (PRNG) : 4.977226 260 | Puid (PRNG) : 0.831748 261 | 262 | Common Solution (CSPRNG) : 8.435073 263 | Puid (CSPRNG) : 0.958437 264 | ``` 265 | 266 | [TOC](#TOC) 267 | 268 | ### [misc_random](https://github.com/gutschilla/elixir-helper-random) 269 | 270 | #### Comments 271 | 272 | - Entropy source: No control. Fixed to PRNG `:random.uniform/1` 273 | - Characters: No control. Fixed to `:alphanum` 274 | - Captured entropy: Indirectly specified via ID length 275 | 276 | #### Timing 277 | 278 | Quite slow compared to **PUID** 279 | 280 | ```code 281 | Generate 50000 random IDs with 128 bits of entropy using alphanum characters 282 | 283 | Misc.Random (PRNG) : 12.196646 284 | Puid (PRNG) : 0.295741 285 | 286 | Misc.Random (CSPRNG) : 11.9858 287 | Puid (CSPRNG) : 0.310417 288 | ``` 289 | 290 | [TOC](#TOC) 291 | 292 | ### [nanoid](https://github.com/railsmechanic/nanoid) 293 | 294 | #### Comments: 295 | 296 | - Entropy source: Limited control; choice of CSPRNG or PRNG 297 | - Characters: Full control 298 | - Captured entropy: Indirectly specified via ID length 299 | 300 | #### Timing: 301 | 302 | **nanoid** is much slower than **PUID** 303 | 304 | ``` 305 | Generate 75000 random IDs with 126 bits of entropy using safe64 characters 306 | 307 | Nanoid (CSPRNG) : 6.354221 308 | Puid (CSPRNG) : 0.226448 309 | 310 | Nanoid (PRNG) : 1.229842 311 | Puid (PRNG) : 0.31025 312 | 313 | Generate 75000 random IDs with 195 bits of entropy using alphanum characters 314 | 315 | Nanoid (CSPRNG) : 10.295134 316 | Puid (CSPRNG) : 0.809756 317 | 318 | Nanoid (PRNG) : 1.678025 319 | Puid (PRNG) : 0.808203 320 | ``` 321 | 322 | [TOC](#TOC) 323 | 324 | ### [Randomizer](https://github.com/jeremytregunna/randomizer) 325 | 326 | #### Comments 327 | 328 | - Entropy source: No control 329 | - Characters: Limited to five pre-defined character sets 330 | - Captured entropy: Indirectly specified via ID length 331 | 332 | #### Timing 333 | 334 | Slower than **PUID** 335 | 336 | ``` 337 | Generate 100000 random IDs with 128 bits of entropy using alphanum characters 338 | 339 | Randomizer (PRNG) : 1.201281 340 | Puid (PRNG) : 0.829199 341 | 342 | Randomizer (CSPRNG) : 4.329881 343 | Puid (CSPRNG) : 0.807226 344 | ``` 345 | 346 | [TOC](#TOC) 347 | 348 | ### [SecureRandom](https://github.com/patricksrobertson/secure_random.ex) 349 | 350 | #### Comments 351 | 352 | - Entropy source: No control. Fixed to `:crypto.strong_rand_bytes/1` 353 | - Characters: Limited control for 3 specified use cases 354 | - Captured entropy: Indirectly specified via ID length 355 | 356 | #### Timing 357 | 358 | About the same as **PUID** when using CSPRNG 359 | 360 | ``` 361 | Generate 500000 random IDs with 128 bits of entropy using hex characters 362 | 363 | SecureRandom (CSPRNG) : 1.19713 364 | Puid (CSPRNG) : 1.187726 365 | 366 | Generate 500000 random IDs with 128 bits of entropy using safe64 characters 367 | 368 | SecureRandom (CSPRNG) : 2.103798 369 | Puid (CSPRNG) : 1.806514 370 | ``` 371 | 372 | [TOC](#TOC) 373 | 374 | ### [ulid](https://github.com/ulid/spec) 375 | 376 | #### Comments 377 | 378 | - Entropy source: No control. Fixed to CSPRNG (per spec) 379 | - Characters: No control. Fixed to :base32 380 | - Captured entropy: 80-bits per timestamp context 381 | 382 | A significant characteristic of **ulid** is the generation of lexicographically sortable IDs. This is not a goal for **PUID**; however, one could use **PUID** to generate such IDs by prefixing a timestamp to a generated **puid**. Such a solution would be similar to **ulid** while still providing full control to **entropy source**, **characters**, and **captured entropy** per timestamp context. 383 | 384 | #### Timing 385 | 386 | **ulid** and **PUID** are not directly comparable with regard to speed. 387 | 388 | [TOC](#TOC) 389 | 390 | ### [UUID](https://github.com/zyro/elixir-uuid) 391 | 392 | #### Comments 393 | 394 | - Entropy source: No control. Fixed to `crypto.strong_rand_bytes/1` 395 | - Character: No control. Furthermore, string representation is inefficient 396 | - Capture entropy: No control. Fixed to 122 bits 397 | 398 | #### Timing 399 | 400 | Similar to **PUID** when using CSPRNG 401 | 402 | ```code 403 | Generate 500000 random IDs with 122 bits of entropy using hex 404 | UUID : 1.925131 405 | Puid hex : 1.823116 406 | 407 | Generate 500000 random IDs with 122 bits of entropy using safe64 408 | UUID : 1.751625 409 | Puid safe64 : 1.367201 410 | ``` 411 | 412 | [TOC](#TOC) 413 | -------------------------------------------------------------------------------- /lib/puid.ex: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # 3 | # Copyright (c) 2019-2023 Knoxen 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in all 13 | # copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | # SOFTWARE. 22 | 23 | defmodule Puid do 24 | @moduledoc """ 25 | 26 | Simple, fast, flexible and efficient generation of probably unique identifiers (`puid`, aka 27 | random strings) of intuitively specified entropy using pre-defined or custom characters. 28 | 29 | ## Overview 30 | 31 | `Puid` provides fast and efficient generation of random IDs. For the purposes of `Puid`, a random 32 | ID is considered a random string used in a context of uniqueness, that is, random IDs are a bunch 33 | of random strings that are hopefully unique. 34 | 35 | Random string generation can be thought of as a _transformation_ of some random source of entropy 36 | into a string _representation_ of randomness. A general purpose random string library used for 37 | random IDs should therefore provide user specification for each of the following three key 38 | aspects: 39 | 40 | ### Entropy source 41 | 42 | What source of randomness is being transformed? `Puid` allows easy specification of the function 43 | used for source randomness. 44 | 45 | ### ID characters 46 | 47 | What characters are used in the ID? `Puid` provides 16 pre-defined character sets, as well as 48 | allows custom character designation, including Unicode 49 | 50 | ### ID randomness 51 | 52 | What is the resulting “randomness” of the IDs? Note this isn't necessarily the same as the 53 | randomness of the entropy source. `Puid` allows explicit specification of ID randomness in an 54 | intuitive manner. 55 | 56 | 57 | ## Examples 58 | 59 | Creating a random ID generator using `Puid` is a simple as: 60 | 61 | ```elixir 62 | iex> defmodule(RandId, do: use(Puid)) 63 | iex> RandId.generate() 64 | "8nGA2UaIfaawX-Og61go5A" 65 | ``` 66 | 67 | Options allow easy and complete control of ID generation. 68 | 69 | ### Entropy Source 70 | 71 | `Puid` uses 72 | [:crypto.strong_rand_bytes/1](https://www.erlang.org/doc/man/crypto.html#strong_rand_bytes-1) as 73 | the default entropy source. The `rand_bytes` option can be used to specify any function of the 74 | form `(non_neg_integer) -> binary` as the source: 75 | 76 | ```elixir 77 | iex > defmodule(PrngPuid, do: use(Puid, rand_bytes: &:rand.bytes/1)) 78 | iex> PrngPuid.generate() 79 | "bIkrSeU6Yr8_1WHGvO0H3M" 80 | ``` 81 | 82 | ### ID Characters 83 | 84 | By default, `Puid` use the [RFC 4648](https://tools.ietf.org/html/rfc4648#section-5) file system & 85 | URL safe characters. The `chars` option can by used to specify any of 16 [pre-defined character 86 | sets](#Chars) or custom characters, including Unicode: 87 | 88 | ```elixir 89 | iex> defmodule(HexPuid, do: use(Puid, chars: :hex)) 90 | iex> HexPuid.generate() 91 | "13fb81e35cb89e5daa5649802ad4bbbd" 92 | 93 | iex> defmodule(DingoskyPuid, do: use(Puid, chars: "dingosky")) 94 | iex> DingoskyPuid.generate() 95 | "yiidgidnygkgydkodggysonydodndsnkgksgonisnko" 96 | 97 | iex> defmodule(DingoskyUnicodePuid, do: use(Puid, chars: "dîñgø$kyDÎÑGØßK¥", total: 2.5e6, risk: 1.0e15)) 98 | iex> DingoskyUnicodePuid.generate() 99 | "øßK$ggKñø$dyGîñdyØøØÎîk" 100 | 101 | ``` 102 | 103 | ### ID Randomness 104 | 105 | Generated IDs have 128-bit entropy by default. `Puid` provides a simple, intuitive way to specify 106 | ID randomness by declaring a `total` number of possible IDs with a specified `risk` of a repeat in 107 | that many IDs: 108 | 109 | To generate up to _10 million_ random IDs with _1 in a trillion_ chance of repeat: 110 | 111 | ```elixir 112 | iex> defmodule(MyPuid, do: use(Puid, total: 10.0e6, risk: 1.0e15)) 113 | iex> MyPuid.generate() 114 | "T0bFZadxBYVKs5lA" 115 | ``` 116 | 117 | The `bits` option can be used to directly specify an amount of ID randomness: 118 | 119 | ```elixir 120 | iex> defmodule(Token, do: use(Puid, bits: 256, chars: :hex_upper)) 121 | iex> Token.generate() 122 | "6E908C2A1AA7BF101E7041338D43B87266AFA73734F423B6C3C3A17599F40F2A" 123 | ``` 124 | 125 | ## Module API 126 | 127 | Module functions: 128 | 129 | - **generate/0**: Generate a random **puid** 130 | - **total/1**: total **puid**s which can be generated at a specified `risk` 131 | - **risk/1**: risk of generating `total` **puid**s 132 | - **encode/1**: Encode `bytes` into a **puid** 133 | - **decode/1**: Decode a `puid` into **bytes** 134 | - **info/0**: Module information 135 | 136 | The `total/1`, `risk/1` functions provide approximations to the **risk** of a repeat in some **total** number of generated **puid**s. The mathematical approximations used purposely _overestimate_ **risk** and _underestimate_ **total**. 137 | 138 | The `encode/1`, `decode/1` functions convert **puid**s to and from **bits** to facilitate binary data storage, e.g. as an **Ecto** type. Note that for efficiency `Puid` operates at a bit level, so `decode/1` of a **puid** produces _representative_ bytes such that `encode/1` of those **bytes** produces the same **puid**. The **bytes** are the **puid** specific _bitstring_ with 0 bit values appended to the ending byte boundary. 139 | 140 | The `info/0` function returns a `Puid.Info` structure consisting of: 141 | 142 | - source characters 143 | - name of pre-defined `Puid.Chars` or `:custom` 144 | - entropy bits per character 145 | - total entropy bits 146 | - may be larger than the specified `bits` since it is a multiple of the entropy bits per 147 | character 148 | - entropy representation efficiency 149 | - ratio of the **puid** entropy to the bits required for **puid** string representation 150 | - entropy source function 151 | - **puid** string length 152 | 153 | #### Example 154 | 155 | ```elixir 156 | iex> defmodule(SafeId, do: use(Puid)) 157 | 158 | iex> SafeId.generate() 159 | "CSWEPL3AiethdYFlCbSaVC" 160 | 161 | iex> SafeId.total(1_000_000) 162 | 104350568690606000 163 | 164 | iex> SafeId.risk(1.0e12) 165 | 9007199254740992 166 | 167 | iex> SafeId.decode("CSWEPL3AiethdYFlCbSaVC") 168 | <<9, 37, 132, 60, 189, 192, 137, 235, 97, 117, 129, 101, 9, 180, 154, 84, 32>> 169 | 170 | iex> SafeId.encode(<<9, 37, 132, 60, 189, 192, 137, 235, 97, 117, 129, 101, 9, 180, 154, 84, 32>>) 171 | "CSWEPL3AiethdYFlCbSaVC" 172 | 173 | iex> SafeId.info() 174 | %Puid.Info{ 175 | characters: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_", 176 | char_set: :safe64, 177 | entropy_bits: 132.0, 178 | entropy_bits_per_char: 6.0, 179 | ere: 0.75, 180 | length: 22, 181 | rand_bytes: &:crypto.strong_rand_bytes/1 182 | } 183 | ``` 184 | 185 | """ 186 | 187 | import Puid.Entropy 188 | import Puid.Util 189 | 190 | @type t :: binary 191 | 192 | @doc false 193 | defmacro __using__(opts) do 194 | quote do 195 | alias Puid.Chars 196 | 197 | puid_default = %Puid.Info{} 198 | 199 | chars = unquote(opts)[:chars] 200 | 201 | bits = unquote(opts)[:bits] 202 | risk = unquote(opts)[:risk] 203 | total = unquote(opts)[:total] 204 | 205 | {puid_charlist, puid_char_set} = 206 | if is_nil(chars) do 207 | {puid_default.characters |> to_charlist(), puid_default.char_set} 208 | else 209 | charlist = Chars.charlist!(chars) 210 | if is_atom(chars), do: {charlist, chars}, else: {charlist, :custom} 211 | end 212 | 213 | chars_encoding = Chars.encoding(puid_charlist) 214 | 215 | if !is_nil(total) and is_nil(risk), 216 | do: raise(Puid.Error, "Must specify risk when specifying total") 217 | 218 | if is_nil(total) and !is_nil(risk), 219 | do: raise(Puid.Error, "Must specify total when specifying risk") 220 | 221 | entropy_bits = 222 | cond do 223 | is_nil(bits) and is_nil(total) -> 224 | puid_default.entropy_bits 225 | 226 | is_number(bits) and bits < 1 -> 227 | raise Puid.Error, "Invalid bits. Must be greater than 1" 228 | 229 | is_number(bits) -> 230 | bits 231 | 232 | !is_nil(bits) -> 233 | raise Puid.Error, "Invalid bits. Must be numeric" 234 | 235 | true -> 236 | bits(total, risk) 237 | end 238 | 239 | rand_bytes = unquote(opts[:rand_bytes]) || (&:crypto.strong_rand_bytes/1) 240 | 241 | if !is_function(rand_bytes), do: raise(Puid.Error, "rand_bytes not a function") 242 | 243 | if :erlang.fun_info(rand_bytes)[:arity] !== 1, 244 | do: raise(Puid.Error, "rand_bytes not arity 1") 245 | 246 | chars_count = length(puid_charlist) 247 | entropy_bits_per_char = :math.log2(chars_count) 248 | puid_len = (entropy_bits / entropy_bits_per_char) |> :math.ceil() |> round() 249 | 250 | avg_rep_bits_per_char = 251 | puid_charlist 252 | |> to_string() 253 | |> byte_size() 254 | |> Kernel.*(8) 255 | |> Kernel./(chars_count) 256 | 257 | ere = (entropy_bits_per_char / avg_rep_bits_per_char) |> Float.round(2) 258 | 259 | puid_bits_per_char = log_ceil(chars_count) 260 | 261 | @entropy_bits entropy_bits_per_char * puid_len 262 | @bits_per_puid puid_len * puid_bits_per_char 263 | @puid_len puid_len 264 | 265 | defmodule __MODULE__.Bits, 266 | do: 267 | use(Puid.Bits, 268 | chars_count: chars_count, 269 | puid_len: puid_len, 270 | rand_bytes: rand_bytes 271 | ) 272 | 273 | if chars_encoding == :ascii do 274 | defmodule __MODULE__.Encoder, 275 | do: 276 | use(Puid.Encoder.ASCII, 277 | charlist: puid_charlist, 278 | bits_per_char: puid_bits_per_char, 279 | puid_len: puid_len 280 | ) 281 | 282 | defmodule __MODULE__.Decoder, 283 | do: 284 | use(Puid.Decoder.ASCII, 285 | charlist: puid_charlist, 286 | puid_len: puid_len 287 | ) 288 | else 289 | defmodule __MODULE__.Encoder, 290 | do: 291 | use(Puid.Encoder.Utf8, 292 | charlist: puid_charlist, 293 | bits_per_char: puid_bits_per_char, 294 | puid_len: puid_len 295 | ) 296 | end 297 | 298 | @doc """ 299 | Generate a `puid` 300 | """ 301 | @spec generate() :: String.t() 302 | def generate(), 303 | do: __MODULE__.Bits.generate() |> __MODULE__.Encoder.encode() 304 | 305 | @doc """ 306 | Encode `bits` into a `puid`. 307 | 308 | `bits` must contain enough bits to create a `puid`. The rest are ignored. 309 | """ 310 | @spec encode(bits :: bitstring()) :: String.t() | Puid.Error.t() 311 | def encode(bits) 312 | 313 | def encode(<<_::size(@bits_per_puid)>> = bits) do 314 | try do 315 | __MODULE__.Encoder.encode(bits) 316 | rescue 317 | _ -> 318 | {:error, "unable to encode"} 319 | end 320 | end 321 | 322 | def encode(_), 323 | do: {:error, "unable to encode"} 324 | 325 | @doc """ 326 | Decode `puid` into representative `bits`. 327 | 328 | `puid` must a representative **puid** from this module. 329 | 330 | NOTE: `decode/1` is not supported for non-ascii character sets 331 | """ 332 | @spec decode(puid :: String.t()) :: bitstring() | Puid.Error.t() 333 | def decode(puid) 334 | 335 | if chars_encoding == :ascii do 336 | def decode(puid), 337 | do: __MODULE__.Decoder.decode(puid) 338 | else 339 | def decode(_), 340 | do: {:error, "not supported for non-ascii characters sets"} 341 | end 342 | 343 | @doc """ 344 | Approximate **total** possible **puid**s at a specified `risk` 345 | """ 346 | @spec total(risk :: float()) :: integer() 347 | def total(risk), 348 | do: round(Puid.Entropy.total(@entropy_bits, risk)) 349 | 350 | @doc """ 351 | Approximate **risk** in genertating `total` **puid**s 352 | """ 353 | @spec risk(total :: float()) :: integer() 354 | def risk(total), 355 | do: round(Puid.Entropy.risk(@entropy_bits, total)) 356 | 357 | mod_info = %Puid.Info{ 358 | characters: puid_charlist |> to_string(), 359 | char_set: puid_char_set, 360 | entropy_bits_per_char: Float.round(entropy_bits_per_char, 2), 361 | entropy_bits: Float.round(@entropy_bits, 2), 362 | ere: ere, 363 | length: puid_len, 364 | rand_bytes: rand_bytes 365 | } 366 | 367 | @puid_mod_info mod_info 368 | 369 | @doc """ 370 | `Puid.Info` module info 371 | """ 372 | @spec info() :: %Puid.Info{} 373 | def info(), 374 | do: @puid_mod_info 375 | end 376 | end 377 | end 378 | -------------------------------------------------------------------------------- /lib/puid/bits.ex: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # 3 | # Copyright (c) 2019-2023 Knoxen 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in all 13 | # copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | # SOFTWARE. 22 | 23 | defmodule Puid.Bits do 24 | @moduledoc false 25 | 26 | import Bitwise 27 | import Puid.Util 28 | 29 | defmacro __using__(opts) do 30 | quote do 31 | chars_count = unquote(opts[:chars_count]) 32 | puid_len = unquote(opts)[:puid_len] 33 | rand_bytes = unquote(opts)[:rand_bytes] 34 | 35 | bits_per_char = log_ceil(chars_count) 36 | bits_per_puid = puid_len * bits_per_char 37 | bytes_per_puid = trunc(:math.ceil(bits_per_puid / 8)) 38 | 39 | base_value = if even?(chars_count), do: chars_count - 1, else: chars_count 40 | base_shift = {base_value, bits_per_char} 41 | 42 | bit_shifts = 43 | if pow2?(chars_count) do 44 | [base_shift] 45 | else 46 | (bits_per_char - 1)..2//-1 47 | |> Enum.reduce( 48 | [], 49 | fn bit, shifts -> 50 | if bit_zero?(base_value, bit) do 51 | [{base_value ||| pow2(bit) - 1, bits_per_char - bit + 1} | shifts] 52 | else 53 | shifts 54 | end 55 | end 56 | ) 57 | |> List.insert_at(0, base_shift) 58 | end 59 | 60 | {:module, mod} = rand_bytes |> Function.info(:module) 61 | {:name, name} = rand_bytes |> Function.info(:name) 62 | 63 | @puid_carried_bits String.to_atom("#{mod}_#{name}_puid_carried_bits") 64 | @puid_bit_shifts bit_shifts 65 | @puid_bits_per_char bits_per_char 66 | @puid_bits_per_puid bits_per_puid 67 | @puid_bytes_per_puid bytes_per_puid 68 | @puid_char_count chars_count 69 | @puid_len puid_len 70 | @puid_rand_bytes rand_bytes 71 | 72 | # If chars count is a power of 2, sliced bits always yield a valid char 73 | is_pow2? = pow2?(chars_count) 74 | 75 | @spec generate() :: bitstring() 76 | def generate() 77 | 78 | cond do 79 | is_pow2? and rem(bits_per_puid, 8) == 0 -> 80 | # Sliced bits always valid and no carried bits 81 | def generate(), do: @puid_rand_bytes.(@puid_bytes_per_puid) 82 | 83 | is_pow2? -> 84 | # Sliced bits always valid with carried bits 85 | def generate() do 86 | carried_bits = Process.get(@puid_carried_bits, <<>>) 87 | 88 | <> = 89 | generate_bits(@puid_len, carried_bits) 90 | 91 | Process.put(@puid_carried_bits, unused_bits) 92 | 93 | <> 94 | end 95 | 96 | true -> 97 | # Always manage carried bits since bit slices can be rejected with variable shift 98 | def generate(), 99 | do: generate(@puid_len, Process.get(@puid_carried_bits, <<>>), <<>>) 100 | 101 | defp generate(0, unused_bits, puid_bits) do 102 | Process.put(@puid_carried_bits, unused_bits) 103 | puid_bits 104 | end 105 | 106 | defp generate(char_count, carried_bits, puid_bits) do 107 | bits = generate_bits(char_count, carried_bits) 108 | 109 | {sliced_count, unused_bits, acc_bits} = slice(char_count, 0, bits, puid_bits) 110 | 111 | generate(char_count - sliced_count, unused_bits, acc_bits) 112 | end 113 | end 114 | 115 | @spec reset() :: no_return() 116 | def reset(), 117 | do: Process.put(@puid_carried_bits, <<>>) 118 | 119 | defp generate_bits(char_count, carried_bits) do 120 | num_bits_needed = char_count * @puid_bits_per_char - bit_size(carried_bits) 121 | 122 | if num_bits_needed <= 0 do 123 | carried_bits 124 | else 125 | new_bytes = 126 | (num_bits_needed / 8) 127 | |> :math.ceil() 128 | |> round() 129 | |> @puid_rand_bytes.() 130 | 131 | <> 132 | end 133 | end 134 | 135 | defp slice(0, sliced, bits, acc_bits), 136 | do: {sliced, bits, acc_bits} 137 | 138 | defp slice(count, sliced, bits, acc_bits) do 139 | <> = bits 140 | 141 | if value < @puid_char_count do 142 | <<_used::@puid_bits_per_char, rest::bits>> = bits 143 | 144 | # IO.puts("accept #{value}") 145 | 146 | slice( 147 | count - 1, 148 | sliced + 1, 149 | <>, 150 | <> 151 | ) 152 | else 153 | {_, bit_shift} = 154 | @puid_bit_shifts 155 | |> Enum.find(fn {shift_value, _} -> value <= shift_value end) 156 | 157 | # IO.puts("reject #{value} --> #{bit_shift}") 158 | 159 | <<_used::size(bit_shift), rest::bits>> = bits 160 | 161 | slice( 162 | count - 1, 163 | sliced, 164 | <>, 165 | <> 166 | ) 167 | end 168 | end 169 | end 170 | end 171 | end 172 | -------------------------------------------------------------------------------- /lib/puid/chars.ex: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # 3 | # Copyright (c) 2019-2023 Knoxen 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in all 13 | # copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | # SOFTWARE. 22 | 23 | defmodule Puid.Chars do 24 | @moduledoc """ 25 | 26 | Pre-defined character sets for use when creating `Puid` modules. 27 | 28 | ## Example 29 | 30 | iex> defmodule(AlphanumId, do: use(Puid, chars: :alphanum)) 31 | 32 | ## Pre-defined Chars 33 | 34 | ### :alpha 35 | Upper/lower case alphabet 36 | 37 | ```none 38 | ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz 39 | ``` 40 | bits per character: `5.7` 41 | 42 | ### :alpha_lower 43 | Lower case alphabet 44 | ```none 45 | abcdefghijklmnopqrstuvwxyz 46 | ``` 47 | bits per character: `4.7` 48 | 49 | ### :alpha_upper 50 | Upper case alphabet 51 | ```none 52 | ABCDEFGHIJKLMNOPQRSTUVWXYZ 53 | ``` 54 | bits per character: `4.7` 55 | 56 | ### :alphanum 57 | Upper/lower case alphabet and numbers 58 | ```none 59 | ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 60 | ``` 61 | bits per character: `5.95` 62 | 63 | ### :alphanum_lower 64 | Lower case alphabet and numbers 65 | ```none 66 | abcdefghijklmnopqrstuvwxyz0123456789 67 | ``` 68 | bits per character: `5.17` 69 | 70 | ### :alphanum_upper 71 | Upper case alphabet and numbers 72 | ```none 73 | ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789 74 | ``` 75 | bits per character: `5.17` 76 | 77 | ### :base16 78 | [RFC 4648](https://tools.ietf.org/html/rfc4648#section-8) base16 character set 79 | ``` 80 | 0123456789ABCDEF 81 | ``` 82 | bits per character: `4` 83 | 84 | ### :base32 85 | [RFC 4648](https://tools.ietf.org/html/rfc4648#section-6) base32 character set 86 | ```none 87 | ABCDEFGHIJKLMNOPQRSTUVWXYZ234567 88 | ``` 89 | bits per character: `5` 90 | 91 | ### :base32_hex 92 | [RFC 4648](https://tools.ietf.org/html/rfc4648#section-7) base32 extended hex character set 93 | with lowercase letters 94 | ```none 95 | 0123456789abcdefghijklmnopqrstuv 96 | ``` 97 | bits per character: `5` 98 | 99 | ### :base32_hex_upper 100 | [RFC 4648](https://tools.ietf.org/html/rfc4648#section-7) base32 extended hex character set 101 | ```none 102 | 0123456789ABCDEFGHIJKLMNOPQRSTUV 103 | ``` 104 | bits per character: `5` 105 | 106 | ### :crockford32 107 | [Crockford 32](https://www.crockford.com/base32.html) 108 | ```none 109 | 0123456789ABCDEFGHJKMNPQRSTVWXYZ 110 | ``` 111 | 112 | ### :decimal 113 | Decimal digits 114 | ```none 115 | 0123456789 116 | ``` 117 | bits per character: `3.32` 118 | 119 | ### :hex 120 | Lowercase hexadecimal 121 | ```none 122 | 0123456789abcdef 123 | ``` 124 | bits per character: `4` 125 | 126 | ### :hex_upper 127 | Uppercase hexadecimal 128 | ```none 129 | 0123456789ABCDEF 130 | ``` 131 | bits per character: `4` 132 | 133 | ### :safe_ascii 134 | ASCII characters from `?!` to `?~`, minus backslash, backtick, single-quote and double-quote 135 | 136 | ```none 137 | `!#$%&()*+,-./0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[]^_\abcdefghijklmnopqrstuvwxyz{|}~` 138 | ``` 139 | bits per character: `6.49` 140 | 141 | ### :safe32 142 | Strings that don't look like English words and are easier to parse visually 143 | ```none 144 | 2346789bdfghjmnpqrtBDFGHJLMNPQRT 145 | ``` 146 | - remove all upper and lower case vowels (including y) 147 | - remove all numbers that look like letters 148 | - remove all letters that look like numbers 149 | - remove all letters that have poor distinction between upper and lower case values 150 | 151 | bits per character: `6.49` 152 | 153 | ### :safe64 154 | [RFC 4648](https://tools.ietf.org/html/rfc4648#section-5) file system and URL safe character set 155 | ```none 156 | ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_ 157 | ``` 158 | bits per character: `6` 159 | 160 | ### :symbol 161 | :safe_ascii characters not in :alphanum 162 | 163 | ```none 164 | `!#$%&()*+,-./:;<=>?@[]^_{|}~` 165 | ``` 166 | bits per character: `4.81` 167 | 168 | ### :wordSafe32 169 | Strings that don't look like English words 170 | ```none 171 | 23456789CFGHJMPQRVWXcfghjmpqrvwx 172 | ``` 173 | Origin unknown 174 | 175 | bits per character: `6.49` 176 | 177 | """ 178 | 179 | @typedoc """ 180 | Chars can be designated by a pre-defined atom, a binary or a charlist 181 | """ 182 | @type puid_chars() :: atom() | String.t() | charlist() 183 | 184 | @typedoc """ 185 | Character encoding scheme. `:ascii` encoding uses cross-product character pairs. 186 | """ 187 | @type puid_encoding() :: :ascii | :utf8 188 | 189 | ## 190 | ## Chars count max is 256 due to optimized bit slicing scheme 191 | ## 192 | @chars_count_max 256 193 | 194 | ## ----------------------------------------------------------------------------------------------- 195 | ## `charlist` of characters 196 | ## ----------------------------------------------------------------------------------------------- 197 | @doc """ 198 | `charlist` for a pre-defined `Puid.Chars`, a String.t() or a charlist. 199 | 200 | The characters for either String.t() or charlist types must be unique, have more than one 201 | character, and not be invalid ascii. 202 | 203 | ## Example 204 | 205 | iex> Puid.Chars.charlist(:safe32) 206 | {:ok, ~c"2346789bdfghjmnpqrtBDFGHJLMNPQRT"} 207 | 208 | iex> Puid.Chars.charlist("dingosky") 209 | {:ok, ~c"dingosky"} 210 | 211 | iex> Puid.Chars.charlist("unique") 212 | {:error, "Characters not unique"} 213 | """ 214 | @spec charlist(puid_chars()) :: {:ok, charlist()} | Puid.Error.t() 215 | def charlist(chars) do 216 | try do 217 | {:ok, charlist!(chars)} 218 | rescue 219 | error in Puid.Error -> 220 | {:error, error.message} 221 | end 222 | end 223 | 224 | @doc """ 225 | 226 | Same as `charlist/1` but either returns __charlist__ or raises a `Puid.Error` 227 | 228 | ## Example 229 | 230 | iex> Puid.Chars.charlist!(:safe32) 231 | ~c"2346789bdfghjmnpqrtBDFGHJLMNPQRT" 232 | 233 | iex> Puid.Chars.charlist!("dingosky") 234 | ~c"dingosky" 235 | 236 | iex> Puid.Chars.charlist!("unique") 237 | # (Puid.Error) Characters not unique 238 | """ 239 | @spec charlist!(puid_chars()) :: charlist() | Puid.Error.t() 240 | def charlist!(chars) 241 | 242 | def charlist!(:alpha), do: charlist!(:alpha_upper) ++ charlist!(:alpha_lower) 243 | def charlist!(:alpha_lower), do: Enum.to_list(?a..?z) 244 | def charlist!(:alpha_upper), do: Enum.to_list(?A..?Z) 245 | def charlist!(:alphanum), do: charlist!(:alpha) ++ charlist!(:decimal) 246 | def charlist!(:alphanum_lower), do: charlist!(:alpha_lower) ++ charlist!(:decimal) 247 | def charlist!(:alphanum_upper), do: charlist!(:alpha_upper) ++ charlist!(:decimal) 248 | def charlist!(:base32), do: charlist!(:alpha_upper) ++ ~c"234567" 249 | def charlist!(:base32_hex), do: charlist!(:decimal) ++ Enum.to_list(?a..?v) 250 | def charlist!(:base32_hex_upper), do: charlist!(:decimal) ++ Enum.to_list(?A..?V) 251 | def charlist!(:crockford32), do: charlist!(:decimal) ++ (charlist!(:alpha_upper) -- ~c"ILOU") 252 | def charlist!(:decimal), do: Enum.to_list(?0..?9) 253 | def charlist!(:hex), do: charlist!(:decimal) ++ Enum.to_list(?a..?f) 254 | def charlist!(:hex_upper), do: charlist!(:decimal) ++ Enum.to_list(?A..?F) 255 | def charlist!(:safe_ascii), do: ?!..?~ |> Enum.filter(&safe_ascii?(&1)) 256 | def charlist!(:safe32), do: ~c"2346789bdfghjmnpqrtBDFGHJLMNPQRT" 257 | 258 | def charlist!(:safe64), 259 | do: charlist!(:alpha_upper) ++ charlist!(:alpha_lower) ++ charlist!(:decimal) ++ ~c"-_" 260 | 261 | def charlist!(:symbol) do 262 | alphanum = charlist!(:alphanum) 263 | :safe_ascii |> charlist!() |> Enum.filter(&(!Enum.member?(alphanum, &1))) 264 | end 265 | 266 | def charlist!(:wordSafe32), do: ~c"23456789CFGHJMPQRVWXcfghjmpqrvwx" 267 | 268 | def charlist!(charlist) when is_atom(charlist), 269 | do: raise(Puid.Error, "Invalid pre-defined charlist: :#{charlist}") 270 | 271 | def charlist!(chars) when is_binary(chars), 272 | do: chars |> to_charlist() |> validate_charlist() 273 | 274 | def charlist!(charlist) when is_list(charlist), do: validate_charlist(charlist) 275 | 276 | @doc false 277 | @spec encoding(charlist() | String.t()) :: puid_encoding() 278 | def encoding(charlist_or_chars) 279 | 280 | def encoding(chars) when is_binary(chars) do 281 | chars |> to_charlist() |> encoding() 282 | end 283 | 284 | def encoding(charlist) when is_list(charlist) do 285 | charlist 286 | |> Enum.reduce(:ascii, fn code_point, encoding -> 287 | cond do 288 | code_point < 0x007F and safe_ascii?(code_point) -> 289 | encoding 290 | 291 | safe_code_point?(code_point) -> 292 | :utf8 293 | 294 | true -> 295 | raise(Puid.Error, "Invalid char") 296 | end 297 | end) 298 | end 299 | 300 | @doc false 301 | # Validate that: 302 | # - at least 2 code points 303 | # - no more than max code points 304 | # - unique code points 305 | # - valid code points 306 | def validate_charlist(charlist) when is_list(charlist) do 307 | len = length(charlist) 308 | if len < 2, do: raise(Puid.Error, "Need at least 2 characters") 309 | 310 | if @chars_count_max < len, 311 | do: raise(Puid.Error, "Character count cannot be greater than #{@chars_count_max}") 312 | 313 | if !unique?(charlist, %{}), do: raise(Puid.Error, "Characters not unique") 314 | 315 | charlist 316 | |> Enum.reduce(true, fn code_point, acc -> 317 | acc and safe_code_point?(code_point) 318 | end) 319 | |> case do 320 | false -> 321 | raise(Puid.Error, "Invalid code point") 322 | 323 | _ -> 324 | charlist 325 | end 326 | end 327 | 328 | # Prevent "unsafe" code points 329 | defp safe_code_point?(cp) when cp < 0x007F, do: safe_ascii?(cp) 330 | defp safe_code_point?(cp), do: safe_utf8?(cp) 331 | 332 | # Safe ascii code points are chars from ?! to ?~, 333 | # omitting backslash, backtick and single/double-quotes 334 | defp safe_ascii?(cp) when cp < 0x0021, do: false 335 | defp safe_ascii?(0x0022), do: false 336 | defp safe_ascii?(0x0027), do: false 337 | defp safe_ascii?(0x005C), do: false 338 | defp safe_ascii?(0x0060), do: false 339 | defp safe_ascii?(cp) when cp < 0x007F, do: true 340 | defp safe_ascii?(_), do: false 341 | 342 | # Reject code points between tilde and inverse bang 343 | # CxNote There may be other utf8 code points that should be invalid. 344 | defp safe_utf8?(g) when g < 0x00A1, do: false 345 | defp safe_utf8?(_), do: true 346 | 347 | # Are charlist characters unique? 348 | defp unique?([], no_repeat?), do: no_repeat? 349 | 350 | defp unique?([char | charlist], seen) do 351 | if seen[char], do: unique?([], false), else: unique?(charlist, seen |> Map.put(char, true)) 352 | end 353 | end 354 | -------------------------------------------------------------------------------- /lib/puid/decoder/ascii.ex: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # 3 | # Copyright (c) 2019-2023 Knoxen 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in all 13 | # copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | # SOFTWARE. 22 | 23 | defmodule Puid.Decoder.ASCII do 24 | import Puid.Util 25 | 26 | defmacro __using__(opts) do 27 | quote do 28 | charlist = unquote(opts)[:charlist] 29 | puid_len = unquote(opts)[:puid_len] 30 | 31 | char_count = length(charlist) 32 | 33 | bits_per_char = log_ceil(char_count) 34 | bits_per_puid = puid_len * bits_per_char 35 | 36 | @puid_len puid_len 37 | @puid_charlist charlist 38 | @puid_bits_per_char bits_per_char 39 | @puid_bits_per_pair 2 * bits_per_char 40 | 41 | @spec decode(puid :: String.t()) :: bitstring() | Puid.Error.t() 42 | def decode(puid) 43 | 44 | def decode(<<_::binary-size(@puid_len)>> = puid) do 45 | try do 46 | puid |> decode_puid_into(<<>>) 47 | rescue 48 | _ -> 49 | {:error, "unable to decode"} 50 | end 51 | end 52 | 53 | def decode(_), 54 | do: {:error, "unable to decode"} 55 | 56 | @spec decode_puid_into(bytes :: binary(), bits :: bitstring()) :: bitstring() 57 | defp decode_puid_into(bytes, bits) 58 | 59 | defp decode_puid_into(<<>>, bits), 60 | do: bits 61 | 62 | defp decode_puid_into(<>, bits) do 63 | c_bits = decode_single(c) 64 | <> 65 | end 66 | 67 | defp decode_puid_into(<>, bits) do 68 | cc_bits = decode_pair(cc) 69 | decode_puid_into(rest, <>) 70 | end 71 | 72 | defp chars_values(), do: @puid_charlist |> Enum.with_index() 73 | 74 | defmacrop pair_decoder(cc) do 75 | quote do 76 | case unquote(cc) do 77 | unquote(pair_decoder_clauses()) 78 | end 79 | end 80 | end 81 | 82 | defp pair_decoder_clauses() do 83 | cv = chars_values() 84 | 85 | for {c1, v1} <- cv, {c2, v2} <- cv do 86 | cc = Bitwise.bsl(c1, 8) + c2 87 | v = Bitwise.bsl(v1, @puid_bits_per_char) + v2 88 | 89 | [clause] = quote(do: (unquote(cc) -> unquote(v))) 90 | clause 91 | end 92 | end 93 | 94 | defmacrop single_decoder(c) do 95 | quote do 96 | case unquote(c) do 97 | unquote(single_decoder_clauses()) 98 | end 99 | end 100 | end 101 | 102 | defp single_decoder_clauses() do 103 | for {c, v} <- chars_values() do 104 | [clause] = quote(do: (unquote(c) -> unquote(v))) 105 | clause 106 | end 107 | end 108 | 109 | def decode_pair(cc) do 110 | vv = pair_decoder(cc) 111 | <> 112 | end 113 | 114 | def decode_single(c) do 115 | v = single_decoder(c) 116 | <> 117 | end 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /lib/puid/encoder/ascii.ex: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # 3 | # Copyright (c) 2019-2023 Knoxen 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in all 13 | # copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | # SOFTWARE. 22 | 23 | defmodule Puid.Encoder.ASCII do 24 | @moduledoc false 25 | 26 | defmacro __using__(opts) do 27 | quote do 28 | charlist = unquote(opts)[:charlist] 29 | bits_per_char = unquote(opts)[:bits_per_char] 30 | puid_len = unquote(opts)[:puid_len] 31 | 32 | puid_size = puid_len * bits_per_char 33 | single_chunk_size = 8 * bits_per_char 34 | pair_chunk_size = 2 * single_chunk_size 35 | pair_chunks_size = max(div(puid_size, pair_chunk_size) * pair_chunk_size, pair_chunk_size) 36 | 37 | @puid_bits_per_char bits_per_char 38 | @puid_bits_per_pair 2 * bits_per_char 39 | @puid_charlist charlist 40 | @puid_char_count length(charlist) 41 | 42 | @puid_single_chunk_size single_chunk_size 43 | @puid_pair_chunk_size pair_chunk_size 44 | @puid_pair_chunks_size pair_chunks_size 45 | 46 | @spec encode(bits :: bitstring()) :: String.t() 47 | def encode(bits) 48 | 49 | cond do 50 | # Less than a single chunk 51 | puid_size < single_chunk_size -> 52 | def encode(bits), 53 | do: encode_singles(bits) 54 | 55 | # Equal to a single chunk 56 | puid_size == single_chunk_size -> 57 | def encode(bits), 58 | do: encode_singles(bits) 59 | 60 | # Less than a pair chunk 61 | puid_size < pair_chunk_size -> 62 | def encode(bits) do 63 | << 64 | single_chunk::size(@puid_single_chunk_size)-bits, 65 | sub_chunk::bits 66 | >> = bits 67 | 68 | singles = encode_singles(single_chunk) 69 | subs = encode_singles(sub_chunk) 70 | 71 | <> 72 | end 73 | 74 | # Equal to one or more pair chunks 75 | puid_size == pair_chunks_size -> 76 | def encode(bits), 77 | do: encode_pairs(bits) 78 | 79 | # Less than one or more pair chunks plus a single chunk 80 | puid_size < pair_chunks_size + single_chunk_size -> 81 | def encode(bits) do 82 | << 83 | pair_chunks::size(@puid_pair_chunks_size)-bits, 84 | sub_chunk::bits 85 | >> = bits 86 | 87 | pairs = encode_pairs(pair_chunks) 88 | subs = encode_singles(sub_chunk) 89 | 90 | <> 91 | end 92 | 93 | # Equal to one or more pair chunks plus a single chunk 94 | puid_size == pair_chunks_size + single_chunk_size -> 95 | def encode(bits) do 96 | << 97 | pair_chunks::size(@puid_pair_chunks_size)-bits, 98 | single_chunk::size(@puid_single_chunk_size)-bits 99 | >> = bits 100 | 101 | pairs = encode_pairs(pair_chunks) 102 | singles = encode_singles(single_chunk) 103 | 104 | <> 105 | end 106 | 107 | # Greater than one or more pair chunks plus a single chunk 108 | true -> 109 | def encode(bits) do 110 | << 111 | pair_chunks::size(@puid_pair_chunks_size)-bits, 112 | single_chunk::size(@puid_single_chunk_size)-bits, 113 | sub_chunk::bits 114 | >> = bits 115 | 116 | pairs = encode_pairs(pair_chunks) 117 | singles = encode_singles(single_chunk) 118 | subs = encode_singles(sub_chunk) 119 | 120 | <> 121 | end 122 | end 123 | 124 | defmacrop pair_encoding(v) do 125 | quote do 126 | case unquote(v) do 127 | unquote(pair_encoding_clauses()) 128 | end 129 | end 130 | end 131 | 132 | defp pair_encoding_clauses() do 133 | cv = @puid_charlist |> Enum.with_index() 134 | 135 | for {c1, v1} <- cv, 136 | {c2, v2} <- cv do 137 | cc = Bitwise.bsl(c1, 8) + c2 138 | v = Bitwise.bsl(v1, @puid_bits_per_char) + v2 139 | 140 | [pair_clause] = quote(do: (unquote(v) -> unquote(cc))) 141 | pair_clause 142 | end 143 | end 144 | 145 | defmacrop single_encoding(v) do 146 | quote do 147 | case unquote(v) do 148 | unquote(single_encoding_clauses()) 149 | end 150 | end 151 | end 152 | 153 | defp single_encoding_clauses() do 154 | for {c, v} <- 155 | @puid_charlist 156 | |> Enum.with_index() do 157 | [single_clause] = quote(do: (unquote(v) -> unquote(c))) 158 | single_clause 159 | end 160 | end 161 | 162 | defp pair_encode(vv), do: pair_encoding(vv) 163 | 164 | defp single_encode(v), do: single_encoding(v) 165 | 166 | defp encode_pairs(<<>>), do: <<>> 167 | 168 | defp encode_pairs(vv_chunks) do 169 | for <>, 172 | into: <<>>, 173 | do: << 174 | pair_encode(vv1)::16, 175 | pair_encode(vv2)::16, 176 | pair_encode(vv3)::16, 177 | pair_encode(vv4)::16, 178 | pair_encode(vv5)::16, 179 | pair_encode(vv6)::16, 180 | pair_encode(vv7)::16, 181 | pair_encode(vv8)::16 182 | >> 183 | end 184 | 185 | defp encode_singles( 186 | <> 189 | ), 190 | do: << 191 | single_encode(v1)::8, 192 | single_encode(v2)::8, 193 | single_encode(v3)::8, 194 | single_encode(v4)::8, 195 | single_encode(v5)::8, 196 | single_encode(v6)::8, 197 | single_encode(v7)::8, 198 | single_encode(v8)::8 199 | >> 200 | 201 | defp encode_singles( 202 | <> 205 | ), 206 | do: << 207 | single_encode(v1)::8, 208 | single_encode(v2)::8, 209 | single_encode(v3)::8, 210 | single_encode(v4)::8, 211 | single_encode(v5)::8, 212 | single_encode(v6)::8, 213 | single_encode(v7)::8 214 | >> 215 | 216 | defp encode_singles( 217 | <> 219 | ), 220 | do: << 221 | single_encode(v1)::8, 222 | single_encode(v2)::8, 223 | single_encode(v3)::8, 224 | single_encode(v4)::8, 225 | single_encode(v5)::8, 226 | single_encode(v6)::8 227 | >> 228 | 229 | defp encode_singles( 230 | <> 232 | ), 233 | do: << 234 | single_encode(v1)::8, 235 | single_encode(v2)::8, 236 | single_encode(v3)::8, 237 | single_encode(v4)::8, 238 | single_encode(v5)::8 239 | >> 240 | 241 | defp encode_singles( 242 | <> 244 | ), 245 | do: << 246 | single_encode(v1)::8, 247 | single_encode(v2)::8, 248 | single_encode(v3)::8, 249 | single_encode(v4)::8 250 | >> 251 | 252 | defp encode_singles( 253 | <> 254 | ), 255 | do: << 256 | single_encode(v1)::8, 257 | single_encode(v2)::8, 258 | single_encode(v3)::8 259 | >> 260 | 261 | defp encode_singles(<>), 262 | do: << 263 | single_encode(v1)::8, 264 | single_encode(v2)::8 265 | >> 266 | 267 | defp encode_singles(<>), 268 | do: << 269 | single_encode(v1)::8 270 | >> 271 | 272 | defp encode_singles(<<>>), 273 | do: <<>> 274 | end 275 | end 276 | end 277 | -------------------------------------------------------------------------------- /lib/puid/encoder/utf8.ex: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # 3 | # Copyright (c) 2019-2023 Knoxen 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in all 13 | # copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | # SOFTWARE. 22 | 23 | defmodule Puid.Encoder.Utf8 do 24 | @moduledoc false 25 | 26 | defmacro __using__(opts) do 27 | quote do 28 | charlist = unquote(opts)[:charlist] 29 | bits_per_char = unquote(opts)[:bits_per_char] 30 | puid_len = unquote(opts)[:puid_len] 31 | 32 | puid_size = puid_len * bits_per_char 33 | single_chunk_size = 8 * bits_per_char 34 | single_chunks_size = div(puid_size, single_chunk_size) * single_chunk_size 35 | 36 | @puid_bits_per_char bits_per_char 37 | @puid_charlist charlist 38 | @puid_char_count length(charlist) 39 | @puid_single_chunks_size single_chunks_size 40 | 41 | @spec encode(bits :: bitstring()) :: String.t() 42 | def encode(bits) 43 | 44 | cond do 45 | # Less than a single chunk 46 | puid_size < @puid_single_chunks_size -> 47 | def encode(bits), do: encode_unchunked(bits) 48 | 49 | # Equal to one or more a single chunks 50 | puid_size == @puid_single_chunks_size -> 51 | def encode(bits), do: encode_singles(bits) 52 | 53 | # Not a multiple of a single chunks 54 | true -> 55 | def encode(bits) do 56 | << 57 | single_chunks::size(@puid_single_chunks_size)-bits, 58 | sub_chunk::bits 59 | >> = bits 60 | 61 | singles = encode_singles(single_chunks) 62 | subchunk = encode_unchunked(sub_chunk) 63 | 64 | <> 65 | end 66 | end 67 | 68 | defmacrop single_encoding(value) do 69 | quote do 70 | case unquote(value) do 71 | unquote(single_encoding_clauses()) 72 | end 73 | end 74 | end 75 | 76 | defp single_encoding_clauses() do 77 | for {char, value} <- 78 | @puid_charlist 79 | |> Enum.with_index() do 80 | [single_clause] = quote(do: (unquote(value) -> unquote(char))) 81 | single_clause 82 | end 83 | end 84 | 85 | def single_encode(char), do: single_encoding(char) 86 | 87 | defp encode_singles(chunks) do 88 | case chunks do 89 | <<>> -> 90 | <<>> 91 | 92 | _ -> 93 | for <>, 96 | into: <<>> do 97 | << 98 | single_encode(s1)::utf8, 99 | single_encode(s2)::utf8, 100 | single_encode(s3)::utf8, 101 | single_encode(s4)::utf8, 102 | single_encode(s5)::utf8, 103 | single_encode(s6)::utf8, 104 | single_encode(s7)::utf8, 105 | single_encode(s8)::utf8 106 | >> 107 | end 108 | end 109 | end 110 | 111 | defp encode_unchunked(chunk) do 112 | case chunk do 113 | <<>> -> 114 | <<>> 115 | 116 | <> -> 119 | << 120 | single_encode(s1)::utf8, 121 | single_encode(s2)::utf8, 122 | single_encode(s3)::utf8, 123 | single_encode(s4)::utf8, 124 | single_encode(s5)::utf8, 125 | single_encode(s6)::utf8, 126 | single_encode(s7)::utf8 127 | >> 128 | 129 | <> -> 131 | << 132 | single_encode(s1)::utf8, 133 | single_encode(s2)::utf8, 134 | single_encode(s3)::utf8, 135 | single_encode(s4)::utf8, 136 | single_encode(s5)::utf8, 137 | single_encode(s6)::utf8 138 | >> 139 | 140 | <> -> 142 | << 143 | single_encode(s1)::utf8, 144 | single_encode(s2)::utf8, 145 | single_encode(s3)::utf8, 146 | single_encode(s4)::utf8, 147 | single_encode(s5)::utf8 148 | >> 149 | 150 | <> -> 152 | << 153 | single_encode(s1)::utf8, 154 | single_encode(s2)::utf8, 155 | single_encode(s3)::utf8, 156 | single_encode(s4)::utf8 157 | >> 158 | 159 | <> -> 160 | << 161 | single_encode(s1)::utf8, 162 | single_encode(s2)::utf8, 163 | single_encode(s3)::utf8 164 | >> 165 | 166 | <> -> 167 | << 168 | single_encode(s1)::utf8, 169 | single_encode(s2)::utf8 170 | >> 171 | 172 | <> -> 173 | << 174 | single_encode(s1)::utf8 175 | >> 176 | end 177 | end 178 | end 179 | end 180 | end 181 | -------------------------------------------------------------------------------- /lib/puid/entropy.ex: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # 3 | # Copyright (c) 2019-2023 Knoxen 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in all 13 | # copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | # SOFTWARE. 22 | 23 | defmodule Puid.Entropy do 24 | @moduledoc """ 25 | [Entropy](https://en.wikipedia.org/wiki/Entropy_(information_theory)) related calculations 26 | 27 | The implementation is based on mathematical approximations to the solution of what is often 28 | referred to as the [Birthday 29 | Problem](https://en.wikipedia.org/wiki/Birthday_problem#Calculating_the_probability). 30 | """ 31 | 32 | @doc """ 33 | Entropy bits necessary to generate `total` number of `puid`s with `risk` risk of repeat. 34 | 35 | The total number of possible `puid`s is 2bits. 36 | Risk is expressed as a 1 in `risk` chance, so the probability of a repeat is `1/risk`. 37 | 38 | ## Example 39 | 40 | iex> Puid.Entropy.bits(10.0e6, 1.0e12) 41 | 85.37013046707142 42 | 43 | """ 44 | @spec bits(non_neg_integer(), non_neg_integer()) :: float() 45 | def bits(0, _), do: 0 46 | def bits(1, _), do: 0 47 | 48 | def bits(_, 0), do: 0 49 | def bits(_, 1), do: 0 50 | 51 | def bits(total, risk) do 52 | n = 53 | cond do 54 | total < 1000 -> 55 | :math.log2(total) + :math.log2(total - 1) 56 | 57 | true -> 58 | 2 * :math.log2(total) 59 | end 60 | 61 | n + :math.log2(risk) - 1 62 | end 63 | 64 | @doc """ 65 | Approximate total number of `puid`s which can be generated using `bits` bits entropy at a `risk` risk of repeat. 66 | 67 | The total number of possible `puid`s is 2bits. 68 | Risk is expressed as a 1 in `risk` chance, so the probability of a repeat is `1/risk`. 69 | 70 | Due to approximations used in the entropy calculation this value is also approximate; the approximation is 71 | conservative, however, so the calculated total will not exceed the specified `risk`. 72 | 73 | ## Example 74 | 75 | iex> bits = 64 76 | iex> risk = 1.0e9 77 | iex> Puid.Entropy.total(bits, risk) 78 | 192077 79 | 80 | """ 81 | @spec total(bits :: float(), risk :: float()) :: integer() 82 | def total(0, _), do: 0 83 | def total(1, _), do: 1 84 | 85 | def total(_, 0), do: 1 86 | def total(_, 1), do: 1 87 | 88 | def total(bits, risk) do 89 | round(2 ** ((bits + 1) / 2) * :math.sqrt(:math.log(risk / (risk - 1)))) 90 | end 91 | 92 | @doc """ 93 | Risk of repeat in `total` number of events with `bits` bits entropy. 94 | 95 | The total number of possible `puid`s is 2bits. 96 | Risk is expressed as a 1 in `risk` chance, so the probability of a repeat is `1/risk`. 97 | 98 | Due to approximations used in the entropy calculation this value is also approximate; the approximation is 99 | conservative, however, so the calculated risk will not be exceed for the specified `total`. 100 | 101 | ## Example 102 | 103 | iex> bits = 96 104 | iex> total = 1.0e7 105 | iex> Puid.Entropy.risk(bits, total) 106 | 1501199875790165 107 | iex> 1.0 / 1501199875790165 108 | 6.661338147750941e-16 109 | """ 110 | @spec risk(bits :: float(), total :: float()) :: integer() 111 | def risk(0, _), do: 0 112 | def risk(1, _), do: 1 113 | 114 | def risk(_, 0), do: 1 115 | def risk(_, 1), do: 1 116 | 117 | def risk(bits, total) do 118 | events = 2 ** bits 119 | exponent = -1.0 * ((total - 1) * total) / (2 * events) 120 | round(1 / (1 - :math.exp(exponent))) 121 | end 122 | 123 | @doc """ 124 | Entropy bits per `chars` character. 125 | 126 | `chars` must be valid as per `Chars.charlist/1`. 127 | 128 | ## Example 129 | 130 | iex> Puid.Entropy.bits_per_char(:alphanum) 131 | {:ok, 5.954196310386875} 132 | 133 | iex> Puid.Entropy.bits_per_char("dingosky") 134 | {:ok, 3.0} 135 | 136 | """ 137 | @spec bits_per_char(Puid.Chars.puid_chars()) :: {:ok, float()} | Puid.Error.t() 138 | def bits_per_char(chars) do 139 | with {:ok, charlist} <- chars |> Puid.Chars.charlist() do 140 | {:ok, charlist |> length() |> :math.log2()} 141 | else 142 | error -> 143 | error 144 | end 145 | end 146 | 147 | @doc """ 148 | Same as `bits_per_char/1` but either returns __bits__ or raises a `Puid.Error` 149 | 150 | ## Example 151 | 152 | iex> Puid.Entropy.bits_per_char!(:alphanum) 153 | 5.954196310386875 154 | 155 | Puid.Entropy.bits_per_char!("dingosky") 156 | 3.0 157 | 158 | """ 159 | @spec bits_per_char!(Puid.Chars.puid_chars()) :: float() 160 | def bits_per_char!(chars) do 161 | with {:ok, ebpc} <- bits_per_char(chars) do 162 | ebpc 163 | else 164 | {:error, reason} -> 165 | raise(Puid.Error, reason) 166 | end 167 | end 168 | 169 | @doc """ 170 | Entropy bits for a binary of length `len` comprised of `chars` characters. 171 | 172 | `chars` must be valid as per `Chars.charlist/1`. 173 | 174 | ## Example 175 | 176 | iex> Puid.Entropy.bits_for_len(:alphanum, 14) 177 | {:ok, 83} 178 | 179 | iex> Puid.Entropy.bits_for_len(~c'dingosky', 14) 180 | {:ok, 42} 181 | 182 | """ 183 | @spec bits_for_len(Puid.Chars.puid_chars(), non_neg_integer()) :: 184 | {:ok, non_neg_integer()} | Puid.Error.t() 185 | def bits_for_len(chars, len) do 186 | with {:ok, ebpc} <- bits_per_char(chars) do 187 | {:ok, (len * ebpc) |> trunc()} 188 | else 189 | error -> 190 | error 191 | end 192 | end 193 | 194 | @doc """ 195 | 196 | Same as `Puid.Entropy.bits_for_len/2` but either returns __bits__ or raises a 197 | `Puid.Error` 198 | 199 | ## Example 200 | 201 | iex> Puid.Entropy.bits_for_len!(:alphanum, 14) 202 | 83 203 | 204 | iex> Puid.Entropy.bits_for_len!("dingosky", 14) 205 | 42 206 | 207 | """ 208 | @spec bits_for_len!(Puid.Chars.puid_chars(), non_neg_integer()) :: non_neg_integer() 209 | def bits_for_len!(chars, len) do 210 | with {:ok, ebpc} <- bits_for_len(chars, len) do 211 | ebpc 212 | else 213 | {:error, reason} -> 214 | raise(Puid.Error, reason) 215 | end 216 | end 217 | 218 | @doc """ 219 | 220 | Length needed for a string generated from `chars` to have entropy `bits`. 221 | 222 | `chars` must be valid as per `Chars.charlist/1`. 223 | 224 | ## Example 225 | 226 | iex> Puid.Entropy.len_for_bits(:alphanum, 128) 227 | {:ok, 22} 228 | 229 | iex> Puid.Entropy.len_for_bits("dingosky", 128) 230 | {:ok, 43} 231 | 232 | """ 233 | @spec len_for_bits(Puid.Chars.puid_chars(), non_neg_integer()) :: 234 | {:ok, non_neg_integer()} | Puid.Error.t() 235 | def len_for_bits(chars, bits) do 236 | with {:ok, ebpc} <- bits_per_char(chars) do 237 | {:ok, (bits / ebpc) |> :math.ceil() |> round()} 238 | else 239 | error -> 240 | error 241 | end 242 | end 243 | 244 | @doc """ 245 | 246 | Same as `Puid.Entropy.len_for_bits/2` but either returns __len__ or raises a 247 | `Puid.Error` 248 | 249 | ## Example 250 | 251 | iex> Puid.Entropy.len_for_bits!(:alphanum, 128) 252 | 22 253 | 254 | iex> Puid.Entropy.len_for_bits!(~c'dingosky', 128) 255 | 43 256 | 257 | """ 258 | @spec len_for_bits!(Puid.Chars.puid_chars(), non_neg_integer()) :: 259 | non_neg_integer() | Puid.Error.t() 260 | def len_for_bits!(chars, bits) do 261 | with {:ok, len} <- len_for_bits(chars, bits) do 262 | len 263 | else 264 | {:error, reason} -> 265 | raise(Puid.Error, reason) 266 | end 267 | end 268 | end 269 | -------------------------------------------------------------------------------- /lib/puid/error.ex: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # 3 | # Copyright (c) 2019-2023 Knoxen 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in all 13 | # copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | # SOFTWARE. 22 | 23 | defmodule Puid.Error do 24 | @moduledoc """ 25 | Raised when defining a Puid module with invalid options 26 | """ 27 | defexception message: "Puid error" 28 | 29 | @typedoc """ 30 | `Puid.Error` type 31 | """ 32 | @type t() :: Puid.Error 33 | end 34 | -------------------------------------------------------------------------------- /lib/puid/info.ex: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # 3 | # Copyright (c) 2019-2023 Knoxen 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in all 13 | # copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | # SOFTWARE. 22 | 23 | defmodule Puid.Info do 24 | @moduledoc """ 25 | Information regarding Puid module parameterization 26 | 27 | The `Puid.Info` struct has the following fields: 28 | 29 | | Field | Description | 30 | | ----- | ----------- | 31 | | char_set | pre-defined `Puid.Chars` atom or :custom | 32 | | characters | source characters | 33 | | entropy_bits | entropy bits for generated **puid** | 34 | | entropy_bits_per_char | entropy bits per character for generated **puid**s | 35 | | ere | **puid** entropy string representation efficiency | 36 | | length | **puid** string length | 37 | | rand_bytes | entropy source function | 38 | 39 | ```elixir 40 | iex> defmodule(CustomId, do: use(Puid, total: 1.0e04, risk: 1.0e12, chars: "thequickbrownfxjmpsvlazydg")) 41 | iex> CustomId.info() 42 | %Puid.Info{ 43 | char_set: :custom, 44 | characters: "thequickbrownfxjmpsvlazydg", 45 | entropy_bits: 65.81, 46 | entropy_bits_per_char: 4.7, 47 | ere: 0.59, 48 | length: 14, 49 | rand_bytes: &:crypto.strong_rand_bytes/1 50 | } 51 | ``` 52 | """ 53 | 54 | defstruct characters: Puid.Chars.charlist!(:safe64) |> to_string(), 55 | char_set: :safe64, 56 | entropy_bits: 128, 57 | entropy_bits_per_char: 0, 58 | ere: 0, 59 | length: 0, 60 | rand_bytes: nil 61 | end 62 | -------------------------------------------------------------------------------- /lib/puid/util.ex: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # 3 | # Copyright (c) 2019-2023 Knoxen 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in all 13 | # copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | # SOFTWARE. 22 | 23 | defmodule Puid.Util do 24 | @moduledoc false 25 | 26 | import Bitwise 27 | 28 | @doc false 29 | def bit_zero?(n, bit), do: (n &&& 1 <<< (bit - 1)) === 0 30 | 31 | @doc false 32 | def even?(n), do: bit_zero?(n, 1) 33 | 34 | @doc false 35 | def log_ceil(n), do: n |> :math.log2() |> r_ceil() 36 | 37 | @doc false 38 | def pow2(n), do: 1 <<< n 39 | 40 | @doc false 41 | def pow2?(n), do: n |> :math.log2() |> round() |> pow2() |> Kernel.==(n) 42 | 43 | defp r_ceil(n), do: n |> :math.ceil() |> round() 44 | end 45 | 46 | defmodule Puid.Util.FixedBytes do 47 | @moduledoc false 48 | 49 | defmacro __using__(opts) do 50 | quote do 51 | fixed_bytes = 52 | case unquote(opts)[:bytes] do 53 | nil -> 54 | File.read!(unquote(opts[:data_path])) 55 | 56 | bytes -> 57 | bytes 58 | end 59 | 60 | @agent_name String.to_atom("#{__MODULE__}Agent") 61 | Agent.start_link(fn -> {0, fixed_bytes} end, name: @agent_name) 62 | 63 | def rand_bytes(count) do 64 | {byte_offset, fixed_bytes} = state() 65 | @agent_name |> Agent.update(fn _ -> {byte_offset + count, fixed_bytes} end) 66 | binary_part(fixed_bytes, byte_offset, count) 67 | end 68 | 69 | def state(), do: @agent_name |> Agent.get(& &1) 70 | 71 | def reset(), do: @agent_name |> Agent.update(fn {_, fixed_bytes} -> {0, fixed_bytes} end) 72 | end 73 | end 74 | end 75 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Puid.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :puid, 7 | version: "2.3.2", 8 | elixir: "~> 1.14", 9 | description: description(), 10 | package: package(), 11 | deps: deps() 12 | ] 13 | end 14 | 15 | def application do 16 | [ 17 | extra_applications: [:crypto] 18 | ] 19 | end 20 | 21 | defp deps do 22 | [ 23 | {:dialyxir, "~> 1.0", only: :dev, runtime: false}, 24 | {:earmark, "~> 1.4", only: :dev}, 25 | {:ex_doc, "~> 0.28", only: :dev}, 26 | {:entropy_string, "~> 1.3", only: :test}, 27 | {:misc_random, "~> 0.2", only: :test}, 28 | {:nanoid, "~> 2.0", only: :test}, 29 | {:randomizer, "~> 1.1", only: :test}, 30 | {:secure_random, "~> 0.5", only: :test}, 31 | {:ulid, "~> 0.2", only: :test}, 32 | {:uuid, "~> 1.1", only: :test} 33 | ] 34 | end 35 | 36 | defp description do 37 | """ 38 | Simple, fast, flexible and efficient generation of probably unique identifiers (`puid`, aka 39 | random strings) of intuitively specified entropy using pre-defined or custom characters. 40 | """ 41 | end 42 | 43 | defp package do 44 | [ 45 | maintainers: ["Paul Rogers"], 46 | licenses: ["MIT"], 47 | links: %{ 48 | "GitHub" => "https://github.com/puid/Elixir", 49 | "README" => "https://puid.github.io/Elixir/", 50 | "Docs" => "https://hexdocs.pm/puid/api-reference.html" 51 | } 52 | ] 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/chars_test.exs: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # 3 | # Copyright (c) 2019-2023 Knoxen 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in all 13 | # copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | # SOFTWARE. 22 | defmodule Puid.Test.Chars do 23 | use ExUnit.Case, async: true 24 | 25 | alias Puid.Chars 26 | 27 | def predefined_chars, 28 | do: [ 29 | :alpha, 30 | :alpha_lower, 31 | :alpha_upper, 32 | :alphanum, 33 | :alphanum_lower, 34 | :alphanum_upper, 35 | :base32, 36 | :base32_hex, 37 | :base32_hex_upper, 38 | :crockford32, 39 | :decimal, 40 | :hex, 41 | :hex_upper, 42 | :safe_ascii, 43 | :safe32, 44 | :safe64, 45 | :symbol, 46 | :wordSafe32 47 | ] 48 | 49 | test "charlist of pre-defined chars" do 50 | predefined_chars() 51 | |> Enum.each(fn predefined -> 52 | {:ok, charlist} = Chars.charlist(predefined) 53 | assert is_list(charlist) 54 | end) 55 | 56 | predefined_chars() 57 | |> Enum.each(fn predefined -> 58 | charlist = Chars.charlist!(predefined) 59 | assert is_list(charlist) 60 | end) 61 | end 62 | 63 | test "charlist of ascii charlist" do 64 | {:ok, charlist} = Chars.charlist(~c"dingosky") 65 | assert is_list(charlist) 66 | 67 | assert(~c"dingosky" |> Chars.charlist!() |> is_list()) 68 | end 69 | 70 | test "charlist of ascii String" do 71 | {:ok, charlist} = Chars.charlist("dingosky") 72 | assert is_list(charlist) 73 | 74 | assert("dingosky" |> Chars.charlist!() |> is_list()) 75 | end 76 | 77 | test "charlist of unicode charlist" do 78 | {:ok, charlist} = Chars.charlist(~c"dîngøsky") 79 | assert is_list(charlist) 80 | 81 | assert(~c"dîngøsky" |> Chars.charlist!() |> is_list()) 82 | end 83 | 84 | test "charlist of unicode String" do 85 | {:ok, charlist} = Chars.charlist("dîngøsky") 86 | assert is_list(charlist) 87 | 88 | assert("dîngøsky" |> Chars.charlist!() |> is_list()) 89 | end 90 | 91 | test "charlist of unknown pre-defined chars" do 92 | assert {:error, reason} = Chars.charlist(:unknown) 93 | assert reason |> String.contains?("pre-defined") 94 | 95 | assert_raise(Puid.Error, fn -> Chars.charlist!(:unknown) end) 96 | end 97 | 98 | test "charlist of non-unique String" do 99 | assert {:error, reason} = Chars.charlist("unique") 100 | assert reason |> String.contains?("not unique") 101 | 102 | assert_raise(Puid.Error, fn -> Chars.charlist!(~c"unique") end) 103 | end 104 | 105 | test "charlist of too short String" do 106 | assert {:error, reason} = Chars.charlist("0") 107 | assert reason |> String.contains?("least") 108 | 109 | assert_raise(Puid.Error, fn -> Chars.charlist!("") end) 110 | end 111 | 112 | test "charlist with too many chars" do 113 | too_long = 229..500 |> Enum.map(& &1) |> to_string() 114 | assert {:error, reason} = too_long |> Chars.charlist() 115 | assert reason |> String.contains?("count") 116 | 117 | assert_raise(Puid.Error, fn -> Chars.charlist!(too_long) end) 118 | end 119 | 120 | test "invalid charlist error" do 121 | assert {:error, reason} = Chars.charlist("dingo sky") 122 | assert reason |> String.contains?("Invalid") 123 | end 124 | 125 | test "charlist with unsafe ascii" do 126 | assert_raise(Puid.Error, fn -> Chars.charlist!(~c"dingo sky") end) 127 | assert_raise(Puid.Error, fn -> Chars.charlist!(~c"dingo\"sky") end) 128 | assert_raise(Puid.Error, fn -> Chars.charlist!(~c"dingo'sky") end) 129 | assert_raise(Puid.Error, fn -> Chars.charlist!(~c"dingo\\sky") end) 130 | assert_raise(Puid.Error, fn -> Chars.charlist!(~c"dingo`sky") end) 131 | end 132 | 133 | test "String with unsafe ascii" do 134 | assert_raise(Puid.Error, fn -> Chars.charlist!("dingo`sky") end) 135 | end 136 | 137 | test "charlist with unsafe utf8 between tilde and inverse bang" do 138 | assert_raise(Puid.Error, fn -> Chars.charlist!("dingo\u00A0sky") end) 139 | end 140 | 141 | test "ascii encoding" do 142 | assert Chars.encoding("abc") == :ascii 143 | assert Chars.encoding("abc∂ef") == :utf8 144 | end 145 | 146 | test "invalid encoding" do 147 | assert_raise(Puid.Error, fn -> Chars.encoding(~c"ab cd") end) 148 | end 149 | end 150 | -------------------------------------------------------------------------------- /test/data.exs: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # 3 | # Copyright (c) 2019-2023 Knoxen 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in all 13 | # copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | # SOFTWARE. 22 | defmodule Puid.Test.File.Data do 23 | use ExUnit.Case, async: true 24 | 25 | @tag :alphanum 26 | test "test alphanum file data" do 27 | Puid.Test.Data.test("alphanum") 28 | end 29 | 30 | @tag :alpha_10_lower 31 | test "test alpha 10 lower file data" do 32 | Puid.Test.Data.test("alpha_10_lower") 33 | end 34 | 35 | @tag :dingosky 36 | test "test dingosky file data" do 37 | Puid.Test.Data.test("dingosky") 38 | end 39 | 40 | @tag :safe32 41 | test "test safe32 file data" do 42 | Puid.Test.Data.test("safe32") 43 | end 44 | 45 | @tag :safe_ascii 46 | test "test safe_ascii file data" do 47 | Puid.Test.Data.test("safe_ascii") 48 | end 49 | 50 | @tag :unicode 51 | test "test unicode file data " do 52 | Puid.Test.Data.test("unicode") 53 | end 54 | end 55 | -------------------------------------------------------------------------------- /test/entropy_test.exs: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # 3 | # Copyright (c) 2019-2023 Knoxen 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in all 13 | # copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | # SOFTWARE. 22 | defmodule Puid.Test.Entropy do 23 | use ExUnit.Case, async: true 24 | 25 | doctest Puid.Entropy 26 | 27 | import Puid.Entropy 28 | 29 | defp d_bits(total, risk, d), do: Float.round(bits(total, risk), d) 30 | defp i_bits(total, risk), do: round(bits(total, risk)) 31 | 32 | defp assert_error_matches({:error, reason}, snippet), 33 | do: assert(reason |> String.contains?(snippet)) 34 | 35 | test "bits" do 36 | assert bits(0, 1.0e12) === 0 37 | assert bits(1, 1.0e12) === 0 38 | 39 | assert bits(500, 0) === 0 40 | assert bits(500, 1) === 0 41 | 42 | assert bits(10_500, 0) === 0 43 | assert bits(10_500, 1) === 0 44 | 45 | assert d_bits(100, 100, 2) === 18.92 46 | assert d_bits(999, 1000, 2) === 28.89 47 | assert d_bits(1000, 1000, 2) === 28.90 48 | assert d_bits(10000, 1000, 2) === 35.54 49 | assert d_bits(1.0e4, 1.0e3, 2) === 35.54 50 | assert d_bits(1.0e6, 1.0e6, 2) === 58.79 51 | assert d_bits(1.0e6, 1.0e9, 2) === 68.76 52 | assert d_bits(1.0e9, 1.0e6, 2) === 78.73 53 | assert d_bits(1.0e9, 1.0e9, 2) === 88.69 54 | assert d_bits(1.0e9, 1.0e12, 2) === 98.66 55 | assert d_bits(1.0e9, 1.0e15, 2) === 108.62 56 | end 57 | 58 | test "total" do 59 | assert total(96, 1.0e15) == 13_263_554 60 | assert total(108.62, 1.0e15) == 1_052_347_509 61 | end 62 | 63 | @tag :risk 64 | test "risk" do 65 | assert risk(96, 10.0e6) == 1_501_199_875_790_165 66 | assert risk(108.62, 1.0e9) == 1_000_799_917_193_444 67 | end 68 | 69 | test "preshing 32-bit" do 70 | assert i_bits(30084, 10) === 32 71 | assert i_bits(9292, 1.0e02) === 32 72 | assert i_bits(2932, 1.0e03) === 32 73 | assert i_bits(927, 1.0e04) === 32 74 | assert i_bits(294, 1.0e05) === 32 75 | assert i_bits(93, 1.0e06) === 32 76 | assert i_bits(30, 1.0e07) === 32 77 | assert i_bits(10, 1.0e08) === 32 78 | end 79 | 80 | test "preshing 64-bit" do 81 | assert i_bits(1.97e09, 1.0e01) === 64 82 | assert i_bits(6.09e08, 1.0e02) === 64 83 | assert i_bits(1.92e08, 1.0e03) === 64 84 | assert i_bits(6.07e07, 1.0e04) === 64 85 | assert i_bits(1.92e07, 1.0e05) === 64 86 | assert i_bits(6.07e06, 1.0e06) === 64 87 | assert i_bits(1.92e06, 1.0e07) === 64 88 | assert i_bits(607_401, 1.0e08) === 64 89 | assert i_bits(192_077, 1.0e09) === 64 90 | assert i_bits(60704, 1.0e10) === 64 91 | assert i_bits(19208, 1.0e11) === 64 92 | assert i_bits(6074, 1.0e12) === 64 93 | assert i_bits(1921, 1.0e13) === 64 94 | assert i_bits(608, 1.0e14) === 64 95 | assert i_bits(193, 1.0e15) === 64 96 | assert i_bits(61, 1.0e16) === 64 97 | assert i_bits(20, 1.0e17) === 64 98 | assert i_bits(7, 1.0e18) === 64 99 | end 100 | 101 | test "preshing 160-bit" do 102 | assert i_bits(1.42e24, 2) === 160 103 | assert i_bits(5.55e23, 10) === 160 104 | assert i_bits(1.71e23, 100) === 160 105 | assert i_bits(5.41e22, 1000) === 160 106 | assert i_bits(1.71e22, 1.0e04) === 160 107 | assert i_bits(5.41e21, 1.0e05) === 160 108 | assert i_bits(1.71e21, 1.0e06) === 160 109 | assert i_bits(5.41e20, 1.0e07) === 160 110 | assert i_bits(1.71e20, 1.0e08) === 160 111 | assert i_bits(5.41e19, 1.0e09) === 160 112 | assert i_bits(1.71e19, 1.0e10) === 160 113 | assert i_bits(5.41e18, 1.0e11) === 160 114 | assert i_bits(1.71e18, 1.0e12) === 160 115 | assert i_bits(5.41e17, 1.0e13) === 160 116 | assert i_bits(1.71e17, 1.0e14) === 160 117 | assert i_bits(5.41e16, 1.0e15) === 160 118 | assert i_bits(1.71e16, 1.0e16) === 160 119 | assert i_bits(5.41e15, 1.0e17) === 160 120 | assert i_bits(1.71e15, 1.0e18) === 160 121 | end 122 | 123 | # 124 | # bits_per_char 125 | # 126 | test "valid bits_per_char" do 127 | assert bits_per_char(:hex) === {:ok, 4.0} 128 | {:ok, ebpc} = bits_per_char(:alphanum) 129 | assert ebpc |> Float.round(2) == 5.95 130 | 131 | assert bits_per_char(~c"dingosky") === {:ok, 3.0} 132 | assert bits_per_char(~c"0123456789") === {:ok, 10 |> :math.log2()} 133 | 134 | assert bits_per_char("0123") === {:ok, 2.0} 135 | assert bits_per_char("0123456789ok") === {:ok, 12 |> :math.log2()} 136 | end 137 | 138 | test "valid bits_per_char!" do 139 | assert bits_per_char!(:hex) === 4.0 140 | assert bits_per_char!(:alphanum) |> Float.round(2) == 5.95 141 | 142 | assert bits_per_char!(~c"dingosky") === 3.0 143 | assert bits_per_char!(~c"0123456789") === 10 |> :math.log2() 144 | 145 | assert bits_per_char!("0123") === 2.0 146 | assert bits_per_char!("0123456789ok") === 12 |> :math.log2() 147 | end 148 | 149 | test "invalid pre-defined bits_per_char" do 150 | :invalid |> bits_per_char() |> assert_error_matches("pre-defined") 151 | 152 | assert_raise Puid.Error, fn -> bits_per_char!(:invalid) end 153 | end 154 | 155 | test "non-unique bits_per_char" do 156 | ~c"unique" |> bits_per_char() |> assert_error_matches("unique") 157 | assert_raise Puid.Error, fn -> bits_per_char!(~c"unique") end 158 | end 159 | 160 | test "too short bits_per_char" do 161 | ~c"u" |> bits_per_char() |> assert_error_matches("least 2") 162 | "" |> bits_per_char() |> assert_error_matches("least 2") 163 | 164 | assert_raise Puid.Error, fn -> bits_per_char!(~c"") end 165 | assert_raise Puid.Error, fn -> bits_per_char!("u") end 166 | end 167 | 168 | test "too long bits_per_char" do 169 | ascii = Puid.Chars.charlist!(:safe_ascii) 170 | too_long = ascii ++ ascii ++ ascii 171 | 172 | too_long |> bits_per_char() |> assert_error_matches("count") 173 | 174 | assert_raise Puid.Error, fn -> bits_per_char!(too_long) end 175 | end 176 | 177 | # 178 | # bits_for_len 179 | # 180 | test "valid bits_for_len" do 181 | assert :alphanum |> bits_for_len(14) === {:ok, 83} 182 | assert ~c"dingosky" |> bits_for_len(14) === {:ok, 42} 183 | assert "uncopyrightable" |> bits_for_len(14) === {:ok, 54} 184 | end 185 | 186 | test "valid bits_for_len!" do 187 | assert :alphanum |> bits_for_len!(14) === 83 188 | assert ~c"uncopyrightable" |> bits_for_len!(14) === 54 189 | assert "dingosky" |> bits_for_len!(14) === 42 190 | end 191 | 192 | test "invalid pre-defined bits_for_len" do 193 | :invalid |> bits_for_len(10) |> assert_error_matches("pre-defined") 194 | 195 | assert_raise Puid.Error, fn -> :invalid |> bits_for_len!(20) end 196 | end 197 | 198 | test "non-unique bits_for_len" do 199 | ~c"unique" |> bits_for_len(14) |> assert_error_matches("unique") 200 | 201 | assert_raise Puid.Error, fn -> ~c"unique" |> bits_for_len!(20) end 202 | end 203 | 204 | test "too short bits_for_len" do 205 | ~c"u" |> bits_for_len(10) |> assert_error_matches("least 2") 206 | "" |> bits_for_len(20) |> assert_error_matches("least 2") 207 | 208 | assert_raise Puid.Error, fn -> ~c"u" |> bits_for_len!(10) end 209 | end 210 | 211 | test "too long bits_for_len" do 212 | ascii = Puid.Chars.charlist!(:safe_ascii) 213 | too_long = ascii ++ ascii ++ ascii 214 | 215 | too_long |> bits_for_len(10) |> assert_error_matches("count") 216 | assert_raise Puid.Error, fn -> too_long |> bits_for_len!(10) end 217 | end 218 | 219 | # 220 | # len_for_bits 221 | # 222 | test "valid len_for_bits" do 223 | assert :alphanum |> len_for_bits(83) === {:ok, 14} 224 | assert ~c"dingosky" |> len_for_bits(42) === {:ok, 14} 225 | assert "uncopyrightable" |> len_for_bits(54) === {:ok, 14} 226 | end 227 | 228 | test "valid len_for_bits!" do 229 | assert :alphanum |> len_for_bits!(83) === 14 230 | assert ~c"uncopyrightable" |> len_for_bits!(54) === 14 231 | assert "dingosky" |> len_for_bits!(42) === 14 232 | end 233 | 234 | test "invalid pre-defined len_for_bits" do 235 | :invalid |> len_for_bits(10) |> assert_error_matches("pre-defined") 236 | 237 | assert_raise Puid.Error, fn -> :invalid |> len_for_bits!(20) end 238 | end 239 | 240 | test "non-unique len_for_bits" do 241 | ~c"unique" |> len_for_bits(14) |> assert_error_matches("unique") 242 | assert_raise Puid.Error, fn -> ~c"unique" |> len_for_bits!(20) end 243 | end 244 | 245 | test "too short len_for_bits" do 246 | ~c"u" |> len_for_bits(10) |> assert_error_matches("least 2") 247 | "" |> len_for_bits(20) |> assert_error_matches("least 2") 248 | assert_raise Puid.Error, fn -> ~c"u" |> len_for_bits!(10) end 249 | end 250 | 251 | test "too long len_for_bits" do 252 | ascii = Puid.Chars.charlist!(:safe_ascii) 253 | too_long = ascii ++ ascii ++ ascii 254 | 255 | too_long |> len_for_bits(10) |> assert_error_matches("count") 256 | assert_raise Puid.Error, fn -> too_long |> len_for_bits!(10) end 257 | end 258 | end 259 | -------------------------------------------------------------------------------- /test/histogram.exs: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # 3 | # Copyright (c) 2019-2023 Knoxen 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in all 13 | # copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | # SOFTWARE. 22 | defmodule Puid.Test.Histogram do 23 | use ExUnit.Case 24 | 25 | @tag :hex 26 | test ":hex", do: test_chars("Hex", "HexId", :hex) 27 | 28 | @tag :safe32 29 | test ":safe32", do: test_chars("Safe32", "Safe32Id", :safe32) 30 | 31 | @tag :alpha_lower 32 | test ":alpha_lower", do: test_chars("AlphaLower", "AlphaLowerId", :alpha_lower) 33 | 34 | @tag :alphanum 35 | test ":alphanum", do: test_chars("Alphanumeric", "AlphanumId", :alphanum) 36 | 37 | @tag :safe_ascii 38 | test ":safe_ascii", do: test_chars("All ASCII", "SafeAsciiId", :safe_ascii) 39 | 40 | @tag :custom_8 41 | test "ascii", do: test_chars("8 custom ASCII", "DingoSkyId", "dingosky") 42 | 43 | @tag :alpha_9_lower 44 | test "alpha 9 lower", do: test_chars("9 alpha lower chars", "Alpha9LowerId", "abcdefghi") 45 | 46 | @tag :alpha_10_lower 47 | test "alpha 10 lower", do: test_chars("10 alpha lower chars", "Alpha10LowerId", "abcdefghij") 48 | 49 | @tag :unicode 50 | test "unicode", do: test_chars("Unicode characters", "DingoSkyUnicodeId", "dîñgø$kyDÎÑGØßK¥") 51 | 52 | defp test_chars(descr, id_name, chars) do 53 | trials = 500_000 54 | risk = 1.0e12 55 | mod_name = String.to_atom(id_name) 56 | 57 | IO.write("#{descr} ... ") 58 | 59 | defmodule(mod_name, do: use(Puid, chars: chars, total: trials, risk: risk)) 60 | 61 | {passed, expect, histogram} = chi_square_test(mod_name, trials) 62 | 63 | if passed, 64 | do: IO.puts("ok"), 65 | else: IO.inspect(histogram, label: "\nFailed histogram for #{id_name}, expected #{expect}") 66 | 67 | assert passed 68 | end 69 | 70 | def chi_square_test(puid_mod, trials, n_sigma \\ 4) do 71 | init_histogram = 72 | puid_mod.info().characters 73 | |> to_charlist() 74 | |> Enum.reduce(%{}, &Map.put(&2, &1, 0)) 75 | 76 | chars_len = String.length(puid_mod.info().characters) 77 | 78 | histogram = 79 | 1..trials 80 | |> Enum.reduce(init_histogram, fn _, acc_histogram -> 81 | puid_mod.generate() 82 | |> to_charlist() 83 | |> Enum.reduce(acc_histogram, &Map.put(&2, &1, &2[&1] + 1)) 84 | end) 85 | 86 | expect = trials * puid_mod.info().length / chars_len 87 | 88 | chi_square = 89 | histogram 90 | |> Enum.reduce(0, fn {_, value}, acc -> 91 | diff = value - expect 92 | acc + diff * diff / expect 93 | end) 94 | 95 | deg_freedom = chars_len - 1 96 | variance = :math.sqrt(2 * deg_freedom) 97 | tolerance = n_sigma * variance 98 | 99 | passed = chi_square < deg_freedom + tolerance and chi_square > deg_freedom - tolerance 100 | 101 | {passed, round(expect), histogram} 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /test/puid_test.exs: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # 3 | # Copyright (c) 2019-2023 Knoxen 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in all 13 | # copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | # SOFTWARE. 22 | defmodule Puid.Test.Puid do 23 | use ExUnit.Case, async: true 24 | 25 | alias Puid.Chars 26 | alias Puid.Entropy 27 | 28 | defp test_char_set(char_set, length, bits \\ 128) do 29 | mod = String.to_atom("#{char_set}_#{bits}_Id") 30 | defmodule(mod, do: use(Puid, chars: char_set, bits: bits)) 31 | 32 | epbc = Entropy.bits_per_char!(char_set) 33 | eb = length * epbc 34 | ere = epbc / 8.0 35 | round_to = 2 36 | 37 | assert mod.info().char_set === char_set 38 | assert mod.info().length === length 39 | 40 | assert mod.info().entropy_bits_per_char === Float.round(epbc, round_to) 41 | assert mod.info().entropy_bits === Float.round(eb, round_to) 42 | assert mod.info().ere === Float.round(ere, round_to) 43 | assert mod.generate() |> String.length() === length 44 | end 45 | 46 | test "pre-defined safe ascii chars" do 47 | test_char_set(:safe_ascii, 20) 48 | test_char_set(:safe_ascii, 10, 64) 49 | test_char_set(:safe_ascii, 40, 256) 50 | end 51 | 52 | test "pre-defined alpha chars" do 53 | test_char_set(:alpha, 23) 54 | test_char_set(:alpha, 12, 64) 55 | test_char_set(:alpha, 45, 256) 56 | end 57 | 58 | test "pre-defined lower alpha chars" do 59 | test_char_set(:alpha_lower, 28) 60 | test_char_set(:alpha_lower, 14, 64) 61 | test_char_set(:alpha_lower, 55, 256) 62 | end 63 | 64 | test "pre-defined upper alpha chars" do 65 | test_char_set(:alpha_upper, 28) 66 | test_char_set(:alpha_upper, 14, 64) 67 | test_char_set(:alpha_upper, 55, 256) 68 | end 69 | 70 | test "pre-defined alphanum" do 71 | test_char_set(:alphanum, 22) 72 | test_char_set(:alphanum, 11, 64) 73 | test_char_set(:alphanum, 43, 256) 74 | end 75 | 76 | test "pre-defined lower alphanum" do 77 | test_char_set(:alphanum_lower, 25) 78 | test_char_set(:alphanum_lower, 13, 64) 79 | test_char_set(:alphanum_lower, 50, 256) 80 | end 81 | 82 | test "pre-defined upper alphanum" do 83 | test_char_set(:alphanum_upper, 25) 84 | test_char_set(:alphanum_upper, 13, 64) 85 | test_char_set(:alphanum_upper, 50, 256) 86 | end 87 | 88 | test "pre-defined hex" do 89 | test_char_set(:hex, 32) 90 | test_char_set(:hex, 16, 64) 91 | test_char_set(:hex, 64, 256) 92 | end 93 | 94 | test "pre-defined upper hex" do 95 | test_char_set(:hex_upper, 32) 96 | test_char_set(:hex_upper, 16, 64) 97 | test_char_set(:hex_upper, 64, 256) 98 | end 99 | 100 | test "pre-defined base32" do 101 | test_char_set(:base32, 26) 102 | test_char_set(:base32, 13, 64) 103 | test_char_set(:base32, 52, 256) 104 | end 105 | 106 | test "pre-defined base32 hex" do 107 | test_char_set(:base32_hex, 26) 108 | test_char_set(:base32_hex, 13, 64) 109 | test_char_set(:base32_hex, 52, 256) 110 | end 111 | 112 | test "pre-defined base32 upper hex" do 113 | test_char_set(:base32_hex_upper, 26) 114 | test_char_set(:base32_hex_upper, 13, 64) 115 | test_char_set(:base32_hex_upper, 52, 256) 116 | end 117 | 118 | test "pre-defined crockford32" do 119 | test_char_set(:crockford32, 26) 120 | test_char_set(:crockford32, 13, 64) 121 | test_char_set(:crockford32, 52, 256) 122 | end 123 | 124 | test "pre-defined safe32" do 125 | test_char_set(:safe32, 26) 126 | test_char_set(:safe32, 13, 64) 127 | test_char_set(:safe32, 52, 256) 128 | end 129 | 130 | test "pre-defined safe64" do 131 | test_char_set(:safe64, 22) 132 | test_char_set(:safe64, 11, 64) 133 | test_char_set(:safe64, 43, 256) 134 | end 135 | 136 | test "pre-defined symbol" do 137 | test_char_set(:symbol, 27) 138 | test_char_set(:symbol, 14, 64) 139 | test_char_set(:symbol, 54, 256) 140 | end 141 | 142 | test "pre-defined wordSafe32" do 143 | test_char_set(:wordSafe32, 26) 144 | test_char_set(:wordSafe32, 13, 64) 145 | test_char_set(:wordSafe32, 52, 256) 146 | end 147 | 148 | defp test_characters(chars) do 149 | puid_mod = 150 | "#{chars |> to_string() |> String.capitalize()}Puid" 151 | |> String.to_atom() 152 | 153 | defmodule(puid_mod, do: use(Puid, chars: chars)) 154 | 155 | characters = puid_mod.info().characters 156 | 157 | 1..100 158 | |> Enum.each(fn _ -> 159 | puid_mod.generate() 160 | |> String.graphemes() 161 | |> Enum.each(fn symbol -> characters |> String.contains?(symbol) end) 162 | end) 163 | end 164 | 165 | test "pre-defined chars mod chars" do 166 | [ 167 | :alpha, 168 | :alpha_lower, 169 | :alpha_upper, 170 | :alphanum, 171 | :alphanum_lower, 172 | :alphanum_upper, 173 | :base32, 174 | :base32_hex, 175 | :base32_hex_upper, 176 | :crockford32, 177 | :decimal, 178 | :hex, 179 | :hex_upper, 180 | :safe_ascii, 181 | :safe32, 182 | :safe64 183 | ] 184 | |> Enum.each(&test_characters(&1)) 185 | end 186 | 187 | test "default puid" do 188 | defmodule(DefaultId, do: use(Puid)) 189 | assert DefaultId.info().characters === Chars.charlist!(:safe64) |> to_string() 190 | assert DefaultId.info().char_set === :safe64 191 | assert DefaultId.info().length === 22 192 | assert DefaultId.info().entropy_bits_per_char === 6.0 193 | assert DefaultId.info().entropy_bits === 132.0 194 | assert DefaultId.info().rand_bytes === (&:crypto.strong_rand_bytes/1) 195 | assert DefaultId.info().ere === 0.75 196 | assert byte_size(DefaultId.generate()) === DefaultId.info().length 197 | end 198 | 199 | test "encode" do 200 | defmodule(IdEncoder, do: use(Puid, bits: 55, chars: :alpha_lower)) 201 | 202 | puid_bits = <<141, 138, 2, 168, 7, 11, 13, 0::size(4)>> 203 | puid = "rwfafkahbmgq" 204 | 205 | assert IdEncoder.encode(puid_bits) == puid 206 | end 207 | 208 | test "encode fail" do 209 | defmodule(FailEncoder, do: use(Puid, bits: 34, chars: :alpha)) 210 | 211 | puid_bits = <<76, 51, 24, 70, 2::size(4)>> 212 | assert FailEncoder.encode(puid_bits) == "TDMYRi" 213 | 214 | assert FailEncoder.encode(<<76, 51, 24, 70, 2::size(3)>>) == {:error, "unable to encode"} 215 | assert FailEncoder.encode(<<76, 51, 24, 70, 2::size(5)>>) == {:error, "unable to encode"} 216 | 217 | assert FailEncoder.encode(<<76, 51, 24, 255, 2::size(4)>>) == {:error, "unable to encode"} 218 | end 219 | 220 | test "decode" do 221 | defmodule(IdDecoder, do: use(Puid, bits: 62, chars: :alphanum)) 222 | 223 | bits = <<70, 114, 103, 8, 162, 67, 146, 76, 3::size(2)>> 224 | puid = "RnJnCKJDkkz" 225 | 226 | assert IdDecoder.decode(puid) == bits 227 | end 228 | 229 | test "decode fail" do 230 | defmodule(FailDecoder, do: use(Puid, bits: 34, chars: :alpha)) 231 | 232 | puid = FailDecoder.generate() 233 | 234 | long = puid <> "1" 235 | assert FailDecoder.decode(long) == {:error, "unable to decode"} 236 | 237 | <<_::binary-size(1), short::binary>> = puid 238 | assert FailDecoder.decode(short) == {:error, "unable to decode"} 239 | 240 | invalid_char = short <> "$" 241 | assert FailDecoder.decode(invalid_char) == {:error, "unable to decode"} 242 | end 243 | 244 | test "decode not supported" do 245 | defmodule(DNoNo, do: use(Puid, bits: 50, chars: ~c"dîngøsky")) 246 | 247 | assert DNoNo.generate() |> DNoNo.decode() == 248 | {:error, "not supported for non-ascii characters sets"} 249 | end 250 | 251 | test "decode/encode round trips" do 252 | defmodule(EDHex, do: use(Puid, chars: :hex)) 253 | hexId = EDHex.generate() 254 | assert hexId |> EDHex.decode() |> EDHex.encode() == hexId 255 | 256 | defmodule(EDAscii, do: use(Puid, chars: :safe_ascii)) 257 | asciiId = EDHex.generate() 258 | assert asciiId |> EDHex.decode() |> EDHex.encode() == asciiId 259 | end 260 | 261 | test "total/risk" do 262 | defmodule(TotalRiskId, do: use(Puid, total: 10_000, risk: 1.0e12, chars: :alpha)) 263 | 264 | info = TotalRiskId.info() 265 | assert info.entropy_bits === 68.41 266 | assert info.entropy_bits_per_char == 5.7 267 | assert info.ere == 0.71 268 | assert info.length == 12 269 | end 270 | 271 | test "total/risk approximations" do 272 | total = 1_000_000 273 | risk = 1.0e12 274 | 275 | defmodule(ApproxTotalRisk, do: use(Puid, total: total, risk: risk, chars: :safe32)) 276 | 277 | assert ApproxTotalRisk.total(risk) == 1_555_013 278 | assert ApproxTotalRisk.risk(total) == 2_418_040_068_387 279 | end 280 | 281 | test "unicode chars" do 282 | chars = "noe\u0308l" 283 | defmodule(XMasChars, do: use(Puid, chars: chars)) 284 | 285 | info = XMasChars.info() 286 | assert info.characters == chars 287 | assert info.char_set == :custom 288 | assert info.length == 56 289 | end 290 | 291 | test "unicode dog" do 292 | chars = "dîngøsky:\u{1F415}" 293 | defmodule(DingoSkyDog, do: use(Puid, total: 1.0e9, risk: 1.0e15, chars: chars)) 294 | 295 | info = DingoSkyDog.info() 296 | assert info.characters == chars 297 | assert info.char_set == :custom 298 | assert info.entropy_bits == 109.62 299 | assert info.entropy_bits_per_char == 3.32 300 | assert info.ere == 0.28 301 | assert info.length == 33 302 | end 303 | 304 | test "Invalid total,risk: one missing" do 305 | assert_raise Puid.Error, fn -> 306 | defmodule(InvalidTotalRisk, do: use(Puid, total: 100)) 307 | end 308 | 309 | assert_raise Puid.Error, fn -> 310 | defmodule(InvalidTotalRisk, do: use(Puid, risk: 100)) 311 | end 312 | end 313 | 314 | test "Invalid chars: not unique" do 315 | assert_raise Puid.Error, fn -> 316 | defmodule(InvalidChars, do: use(Puid, chars: "unique")) 317 | end 318 | end 319 | 320 | test "Invalid chars: only one" do 321 | assert_raise Puid.Error, fn -> 322 | defmodule(InvalidChars, do: use(Puid, chars: "1")) 323 | end 324 | end 325 | 326 | test "Invalid chars: unknown" do 327 | assert_raise Puid.Error, fn -> 328 | defmodule(InvalidChars, do: use(Puid, chars: :unknown)) 329 | end 330 | end 331 | 332 | test "Invalid ascii chars" do 333 | # Below bang 334 | assert_raise Puid.Error, fn -> 335 | defmodule(InvalidChars, do: use(Puid, chars: "!# $")) 336 | end 337 | 338 | # Include double-quote 339 | assert_raise Puid.Error, fn -> 340 | defmodule(InvalidChars, do: use(Puid, chars: "!\"#$")) 341 | end 342 | 343 | # Include single-quote 344 | assert_raise Puid.Error, fn -> 345 | defmodule(InvalidChars, do: use(Puid, chars: "!\'#$")) 346 | end 347 | 348 | # Include backslash 349 | assert_raise Puid.Error, fn -> 350 | defmodule(InvalidChars, do: use(Puid, chars: "!#\\$")) 351 | end 352 | 353 | # Include back-tick 354 | assert_raise Puid.Error, fn -> 355 | defmodule(InvalidChars, do: use(Puid, chars: "!#`$")) 356 | end 357 | end 358 | 359 | test "Invalid chars: out of range" do 360 | # Between tilde and inverted bang 361 | assert_raise Puid.Error, fn -> 362 | defmodule(InvalidChars, do: use(Puid, chars: "!#$~\u0099\u00a1")) 363 | end 364 | end 365 | 366 | test "invalid chars" do 367 | assert_raise Puid.Error, fn -> 368 | defmodule(NoNoId, do: use(Puid, chars: ~c"dingo\n")) 369 | end 370 | end 371 | 372 | defp test_predefined_chars_mod(descr, chars, bits, rand_bytes_mod, expect) do 373 | puid_mod = String.to_atom("#{descr}_#{bits}_bits") 374 | 375 | defmodule(puid_mod, 376 | do: use(Puid, bits: bits, chars: chars, rand_bytes: &rand_bytes_mod.rand_bytes/1) 377 | ) 378 | 379 | test_mod(puid_mod, expect, rand_bytes_mod) 380 | end 381 | 382 | defp test_custom_chars_mod(descr, chars, bits, rand_bytes_mod, expect) do 383 | puid_mod = String.to_atom("#{descr}_#{bits}_bits") 384 | 385 | defmodule(puid_mod, 386 | do: use(Puid, bits: bits, chars: chars, rand_bytes: &rand_bytes_mod.rand_bytes/1) 387 | ) 388 | 389 | test_mod(puid_mod, expect, rand_bytes_mod) 390 | end 391 | 392 | defp test_mod(puid_mod, expect, rand_bytes_mod) do 393 | assert puid_mod.generate() === expect 394 | 395 | bits_mod = 396 | ("Elixir." <> (puid_mod |> to_string()) <> ".Bits") 397 | |> String.to_atom() 398 | 399 | rand_bytes_mod.reset() 400 | bits_mod.reset() 401 | end 402 | 403 | test "alpha" do 404 | defmodule(AlphaBytes, 405 | do: use(Puid.Util.FixedBytes, bytes: <<0xF1, 0xB1, 0x78, 0x0A, 0xCE, 0x2B>>) 406 | ) 407 | 408 | defmodule(Alpha14Id, 409 | do: use(Puid, bits: 14, chars: :alpha, rand_bytes: &AlphaBytes.rand_bytes/1) 410 | ) 411 | 412 | assert Alpha14Id.generate() === "jYv" 413 | assert Alpha14Id.generate() === "AVn" 414 | end 415 | 416 | test "26 lower alpha chars (5 bits)" do 417 | defmodule(LowerAlphaBytes, 418 | do: use(Puid.Util.FixedBytes, bytes: <<0xF1, 0xB1, 0x78, 0x0B, 0xAA>>) 419 | ) 420 | 421 | bits_expect = &test_predefined_chars_mod("LowerAlpha", :alpha_lower, &1, LowerAlphaBytes, &2) 422 | 423 | # shifts:[{25, 5}, {27, 4}, {31, 3}] 424 | # 425 | # F 1 B 1 7 8 0 B A A 426 | # 1111 0001 1011 0001 0111 1000 0000 1011 1010 1010 427 | # 428 | # 111 10001 10110 00101 111 00000 00101 1101 01010 429 | # xxx |---| |---| |---| xxx |---| |---| xxxx |---| 430 | # 30 17 22 5 30 0 5 26 10 431 | # r w f a f k 432 | # 433 | bits_expect.(4, "r") 434 | bits_expect.(5, "rw") 435 | bits_expect.(10, "rwf") 436 | bits_expect.(14, "rwf") 437 | bits_expect.(15, "rwfa") 438 | bits_expect.(18, "rwfa") 439 | bits_expect.(19, "rwfaf") 440 | bits_expect.(24, "rwfafk") 441 | end 442 | 443 | test "lower alpha carry (26 chars, 5 bits)" do 444 | defmodule(LowerAlphaCarryBytes, 445 | do: use(Puid.Util.FixedBytes, bytes: <<0xF1, 0xB1, 0x78, 0x0A, 0xCE>>) 446 | ) 447 | 448 | defmodule(PuidWithAgent, 449 | do: 450 | use(Puid, 451 | bits: 5, 452 | chars: :alpha_lower, 453 | rand_bytes: &LowerAlphaCarryBytes.rand_bytes/1 454 | ) 455 | ) 456 | 457 | assert PuidWithAgent.generate() === "rw" 458 | assert PuidWithAgent.generate() === "fa" 459 | assert PuidWithAgent.generate() === "fm" 460 | end 461 | 462 | test "upper alpha" do 463 | defmodule(UpperAlphaBytes, 464 | do: use(Puid.Util.FixedBytes, bytes: <<0xF1, 0xB1, 0x78, 0x0A, 0xCE>>) 465 | ) 466 | 467 | defmodule(UpperAlphaId, 468 | do: 469 | use(Puid, 470 | bits: 14, 471 | chars: :alpha_upper, 472 | rand_bytes: &UpperAlphaBytes.rand_bytes/1 473 | ) 474 | ) 475 | 476 | assert UpperAlphaId.generate() === "RWF" 477 | assert UpperAlphaId.generate() === "AFM" 478 | end 479 | 480 | test "62 alphanum chars (6 bits)" do 481 | defmodule(AlphaNumBytes, 482 | do: use(Puid.Util.FixedBytes, bytes: <<0xD2, 0xE3, 0xE9, 0xFA, 0x19, 0x00>>) 483 | ) 484 | 485 | bits_expect = &test_predefined_chars_mod("AlphaNum", :alphanum, &1, AlphaNumBytes, &2) 486 | 487 | # shifts: [{61, 6}, {63, 5}] 488 | # 489 | # D 2 E 3 E 9 F A 1 9 0 0 490 | # 1101 0010 1110 0011 1110 1001 1111 1010 0001 1001 0000 0000 491 | # 492 | # 110100 101110 001111 101001 11111 010000 110010 000000 0 493 | # |----| |----| |----| |----| xxxxx |----| |----| |----| 494 | # 52 46 15 41 62 16 50 0 495 | # 0 u P p Q y A 496 | # 497 | bits_expect.(41, "0uPpQyA") 498 | end 499 | 500 | test "alphanum chars (62 chars, 6 bits) carry" do 501 | defmodule(AlphaNumCarryBytes, 502 | do: use(Puid.Util.FixedBytes, bytes: <<0xD2, 0xE3, 0xE9, 0xFA, 0x1F, 0xAC>>) 503 | ) 504 | 505 | defmodule(AlphaNumCarryId, 506 | do: use(Puid, bits: 12, chars: :alphanum, rand_bytes: &AlphaNumCarryBytes.rand_bytes/1) 507 | ) 508 | 509 | # shifts: [{61, 6}, {63, 5}] 510 | # 511 | # D 2 E 3 E 9 F A 1 F A C 512 | # 1101 0010 1110 0011 1110 1001 1111 1010 0001 1111 1010 1100 513 | # 514 | # 110100 101110 001111 101001 11111 010000 11111 101011 00 515 | # |----| |----| |----| |----| xxxxx |----| xxxxx |----| 516 | # 52 46 15 41 62 16 63 43 517 | # 0 u P p Q r 518 | # 519 | assert AlphaNumCarryId.generate() == "0uP" 520 | assert AlphaNumCarryId.generate() == "pQr" 521 | end 522 | 523 | test "alpha lower" do 524 | defmodule(AlphaLowerBytes, 525 | do: use(Puid.Util.FixedBytes, bytes: <<0x53, 0xC8, 0x8D, 0xE6, 0x3E, 0x27, 0xEF>>) 526 | ) 527 | 528 | defmodule(AlphaLower14Id, 529 | do: use(Puid, bits: 14, chars: :alpha_lower, rand_bytes: &AlphaLowerBytes.rand_bytes/1) 530 | ) 531 | 532 | # shifts: [{25, 5}, {27, 4}, {31, 3}]) 533 | # 534 | # 5 3 c 8 8 d e 6 3 e 2 7 e f 535 | # 0101 0011 1100 1000 1000 1101 1110 0110 0011 1110 0010 0111 1110 1111 536 | # 537 | # 01010 01111 00100 01000 1101 111 00110 00111 11000 10011 111 10111 1 538 | # |---| |---| |---| |---| xxxx xxx |---| |---| |---| |---| xxx |---| 539 | # 10 15 4 8 27 28 6 7 24 19 30 23 540 | # k p e i g h y t x 541 | 542 | assert AlphaLower14Id.generate() == "kpe" 543 | assert AlphaLower14Id.generate() == "igh" 544 | assert AlphaLower14Id.generate() == "ytx" 545 | end 546 | 547 | test "alphanum lower" do 548 | defmodule(AlphaNumLowerBytes, 549 | do: use(Puid.Util.FixedBytes, bytes: <<0xD2, 0xE3, 0xE9, 0xFA, 0x19, 0x00, 0xC8, 0x2D>>) 550 | ) 551 | 552 | defmodule(AlphaNumLowerId, 553 | do: 554 | use(Puid, bits: 12, chars: :alphanum_lower, rand_bytes: &AlphaNumLowerBytes.rand_bytes/1) 555 | ) 556 | 557 | assert AlphaNumLowerId.generate() == "s9p" 558 | assert AlphaNumLowerId.generate() == "qib" 559 | end 560 | 561 | test "alphanum upper" do 562 | defmodule(AlphaNumUpperBytes, 563 | do: use(Puid.Util.FixedBytes, bytes: <<0xD2, 0xE3, 0xE9, 0xFA, 0x19, 0x00, 0xC8, 0x2D>>) 564 | ) 565 | 566 | defmodule(AlphaNumUpperId, 567 | do: 568 | use(Puid, bits: 12, chars: :alphanum_upper, rand_bytes: &AlphaNumUpperBytes.rand_bytes/1) 569 | ) 570 | 571 | assert AlphaNumUpperId.generate() == "S9P" 572 | assert AlphaNumUpperId.generate() == "QIB" 573 | end 574 | 575 | test "base32 chars (5 bits)" do 576 | defmodule(Base32Bytes, 577 | do: use(Puid.Util.FixedBytes, bytes: <<0xD2, 0xE3, 0xE9, 0xDA, 0x19, 0x00, 0x22>>) 578 | ) 579 | 580 | bits_expect = &test_predefined_chars_mod("Base32", :base32, &1, Base32Bytes, &2) 581 | 582 | bits_expect.(41, "2LR6TWQZA") 583 | bits_expect.(45, "2LR6TWQZA") 584 | bits_expect.(46, "2LR6TWQZAA") 585 | end 586 | 587 | test "base32 hex" do 588 | defmodule(Base32HexBytes, 589 | do: use(Puid.Util.FixedBytes, bytes: <<0xD2, 0xE3, 0xE9, 0xDA, 0x19, 0x03, 0xB7, 0x3C>>) 590 | ) 591 | 592 | defmodule(Base32HexId, 593 | do: use(Puid, bits: 30, chars: :base32_hex, rand_bytes: &Base32HexBytes.rand_bytes/1) 594 | ) 595 | 596 | assert Base32HexId.generate() == "qbhujm" 597 | assert Base32HexId.generate() == "gp0erj" 598 | end 599 | 600 | test "base32 hex upper" do 601 | defmodule(Base32HexUpperBytes, 602 | do: use(Puid.Util.FixedBytes, bytes: <<0xD2, 0xE3, 0xE9, 0xDA, 0x19, 0x03, 0xB7, 0x3C>>) 603 | ) 604 | 605 | defmodule(Base32HexUpperId, 606 | do: 607 | use(Puid, 608 | bits: 14, 609 | chars: :base32_hex_upper, 610 | rand_bytes: &Base32HexUpperBytes.rand_bytes/1 611 | ) 612 | ) 613 | 614 | assert Base32HexUpperId.generate() == "QBH" 615 | assert Base32HexUpperId.generate() == "UJM" 616 | assert Base32HexUpperId.generate() == "GP0" 617 | assert Base32HexUpperId.generate() == "ERJ" 618 | end 619 | 620 | test "crockford 32" do 621 | defmodule(Crockford32Bytes, 622 | do: use(Puid.Util.FixedBytes, bytes: <<0xD2, 0xE3, 0xE9, 0xDA, 0x19, 0x03, 0xB7, 0x3C>>) 623 | ) 624 | 625 | defmodule(Crockford32Id, 626 | do: 627 | use(Puid, 628 | bits: 20, 629 | chars: :crockford32, 630 | rand_bytes: &Crockford32Bytes.rand_bytes/1 631 | ) 632 | ) 633 | 634 | assert Crockford32Id.generate() == "TBHY" 635 | assert Crockford32Id.generate() == "KPGS" 636 | assert Crockford32Id.generate() == "0EVK" 637 | end 638 | 639 | test "decimal" do 640 | defmodule(DecimalBytes, 641 | do: 642 | use(Puid.Util.FixedBytes, 643 | bytes: <<0xD2, 0xE3, 0xE9, 0xDA, 0x19, 0x03, 0xB7, 0x3C, 0xFF, 0x22>> 644 | ) 645 | ) 646 | 647 | # shifts: [{9, 4}, {11, 3}, {15, 2}] 648 | # 649 | # D 2 E 3 E 9 D A 1 9 0 3 B 7 3 C F F 650 | # 1101 0010 1110 0011 1110 1001 1101 1010 0001 1001 0000 0011 1011 0111 0011 1100 1111 1111 651 | # 652 | # 11 0100 101 11 0001 11 11 0100 11 101 101 0000 11 0010 0000 0111 0110 11 1001 11 1001 111 1111 653 | # xx |--| xxx xx |--| xx xx |--| xx xxx xxx |--| xx |--| |--| |--| |--| xx |--| xx |--| 654 | # 13 4 11 12 1 15 13 4 14 11 10 0 12 2 0 7 6 14 9 14 9 655 | # 4 1 4 0 2 0 7 6 9 9 656 | 657 | defmodule(DecimalId, 658 | do: use(Puid, bits: 16, chars: :decimal, rand_bytes: &DecimalBytes.rand_bytes/1) 659 | ) 660 | 661 | assert DecimalId.generate() == "41402" 662 | assert DecimalId.generate() == "07699" 663 | end 664 | 665 | test "hex chars without carry" do 666 | defmodule(HexNoCarryBytes, 667 | do: use(Puid.Util.FixedBytes, bytes: <<0xC7, 0xC9, 0x00, 0x2A, 0xBD>>) 668 | ) 669 | 670 | # C 7 C 9 0 0 2 A B D 671 | # 1100 0111 1100 1001 0000 0000 0010 1010 1011 1100 672 | 673 | defmodule(HexNoCarryId, 674 | do: use(Puid, bits: 16, chars: :hex_upper, rand_bytes: &HexNoCarryBytes.rand_bytes/1) 675 | ) 676 | 677 | assert HexNoCarryId.generate() == "C7C9" 678 | assert HexNoCarryId.generate() == "002A" 679 | end 680 | 681 | test "hex chars with carry" do 682 | defmodule(HexCarryBytes, 683 | do: use(Puid.Util.FixedBytes, bytes: <<0xC7, 0xC9, 0x00, 0x2A, 0xBD>>) 684 | ) 685 | 686 | # C 7 C 9 0 0 2 A B D 687 | # 1100 0111 1100 1001 0000 0000 0010 1010 1011 1100 688 | 689 | defmodule(HexCarryId, 690 | do: use(Puid, bits: 12, chars: :hex_upper, rand_bytes: &HexCarryBytes.rand_bytes/1) 691 | ) 692 | 693 | assert HexCarryId.generate() == "C7C" 694 | assert HexCarryId.generate() == "900" 695 | assert HexCarryId.generate() == "2AB" 696 | end 697 | 698 | test "hex chars, variable bits" do 699 | defmodule(FixedHexBytes, 700 | do: use(Puid.Util.FixedBytes, bytes: <<0xC7, 0xC9, 0x00, 0x2A>>) 701 | ) 702 | 703 | bits_expect = &test_predefined_chars_mod("Hex", :hex, &1, FixedHexBytes, &2) 704 | 705 | bits_expect.(3, "c") 706 | bits_expect.(4, "c") 707 | bits_expect.(5, "c7") 708 | bits_expect.(8, "c7") 709 | bits_expect.(9, "c7c") 710 | bits_expect.(12, "c7c") 711 | bits_expect.(15, "c7c9") 712 | bits_expect.(16, "c7c9") 713 | bits_expect.(19, "c7c90") 714 | bits_expect.(20, "c7c90") 715 | bits_expect.(23, "c7c900") 716 | bits_expect.(24, "c7c900") 717 | bits_expect.(27, "c7c9002") 718 | bits_expect.(28, "c7c9002") 719 | bits_expect.(31, "c7c9002a") 720 | bits_expect.(32, "c7c9002a") 721 | end 722 | 723 | test "hex upper" do 724 | defmodule(HexUpperBytes, 725 | do: 726 | use(Puid.Util.FixedBytes, 727 | bytes: <<0xC7, 0xC9, 0x00, 0x2A, 0x16, 0x32>> 728 | ) 729 | ) 730 | 731 | defmodule(HexUpperId, 732 | do: use(Puid, bits: 16, chars: :hex_upper, rand_bytes: &HexUpperBytes.rand_bytes/1) 733 | ) 734 | 735 | assert HexUpperId.generate() == "C7C9" 736 | assert HexUpperId.generate() == "002A" 737 | assert HexUpperId.generate() == "1632" 738 | end 739 | 740 | test "safe ascii" do 741 | defmodule(SafeAsciiBytes, 742 | do: 743 | use(Puid.Util.FixedBytes, bytes: <<0xA6, 0x33, 0x2A, 0xBE, 0xE6, 0x2D, 0xB3, 0x68, 0x41>>) 744 | ) 745 | 746 | # shifts: [{89, 7}, {91, 6}, {95, 5}, {127, 2}] 747 | # 748 | # A 6 3 3 2 A B E E 6 2 D B 3 6 8 749 | # 1010 0110 0011 0011 0010 1010 1011 1110 1110 0110 0010 1101 1011 0011 0110 1000 0100 0001 750 | # 751 | # 1010011 0001100 11 0010010 0101111 10111 0011000 101101 1011001 101101 0000100 0001 752 | # |-----| |-----| xx |-----| |-----| xxxxx |-----| xxxxxx |-----| xxxxxx |-----| 753 | # 83 12 101 21 47 92 24 91 89 90 4 754 | # x / 8 R ; ~ & 755 | 756 | bits_expect = &test_predefined_chars_mod("SafeAscii", :safe_ascii, &1, SafeAsciiBytes, &2) 757 | 758 | bits_expect.(6, "x") 759 | bits_expect.(12, "x/") 760 | bits_expect.(18, "x/8") 761 | bits_expect.(22, "x/8R") 762 | bits_expect.(26, "x/8R;") 763 | bits_expect.(34, "x/8R;~") 764 | bits_expect.(40, "x/8R;~&") 765 | end 766 | 767 | test "safe32 chars (5 bits)" do 768 | defmodule(Safe32Bytes, 769 | do: use(Puid.Util.FixedBytes, bytes: <<0xD2, 0xE3, 0xE9, 0xDA, 0x19, 0x03, 0xB7, 0x3C>>) 770 | ) 771 | 772 | # D 2 E 3 E 9 D A 1 9 0 3 B 7 3 C 773 | # 1101 0010 1110 0011 1110 1001 1101 1010 0001 1001 0000 0011 1011 0111 0011 1100 774 | # 775 | # 11010 01011 10001 11110 10011 10110 10000 11001 00000 01110 11011 10011 1100 776 | # |---| |---| |---| |---| |---| |---| |---| |---| |---| |---| |---| |---| 777 | # 26 11 17 30 19 22 16 25 0 14 27 19 778 | # M h r R B G q L 2 n N B 779 | 780 | bits_expect = &test_predefined_chars_mod("Safe32", :safe32, &1, Safe32Bytes, &2) 781 | 782 | bits_expect.(4, "M") 783 | bits_expect.(5, "M") 784 | bits_expect.(6, "Mh") 785 | bits_expect.(10, "Mh") 786 | bits_expect.(11, "Mhr") 787 | bits_expect.(15, "Mhr") 788 | bits_expect.(16, "MhrR") 789 | bits_expect.(20, "MhrR") 790 | bits_expect.(21, "MhrRB") 791 | bits_expect.(25, "MhrRB") 792 | bits_expect.(26, "MhrRBG") 793 | bits_expect.(30, "MhrRBG") 794 | bits_expect.(31, "MhrRBGq") 795 | bits_expect.(35, "MhrRBGq") 796 | bits_expect.(36, "MhrRBGqL") 797 | bits_expect.(40, "MhrRBGqL") 798 | bits_expect.(41, "MhrRBGqL2") 799 | bits_expect.(45, "MhrRBGqL2") 800 | bits_expect.(46, "MhrRBGqL2n") 801 | bits_expect.(50, "MhrRBGqL2n") 802 | bits_expect.(52, "MhrRBGqL2nN") 803 | bits_expect.(58, "MhrRBGqL2nNB") 804 | end 805 | 806 | test "safe32 with carry" do 807 | defmodule(Safe32NoCarryBytes, 808 | do: use(Puid.Util.FixedBytes, bytes: <<0xD2, 0xE3, 0xE9, 0xDA, 0x19, 0x03, 0xB7, 0x3C>>) 809 | ) 810 | 811 | # D 2 E 3 E 9 D A 1 9 0 3 B 7 3 C 812 | # 1101 0010 1110 0011 1110 1001 1101 1010 0001 1001 0000 0011 1011 0111 0011 1100 813 | # 814 | # 11010 01011 10001 11110 10011 10110 10000 11001 00000 01110 11011 10011 1100 815 | # |---| |---| |---| |---| |---| |---| |---| |---| |---| |---| |---| |---| 816 | # 26 11 17 30 19 22 16 25 0 14 27 19 817 | # M h r R B G q L 2 n N B 818 | 819 | defmodule(Safe32CarryId, 820 | do: use(Puid, bits: 20, chars: :safe32, rand_bytes: &Safe32NoCarryBytes.rand_bytes/1) 821 | ) 822 | 823 | assert Safe32CarryId.generate() == "MhrR" 824 | assert Safe32CarryId.generate() == "BGqL" 825 | assert Safe32CarryId.generate() == "2nNB" 826 | end 827 | 828 | @tag :only 829 | test "wordSafe32 chars (5 bits)" do 830 | defmodule(WordSafe32Bytes, 831 | do: use(Puid.Util.FixedBytes, bytes: <<0xD2, 0xE3, 0xE9, 0xDA, 0x19, 0x03, 0xB7, 0x3C>>) 832 | ) 833 | 834 | # D 2 E 3 E 9 D A 1 9 0 3 B 7 3 C 835 | # 1101 0010 1110 0011 1110 1001 1101 1010 0001 1001 0000 0011 1011 0111 0011 1100 836 | # 837 | # 11010 01011 10001 11110 10011 10110 10000 11001 00000 01110 11011 10011 1100 838 | # |---| |---| |---| |---| |---| |---| |---| |---| |---| |---| |---| |---| 839 | # 26 11 17 30 19 22 16 25 0 14 27 19 840 | # p H V w X g R m 2 P q X 841 | 842 | bits_expect = &test_predefined_chars_mod("WordSafe32", :wordSafe32, &1, WordSafe32Bytes, &2) 843 | 844 | bits_expect.(58, "pHVwXgRm2PqX") 845 | end 846 | 847 | test "safe64 chars (6 bits)" do 848 | defmodule(Safe64Bytes, 849 | do: use(Puid.Util.FixedBytes, bytes: <<0xD2, 0xE3, 0xE9, 0xFA, 0x19, 0x00>>) 850 | ) 851 | 852 | bits_expect = &test_predefined_chars_mod("Safe64", :safe64, &1, Safe64Bytes, &2) 853 | 854 | bits_expect.(24, "0uPp") 855 | bits_expect.(25, "0uPp-") 856 | bits_expect.(42, "0uPp-hk") 857 | bits_expect.(47, "0uPp-hkA") 858 | bits_expect.(48, "0uPp-hkA") 859 | end 860 | 861 | test "TF chars without carry" do 862 | defmodule(TFNoCarryBytes, 863 | do: use(Puid.Util.FixedBytes, bytes: <<0b11111011, 0b00000100, 0b00101100, 0b10110011>>) 864 | ) 865 | 866 | defmodule(TFNoCarryId, 867 | do: use(Puid, bits: 16, chars: ~c"FT", rand_bytes: &TFNoCarryBytes.rand_bytes/1) 868 | ) 869 | 870 | assert TFNoCarryId.generate() == "TTTTTFTTFFFFFTFF" 871 | assert TFNoCarryId.generate() == "FFTFTTFFTFTTFFTT" 872 | end 873 | 874 | test "DingoSky chars without carry" do 875 | defmodule(DingoSkyNoCarryBytes, 876 | do: use(Puid.Util.FixedBytes, bytes: <<0xC7, 0xC9, 0x00, 0x2A, 0xBD, 0x72>>) 877 | ) 878 | 879 | # C 7 C 9 0 0 2 A B D 7 2 880 | # 1100 0111 1100 1001 0000 0000 0010 1010 1011 1101 0111 0010 881 | # 882 | # 110 001 111 100 100 100 000 000 001 010 101 011 110 101 110 010 883 | # |-| |-| |-| |-| |-| |-| |-| |-| |-| |-| |-| |-| |-| |-| |-| |-| 884 | # k i y o o o d d i n s g k s k n 885 | 886 | defmodule(DingoSkyNoCarryId, 887 | do: use(Puid, bits: 24, chars: ~c"dingosky", rand_bytes: &DingoSkyNoCarryBytes.rand_bytes/1) 888 | ) 889 | 890 | assert DingoSkyNoCarryId.generate() == "kiyooodd" 891 | assert DingoSkyNoCarryId.generate() == "insgkskn" 892 | end 893 | 894 | test "dingosky chars with carry" do 895 | defmodule(DingoSkyCarryBytes, 896 | do: use(Puid.Util.FixedBytes, bytes: <<0xC7, 0xC9, 0x00, 0x2A, 0xBD, 0x72>>) 897 | ) 898 | 899 | # C 7 C 9 0 0 2 A B D 7 2 900 | # 1100 0111 1100 1001 0000 0000 0010 1010 1011 1101 0111 0010 901 | # 902 | # 110 001 111 100 100 100 000 000 001 010 101 011 110 101 110 010 903 | # |-| |-| |-| |-| |-| |-| |-| |-| |-| |-| |-| |-| |-| |-| |-| |-| 904 | # k i y o o o d d i n s g k s k n 905 | 906 | defmodule(DingoSkyCarryId, 907 | do: use(Puid, bits: 9, chars: ~c"dingosky", rand_bytes: &DingoSkyCarryBytes.rand_bytes/1) 908 | ) 909 | 910 | assert DingoSkyCarryId.generate() == "kiy" 911 | assert DingoSkyCarryId.generate() == "ooo" 912 | assert DingoSkyCarryId.generate() == "ddi" 913 | assert DingoSkyCarryId.generate() == "nsg" 914 | assert DingoSkyCarryId.generate() == "ksk" 915 | end 916 | 917 | test "dîngøsky chars with carry" do 918 | defmodule(DingoSkyUtf8Bytes, 919 | do: use(Puid.Util.FixedBytes, bytes: <<0xC7, 0xC9, 0x00, 0x2A, 0xBD, 0x72>>) 920 | ) 921 | 922 | # C 7 C 9 0 0 2 A B D 7 2 923 | # 1100 0111 1100 1001 0000 0000 0010 1010 1011 1101 0111 0010 924 | # 925 | # 110 001 111 100 100 100 000 000 001 010 101 011 110 101 110 010 926 | # |-| |-| |-| |-| |-| |-| |-| |-| |-| |-| |-| |-| |-| |-| |-| |-| 927 | # k î y ø ø ø d d î n s g k s k n 928 | 929 | defmodule(DingoskyUtf8CarryId, 930 | do: use(Puid, bits: 9, chars: ~c"dîngøsky", rand_bytes: &DingoSkyUtf8Bytes.rand_bytes/1) 931 | ) 932 | 933 | assert DingoskyUtf8CarryId.generate() == "kîy" 934 | assert DingoskyUtf8CarryId.generate() == "øøø" 935 | assert DingoskyUtf8CarryId.generate() == "ddî" 936 | assert DingoskyUtf8CarryId.generate() == "nsg" 937 | assert DingoskyUtf8CarryId.generate() == "ksk" 938 | end 939 | 940 | test "dîngøsky:🐕" do 941 | defmodule(DogBytes, 942 | do: 943 | use(Puid.Util.FixedBytes, 944 | bytes: 945 | <<0xEC, 0xF9, 0xDB, 0x7A, 0x33, 0x3D, 0x21, 0x97, 0xA0, 0xC2, 0xBF, 0x92, 0x80, 0xDD, 946 | 0x2F, 0x57, 0x12, 0xC1, 0x1A, 0xEF>> 947 | ) 948 | ) 949 | 950 | defmodule(DogId, 951 | do: use(Puid, bits: 24, chars: ~c"dîngøsky:🐕", rand_bytes: &DogBytes.rand_bytes/1) 952 | ) 953 | 954 | assert DogId.generate() == "🐕gî🐕🐕nî🐕" 955 | assert DogId.generate() == "ydkîsnsd" 956 | assert DogId.generate() == "îøsîndøk" 957 | end 958 | 959 | test "10 custom vowels chars (4 bits)" do 960 | defmodule(VowelBytes, 961 | do: use(Puid.Util.FixedBytes, bytes: <<0xA6, 0x33, 0xF6, 0x9E, 0xBD, 0xEE, 0xA7>>) 962 | ) 963 | 964 | bits_expect = &test_custom_chars_mod("Vowels", "aeiouAEIOU", &1, VowelBytes, &2) 965 | 966 | # shifts: [{9, 4}, {11, 3}, {15, 2}] 967 | # 968 | # A 6 3 3 F 6 9 E B D E E A 7 969 | # 1010 0110 0011 0011 1111 0110 1001 1110 1011 1101 1110 1110 1010 0111 970 | # 971 | # 101 0011 0001 1001 11 11 101 101 0011 11 0101 11 101 11 101 11 0101 0011 1 972 | # xxx |--| |--| |--| xx xx xxx xxx |--| xx |--| xx xxx xx xxx xx |--| |--| 973 | # 10 3 1 9 15 14 11 10 3 13 5 14 11 14 11 13 5 3 974 | # o e U o A A o 975 | # 976 | 977 | bits_expect.(3, "o") 978 | bits_expect.(6, "oe") 979 | bits_expect.(9, "oeU") 980 | bits_expect.(12, "oeUo") 981 | bits_expect.(15, "oeUoA") 982 | bits_expect.(18, "oeUoAA") 983 | bits_expect.(20, "oeUoAAo") 984 | end 985 | 986 | test "256 chars" do 987 | defmodule(SomeBytes, 988 | do: 989 | use(Puid.Util.FixedBytes, 990 | bytes: 991 | <<0xEC, 0xF9, 0xDB, 0x7A, 0x33, 0x3D, 0x21, 0x97, 0xA0, 0xC2, 0xBF, 0x92, 0x80, 0xDD, 992 | 0x2F, 0x57, 0x12, 0xC1, 0x1A, 0xEF>> 993 | ) 994 | ) 995 | 996 | single_byte = Chars.charlist!(:safe64) 997 | n_single = length(single_byte) 998 | 999 | n_double = 128 1000 | double_start = 0x0100 1001 | double_byte = 0..(n_double - 1) |> Enum.map(&(&1 + double_start)) 1002 | 1003 | n_triple = 64 1004 | triple_start = 0x4DC0 1005 | triple_byte = 0..(n_triple - 1) |> Enum.map(&(&1 + triple_start)) 1006 | 1007 | chars = single_byte ++ double_byte ++ triple_byte 1008 | 1009 | defmodule(C256Id, do: use(Puid, bits: 36, chars: chars, rand_bytes: &SomeBytes.rand_bytes/1)) 1010 | 1011 | info = C256Id.info() 1012 | 1013 | assert info.length == 5 1014 | assert info.entropy_bits_per_char == 8.0 1015 | assert info.ere == 0.5 1016 | 1017 | assert C256Id.generate() == "䷬䷹䷛ĺz" 1018 | assert C256Id.generate() == "9hŗŠ䷂" 1019 | assert C256Id.generate() == "ſŒŀ䷝v" 1020 | assert C256Id.generate() == "ėS䷁a䷯" 1021 | end 1022 | 1023 | # This doesn't actually test the modules, but defines each case as per issue #12 1024 | test "Cover all chunk sizings" do 1025 | defmodule(LessSingleId, do: use(Puid, bits: 5, chars: :safe64)) 1026 | defmodule(SingleId, do: use(Puid, bits: 40, chars: :safe32)) 1027 | defmodule(LessPairId, do: use(Puid, bits: 40, chars: "dingoskyme")) 1028 | defmodule(EqualPairId, do: use(Puid, bits: 64, chars: :hex)) 1029 | defmodule(EqualPairsId, do: use(Puid, bits: 128, chars: :hex)) 1030 | defmodule(LessPairsPlusSingleId, do: use(Puid, bits: 148, chars: :hex)) 1031 | defmodule(EqualPairPlusSingleId, do: use(Puid, bits: 140, chars: :alphanum)) 1032 | defmodule(EqualPairsPlusSingleId, do: use(Puid, bits: 196, chars: :wordSafe32)) 1033 | defmodule(GreaterPairsPlusSingleId, do: use(Puid, bits: 220, chars: :safe32)) 1034 | end 1035 | 1036 | test "Calling process not the same as creating process" do 1037 | defmodule(HereId, do: use(Puid)) 1038 | assert String.length(HereId.generate()) === HereId.info().length 1039 | spawn(fn -> assert String.length(HereId.generate()) === HereId.info().length end) 1040 | 1041 | defmodule(HereAlphanumId, do: use(Puid, chars: :alphanum)) 1042 | assert String.length(HereAlphanumId.generate()) === HereAlphanumId.info().length 1043 | 1044 | spawn(fn -> 1045 | assert String.length(HereAlphanumId.generate()) === HereAlphanumId.info().length 1046 | end) 1047 | end 1048 | 1049 | test "Calling process not the same as creating process: fixed bytes" do 1050 | defmodule(HereVowelBytes, 1051 | do: 1052 | use(Puid.Util.FixedBytes, 1053 | bytes: <<0xA6, 0x33, 0xF6, 0x9E, 0xBD, 0xEE, 0xA7, 0x54, 0x9F, 0x2D>> 1054 | ) 1055 | ) 1056 | 1057 | defmodule(HereVowelId, 1058 | do: use(Puid, bits: 15, chars: "aeiouAEIOU", rand_bytes: &HereVowelBytes.rand_bytes/1) 1059 | ) 1060 | 1061 | assert HereVowelId.generate() === "oeUoA" 1062 | assert HereVowelId.generate() === "AoAiI" 1063 | 1064 | spawn(fn -> assert HereVowelId.generate() === "oeUoA" end) 1065 | 1066 | spawn(fn -> 1067 | assert HereVowelId.generate() === "oeUoA" 1068 | assert HereVowelId.generate() === "AoAiI" 1069 | end) 1070 | end 1071 | end 1072 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # 3 | # Copyright (c) 2019-2023 Knoxen 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in all 13 | # copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | # SOFTWARE. 22 | ExUnit.start() 23 | 24 | defmodule Puid.Test.Util do 25 | @moduledoc false 26 | 27 | def binary_digits(bits, group \\ 4) when is_bitstring(bits) and 0 < group, 28 | do: binary_digits(bits, "", group) 29 | 30 | defp binary_digits(<<>>, digits, group), do: digits |> group_digits(group) 31 | 32 | defp binary_digits(<<0::1, rest::bits>>, digits, group), 33 | do: binary_digits(<>, <>, group) 34 | 35 | defp binary_digits(<<1::1, rest::bits>>, digits, group), 36 | do: binary_digits(<>, <>, group) 37 | 38 | defp group_digits(binary_digits, group) do 39 | group_digits({"", binary_digits}, "", group) |> String.trim() 40 | end 41 | 42 | defp group_digits({octet, ""}, acc, _group), do: <> 43 | 44 | defp group_digits({octet, rest}, acc, group), 45 | do: group_digits(rest |> String.split_at(group), <>, group) 46 | 47 | def print_bits(bits), do: print_bits(bits, "bits", 4) 48 | 49 | def print_bits(bits, msg) when is_binary(msg), do: print_bits(bits, msg, 4) 50 | 51 | def print_bits(bits, group) when is_integer(group), do: print_bits(bits, "bits", group) 52 | 53 | def print_bits(bits, msg, group) do 54 | bits |> binary_digits(group) |> IO.inspect(label: msg) 55 | bits 56 | end 57 | end 58 | 59 | defmodule Puid.Test.Data do 60 | @moduledoc false 61 | use ExUnit.Case, async: true 62 | 63 | def path(file_name), do: Path.join([Path.absname(""), "test", "data", file_name]) 64 | 65 | def params(data_name) do 66 | params = File.open!(Puid.Test.Data.path(Path.join(data_name, "params")), [:utf8]) 67 | next_param = fn -> params |> IO.read(:line) |> String.trim_trailing() end 68 | 69 | bin_file = Puid.Test.Data.path(next_param.()) 70 | test_name = next_param.() 71 | total = String.to_integer(next_param.()) 72 | risk = String.to_float(next_param.()) 73 | 74 | chars = 75 | String.split(next_param.(), ":") 76 | |> case do 77 | ["predefined", atom] -> 78 | String.to_atom(atom) 79 | 80 | ["custom", string] -> 81 | string 82 | end 83 | 84 | id_count = String.to_integer(next_param.()) 85 | 86 | %{ 87 | bin_file: bin_file, 88 | test_name: test_name, 89 | total: total, 90 | risk: risk, 91 | chars: chars, 92 | id_count: id_count 93 | } 94 | end 95 | 96 | def data_id_mod(data_name) do 97 | %{ 98 | :bin_file => bin_file, 99 | :test_name => test_name, 100 | :total => total, 101 | :risk => risk, 102 | :chars => chars 103 | } = params(data_name) 104 | 105 | data_bytes_mod = "#{test_name}Bytes" |> String.to_atom() 106 | 107 | defmodule(data_bytes_mod, 108 | do: use(Puid.Util.FixedBytes, data_path: bin_file) 109 | ) 110 | 111 | data_id_mod = "#{test_name}Id" |> String.to_atom() 112 | 113 | defmodule(data_id_mod, 114 | do: 115 | use(Puid, 116 | total: total, 117 | risk: risk, 118 | chars: chars, 119 | rand_bytes: &data_bytes_mod.rand_bytes/1 120 | ) 121 | ) 122 | 123 | data_id_mod 124 | end 125 | 126 | def test(data_name) do 127 | data_id_mod = data_id_mod(data_name) 128 | 129 | path(Path.join(data_name, "ids")) 130 | |> File.stream!() 131 | |> Stream.map(&String.trim_trailing/1) 132 | |> Stream.each(fn id -> assert data_id_mod.generate() == id end) 133 | |> Stream.run() 134 | end 135 | 136 | def write_file_data(data_name) do 137 | data_id_mod = Puid.Test.Data.data_id_mod(data_name) 138 | 139 | %{:id_count => id_count} = params(data_name) 140 | 141 | ids_file = File.stream!(path(Path.join(data_name, "ids"))) 142 | 143 | 1..id_count 144 | |> Stream.map(fn _ -> data_id_mod.generate() end) 145 | |> Stream.map(&[&1, "\n"]) 146 | |> Enum.into(ids_file) 147 | end 148 | end 149 | -------------------------------------------------------------------------------- /test/timing.exs: -------------------------------------------------------------------------------- 1 | # MIT License 2 | # 3 | # Copyright (c) 2019-2023 Knoxen 4 | # 5 | # Permission is hereby granted, free of charge, to any person obtaining a copy 6 | # of this software and associated documentation files (the "Software"), to deal 7 | # in the Software without restriction, including without limitation the rights 8 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | # copies of the Software, and to permit persons to whom the Software is 10 | # furnished to do so, subject to the following conditions: 11 | # 12 | # The above copyright notice and this permission notice shall be included in all 13 | # copies or substantial portions of the Software. 14 | # 15 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | # SOFTWARE. 22 | defmodule Puid.Test.Timing do 23 | use ExUnit.Case 24 | 25 | def time(function, label) do 26 | function 27 | |> :timer.tc() 28 | |> elem(0) 29 | |> Kernel./(1_000_000) 30 | |> IO.inspect(label: label) 31 | end 32 | 33 | def common_solution(len, chars) do 34 | chars_count = String.length(chars) 35 | 36 | for(_ <- 1..len, do: :rand.uniform(chars_count) - 1) 37 | |> Enum.map(&(chars |> String.at(&1))) 38 | |> List.to_string() 39 | end 40 | 41 | @tag :common_solution 42 | test "compare to common solution using alphanumeric characters" do 43 | trials = 100_000 44 | 45 | defmodule(AlphanumPuid128_CS, do: use(Puid, chars: :alphanum)) 46 | 47 | defmodule(AlphanumPrngPuid128_CS, 48 | do: use(Puid, chars: :alphanum, rand_bytes: &:rand.bytes/1) 49 | ) 50 | 51 | chars = AlphanumPuid128_CS.info().characters 52 | len = AlphanumPuid128_CS.info().length 53 | common = fn -> for(_ <- 1..trials, do: common_solution(len, chars)) end 54 | puid = fn -> for(_ <- 1..trials, do: AlphanumPuid128_CS.generate()) end 55 | prng_puid = fn -> for(_ <- 1..trials, do: AlphanumPrngPuid128_CS.generate()) end 56 | 57 | IO.puts("\n--- Common Solution ---") 58 | 59 | IO.puts( 60 | "\n Generate #{trials} random IDs with 128 bits of entropy using alphanumeric characters" 61 | ) 62 | 63 | :rand.seed(:exsss) 64 | IO.puts("") 65 | time(common, " Common Solution (PRNG) ") 66 | time(prng_puid, " Puid (PRNG) ") 67 | IO.puts("") 68 | :crypto.rand_seed() 69 | time(common, " Common Solution (CSPRNG) ") 70 | time(puid, " Puid (CSPRNG) ") 71 | end 72 | 73 | @tag :entropy_string 74 | test "compare to :entropy_string" do 75 | trials = 100_000 76 | 77 | IO.puts("\n--- EntropyString ---") 78 | 79 | IO.puts( 80 | "\n Generate #{trials} random IDs with 128 bits of entropy using #{:safe64} characters" 81 | ) 82 | 83 | defmodule(Safe64ES, do: use(EntropyString, chars: :charset64)) 84 | defmodule(Safe64Puid128_ES, do: use(Puid, chars: :safe64)) 85 | 86 | entropy_string = fn -> for(_ <- 1..trials, do: Safe64ES.random()) end 87 | puid = fn -> for(_ <- 1..trials, do: Safe64Puid128_ES.generate()) end 88 | 89 | IO.puts("") 90 | time(entropy_string, " Entropy String (CSPRNG) ") 91 | time(puid, " Puid (CSPRNG) ") 92 | 93 | defmodule(DingoskyPuid64, do: use(Puid, bits: 64, chars: "dingosky")) 94 | 95 | chars = DingoskyPuid64.info().characters 96 | defmodule(CustomES64, do: use(EntropyString, bits: 64, chars: chars)) 97 | 98 | IO.puts( 99 | "\n Generate #{trials} random IDs with 64 bits of entropy using #{String.length(chars)} custom characters" 100 | ) 101 | 102 | entropy_string = fn -> for(_ <- 1..trials, do: CustomES64.random()) end 103 | puid = fn -> for(_ <- 1..trials, do: DingoskyPuid64.generate()) end 104 | 105 | IO.puts("") 106 | time(entropy_string, " Entropy String (CSPRNG) ") 107 | time(puid, " Puid (CSPRNG) ") 108 | end 109 | 110 | def gen_reference() do 111 | min = String.to_integer("100000", 36) 112 | max = String.to_integer("ZZZZZZ", 36) 113 | 114 | max 115 | |> Kernel.-(min) 116 | |> :rand.uniform() 117 | |> Kernel.+(min) 118 | |> Integer.to_string(36) 119 | end 120 | 121 | @tag :gen_reference 122 | test "compare to gen_reference" do 123 | trials = 500_000 124 | 125 | IO.puts("\n--- gen_reference ---") 126 | 127 | IO.puts( 128 | "\n Generate #{trials} random IDs with 31 bits of entropy using #{:alphanum_upper} characters" 129 | ) 130 | 131 | gen_reference = fn -> for(_ <- 1..trials, do: gen_reference()) end 132 | 133 | defmodule(UpperAlphanumPRNGPuid31, 134 | do: use(Puid, bits: 31, chars: :alphanum_upper, rand_bytes: &:rand.bytes/1) 135 | ) 136 | 137 | prng_puid = fn -> for(_ <- 1..trials, do: UpperAlphanumPRNGPuid31.generate()) end 138 | 139 | defmodule(UpperAlphanumPuid31, do: use(Puid, bits: 31, chars: :alphanum_upper)) 140 | puid = fn -> for(_ <- 1..trials, do: UpperAlphanumPuid31.generate()) end 141 | 142 | IO.puts("") 143 | :rand.seed(:exsss) 144 | time(gen_reference, " gen_reference (PRNG) ") 145 | time(prng_puid, " Puid (PRNG) ") 146 | IO.puts("") 147 | :crypto.rand_seed() 148 | time(gen_reference, " gen_reference (CSPRNG) ") 149 | time(puid, " Puid (CSPRNG) ") 150 | 151 | IO.puts("\n Generate #{trials} random IDs with 31 bits of entropy using :safe32 characters") 152 | defmodule(Safe32Puid, do: use(Puid, bits: 31, chars: :safe32)) 153 | safe32_puid = fn -> for(_ <- 1..trials, do: Safe32Puid.generate()) end 154 | 155 | IO.puts("") 156 | time(gen_reference, " gen_reference (CSPRNG) ") 157 | time(safe32_puid, " Puid safe32 (CSPRNG) ") 158 | end 159 | 160 | @tag :misc_random 161 | test "compare to Misc.Random alphanum" do 162 | trials = 30_000 163 | 164 | IO.puts("\n--- Misc.Random ---") 165 | 166 | IO.puts( 167 | "\n Generate #{trials} random IDs with 128 bits of entropy using #{:alphanum} characters" 168 | ) 169 | 170 | defmodule(AlphanumPrngPuid128_MR, 171 | do: use(Puid, chars: :alphanum, rand_bytes: &:rand.bytes/1) 172 | ) 173 | 174 | defmodule(AlphanumPuid128_MR, do: use(Puid, chars: :alphanum)) 175 | 176 | misc_random = fn -> for(_ <- 1..trials, do: Misc.Random.string(22)) end 177 | prng_puid = fn -> for(_ <- 1..trials, do: AlphanumPrngPuid128_MR.generate()) end 178 | puid = fn -> for(_ <- 1..trials, do: AlphanumPuid128_MR.generate()) end 179 | 180 | IO.puts("") 181 | :rand.seed(:exsss) 182 | time(misc_random, " Misc.Random (PRNG) ") 183 | time(prng_puid, " Puid (PRNG) ") 184 | 185 | IO.puts("") 186 | :crypto.rand_seed() 187 | time(misc_random, " Misc.Random (CSPRNG) ") 188 | time(puid, " Puid (CSPRNG) ") 189 | end 190 | 191 | @tag :nanoid 192 | test "compare to nanoid" do 193 | trials = 75_000 194 | 195 | IO.puts("\n--- nanoid ---") 196 | 197 | IO.puts( 198 | "\n Generate #{trials} random IDs with 126 bits of entropy using #{:safe64} characters" 199 | ) 200 | 201 | defmodule(Safe64Puid126, do: use(Puid, bits: 126, chars: :safe64)) 202 | 203 | defmodule(Safe64Puid126_prng, 204 | do: use(Puid, bits: 126, chars: :safe64, rand_bytes: &:rand.bytes/1) 205 | ) 206 | 207 | defmodule(AlphaNumPuid195, do: use(Puid, bits: 195, chars: :alphanum)) 208 | defmodule(AlphaNumPuid195_prng, do: use(Puid, bits: 195, chars: :alphanum)) 209 | 210 | puid_safe64_126 = fn -> for(_ <- 1..trials, do: Safe64Puid126.generate()) end 211 | nanoid_safe64_126 = fn -> for(_ <- 1..trials, do: Nanoid.generate()) end 212 | 213 | puid_safe64_126_prng = fn -> for(_ <- 1..trials, do: Safe64Puid126_prng.generate()) end 214 | nanoid_safe64_126_prng = fn -> for(_ <- 1..trials, do: Nanoid.generate_non_secure()) end 215 | 216 | puid_alphanum_195 = fn -> for(_ <- 1..trials, do: AlphaNumPuid195.generate()) end 217 | 218 | nanoid_alphanum_195 = fn -> 219 | for( 220 | _ <- 1..trials, 221 | do: Nanoid.generate(33, "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789") 222 | ) 223 | end 224 | 225 | puid_alphanum_195_prng = fn -> for(_ <- 1..trials, do: AlphaNumPuid195_prng.generate()) end 226 | 227 | nanoid_alphanum_195_prng = fn -> 228 | for( 229 | _ <- 1..trials, 230 | do: 231 | Nanoid.generate_non_secure( 232 | 33, 233 | "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789" 234 | ) 235 | ) 236 | end 237 | 238 | IO.puts("") 239 | time(nanoid_safe64_126, " Nanoid (CSPRNG) ") 240 | time(puid_safe64_126, " Puid (CSPRNG) ") 241 | 242 | IO.puts("") 243 | time(nanoid_safe64_126_prng, " Nanoid (PRNG) ") 244 | time(puid_safe64_126_prng, " Puid (PRNG) ") 245 | 246 | IO.puts( 247 | "\n Generate #{trials} random IDs with 195 bits of entropy using #{:alphanum} characters" 248 | ) 249 | 250 | IO.puts("") 251 | time(nanoid_alphanum_195, " Nanoid (CSPRNG) ") 252 | time(puid_alphanum_195, " Puid (CSPRNG) ") 253 | 254 | IO.puts("") 255 | time(nanoid_alphanum_195_prng, " Nanoid (PRNG) ") 256 | time(puid_alphanum_195_prng, " Puid (PRNG) ") 257 | end 258 | 259 | @tag :randomizer 260 | test "compare to Randomizer" do 261 | trials = 100_000 262 | 263 | IO.puts("\n--- randomizer ---") 264 | 265 | IO.puts( 266 | "\n Generate #{trials} random IDs with 128 bits of entropy using #{:alphanum} characters" 267 | ) 268 | 269 | defmodule(AlphanumPuid128_R, do: use(Puid, chars: :alphanum)) 270 | 271 | defmodule(AlphanumPrngPuid128_R, do: use(Puid, chars: :alphanum, rand_bytes: &:rand.bytes/1)) 272 | 273 | randomizer = fn -> for(_ <- 1..trials, do: Randomizer.generate!(22)) end 274 | puid = fn -> for(_ <- 1..trials, do: AlphanumPuid128_R.generate()) end 275 | prng_puid = fn -> for(_ <- 1..trials, do: AlphanumPrngPuid128_R.generate()) end 276 | 277 | IO.puts("") 278 | :rand.seed(:exsss) 279 | time(randomizer, " Randomizer (PRNG) ") 280 | time(prng_puid, " Puid (PRNG) ") 281 | 282 | IO.puts("") 283 | :crypto.rand_seed() 284 | time(randomizer, " Randomizer (CSPRNG) ") 285 | time(puid, " Puid (CSPRNG) ") 286 | end 287 | 288 | @tag :secure_random 289 | test "compare to SecureRandom safe64" do 290 | trials = 500_000 291 | 292 | IO.puts("\n--- secure_random ---") 293 | 294 | IO.puts("\n Generate #{trials} random IDs with 128 bits of entropy using #{:hex} characters") 295 | 296 | defmodule(HexPuid128_SR, do: use(Puid, chars: :hex)) 297 | defmodule(Safe64Puid128_SR, do: use(Puid, chars: :safe64)) 298 | 299 | hex_secure_random = fn -> for(_ <- 1..trials, do: SecureRandom.hex(16)) end 300 | hex_puid = fn -> for(_ <- 1..trials, do: HexPuid128_SR.generate()) end 301 | 302 | IO.puts("") 303 | time(hex_secure_random, " SecureRandom (CSPRNG) ") 304 | time(hex_puid, " Puid (CSPRNG) ") 305 | 306 | IO.puts( 307 | "\n Generate #{trials} random IDs with 128 bits of entropy using #{:safe64} characters" 308 | ) 309 | 310 | secure_random = fn -> for(_ <- 1..trials, do: SecureRandom.urlsafe_base64()) end 311 | puid = fn -> for(_ <- 1..trials, do: Safe64Puid128_SR.generate()) end 312 | 313 | IO.puts("") 314 | time(secure_random, " SecureRandom (CSPRNG) ") 315 | time(puid, " Puid (CSPRNG) ") 316 | end 317 | 318 | @tag :custom_chars 319 | test "compare Puid hex to 16 custom chars" do 320 | trials = 500_000 321 | 322 | IO.puts("\n--- Custom chars vs pre-defined with same length ---") 323 | 324 | IO.puts( 325 | "\n Generate #{trials} random IDs with 128 bits of entropy using hex vs 16 custom characters" 326 | ) 327 | 328 | defmodule(CustomPuid128, do: use(Puid, bits: 128, chars: "DINGOSKYdingosky")) 329 | defmodule(HexPuid128_CC, do: use(Puid, chars: :hex)) 330 | 331 | custom_puid = fn -> for(_ <- 1..trials, do: CustomPuid128.generate()) end 332 | hex_puid = fn -> for(_ <- 1..trials, do: HexPuid128_CC.generate()) end 333 | 334 | IO.puts("") 335 | time(hex_puid, " Puid hex ") 336 | time(custom_puid, " Puid custom ") 337 | end 338 | 339 | @tag :prng_csprng 340 | test "compare Puid PRNG to CSPRNG with hex chars" do 341 | trials = 500_000 342 | 343 | IO.puts("\n--- Puid PRNG vs CSPRNG ---") 344 | 345 | IO.puts( 346 | "\n Generate #{trials} random IDs with 128 bits of entropy using hex vs 16 custom characters" 347 | ) 348 | 349 | defmodule(HexPrngPuid128, do: use(Puid, chars: :hex, rand_bytes: &:rand.bytes/1)) 350 | 351 | defmodule(CustomPrngPuid128, 352 | do: use(Puid, bits: 128, chars: "DINGOSKYdingosky", rand_bytes: &:rand.bytes/1) 353 | ) 354 | 355 | hex_puid = fn -> for(_ <- 1..trials, do: HexPrngPuid128.generate()) end 356 | custom_puid = fn -> for(_ <- 1..trials, do: CustomPrngPuid128.generate()) end 357 | 358 | IO.puts("") 359 | time(hex_puid, " Puid PRNG ") 360 | time(custom_puid, " Puid CSPRNG ") 361 | end 362 | 363 | @tag :uuid 364 | test "compare to uuid" do 365 | trials = 500_000 366 | 367 | IO.puts("\n--- UUID ---") 368 | 369 | IO.puts("\n Generate #{trials} random IDs") 370 | 371 | defmodule(HexPuid, do: use(Puid, chars: :hex)) 372 | 373 | uuid = fn -> for(_ <- 1..trials, do: UUID.uuid4()) end 374 | puid = fn -> for(_ <- 1..trials, do: HexPuid.generate()) end 375 | 376 | IO.puts("") 377 | time(uuid, " UUID (122 bits) ") 378 | time(puid, " Puid (128 bits) ") 379 | end 380 | 381 | @tag :utf8 382 | test "ascii vs utf8 encoding" do 383 | trials = 500_000 384 | 385 | IO.puts("\n--- ASCII vs UTF-8 ---") 386 | 387 | IO.puts("\n Generate #{trials} random IDs") 388 | 389 | defmodule(AsciiPuid, do: use(Puid, chars: ~c"dingoskyDINGOSKY")) 390 | defmodule(Utf8Puid, do: use(Puid, chars: ~c"dîñgøskyDÎNGØSK¥")) 391 | 392 | ascii = fn -> for(_ <- 1..trials, do: AsciiPuid.generate()) end 393 | utf8 = fn -> for(_ <- 1..trials, do: Utf8Puid.generate()) end 394 | 395 | IO.puts("") 396 | time(ascii, " ASCII") 397 | time(utf8, " UTF-8") 398 | end 399 | end 400 | --------------------------------------------------------------------------------