├── .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 | [](https://hex.pm/packages/puid) []()
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 |
--------------------------------------------------------------------------------