pikaid 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/CHANGELOG.md +51 -0
- data/LICENSE +21 -0
- data/README.md +282 -0
- data/lib/pikaid/codec.rb +49 -0
- data/lib/pikaid/constants.rb +30 -0
- data/lib/pikaid/errors.rb +37 -0
- data/lib/pikaid/id.rb +114 -0
- data/lib/pikaid/validation.rb +54 -0
- data/lib/pikaid/version.rb +9 -0
- data/lib/pikaid.rb +260 -0
- metadata +93 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b56920541de19dfaf3c786b32ecbe641e0cd861905187632871bc6610ed8c9c4
|
|
4
|
+
data.tar.gz: 766fcbcf2f053c8bd2d1da9ec47249baf1b62c325ec6cd336ce5ee805194d5a5
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 66a63d685fbbc25b160e1c48ebde07c7d10ab42d7375ee5da6977cdac144977ff5fad141ba16dfc342b065fb18473e05b423b11e2a1525563fed756b235ca9f8
|
|
7
|
+
data.tar.gz: da008d152fd8e36423ccba3b0d706d401b2da1c95e9f6ae007049d19d160404063daee4deaf64d38459466c9847919af4b39e38ca6580cf4d5faf847ce2c0d07
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to the official Ruby implementation of pikaid are
|
|
4
|
+
documented in this file.
|
|
5
|
+
|
|
6
|
+
The pikaid **identifier format** is immutable and unversioned. Entries here
|
|
7
|
+
version the Ruby library only.
|
|
8
|
+
|
|
9
|
+
## [1.0.0] - 2026-11-07
|
|
10
|
+
|
|
11
|
+
First stable release of the Ruby implementation. The release is prepared in the
|
|
12
|
+
repository; the gem has **not** been published to RubyGems yet.
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- `Pikaid::Id`, an immutable value object with `timestamp`, `randomness`,
|
|
17
|
+
`entropy`, `randomness_hex`, `to_s`, and `to_binary`.
|
|
18
|
+
- Module API: `generate`, `generate_binary`, `parse`, `from_binary`, `valid?`,
|
|
19
|
+
`valid_binary?`, `well_formed?`, `timestamp`, `randomness`, `entropy`,
|
|
20
|
+
`decode`, `to_binary`, and `compare`.
|
|
21
|
+
- Strict validation that rejects malformed, non-canonical, and overflowing
|
|
22
|
+
input instead of coercing, truncating, or wrapping it.
|
|
23
|
+
- `Pikaid::InvalidIdError`, `Pikaid::InvalidTimestampError`, and
|
|
24
|
+
`Pikaid::RandomnessError`, all inheriting from `Pikaid::Error`.
|
|
25
|
+
- Vendored official PHP v1.2.0 conformance vectors, byte-for-byte pinned by
|
|
26
|
+
SHA-256, plus an independent reference oracle for conversion tests.
|
|
27
|
+
- Installed-gem consumer smoke test and packaged-file inventory check in CI.
|
|
28
|
+
- `SECURITY.md`, a reviewer packet mapping every generation path to the CSPRNG
|
|
29
|
+
source and the fail-closed error path for the two mandatory manual audits.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- `Pikaid::Id#hash` now derives from the canonical value only. It previously
|
|
34
|
+
included `self.class`, so a subclass instance that compares equal to a
|
|
35
|
+
`Pikaid::Id` hashed differently and broke `Hash` lookups.
|
|
36
|
+
- Vector fixtures are forced to LF in the working tree, so their recorded
|
|
37
|
+
SHA-256 digests hold on Windows checkouts with `core.autocrlf`.
|
|
38
|
+
- The CI matrix now tracks Ruby 2.6 through 4.0 on Ubuntu, Ruby 4.0 on macOS,
|
|
39
|
+
and Ruby 4.0 on Windows. Ruby 3.5 was never released, so it was replaced by
|
|
40
|
+
the current stable line rather than being tested as a preview.
|
|
41
|
+
- No `Gemfile.lock` is committed (it is intentionally ignored). A local
|
|
42
|
+
lockfile pinned to Bundler 1.17.2 and rake 12.3.3 cannot run on Ruby 3.2 or
|
|
43
|
+
newer, so each Ruby line resolves development dependencies on its own; the
|
|
44
|
+
verified ranges are minitest 5.25.4 + rake 13.4.2 on Ruby 2.6 and minitest
|
|
45
|
+
5.27.0 + rake 13.4.2 on Ruby 4.0.
|
|
46
|
+
|
|
47
|
+
### Security
|
|
48
|
+
|
|
49
|
+
- Generation fails closed when `SecureRandom.random_bytes(12)` raises or
|
|
50
|
+
returns the wrong number of bytes. There is no fallback entropy source.
|
|
51
|
+
- pikaid is an identifier, not an authentication token or capability.
|
data/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 larananas (Lara G.)
|
|
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.
|
data/README.md
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<a href="https://github.com/pikaid/pikaid-specs">
|
|
3
|
+
<img src="https://raw.githubusercontent.com/pikaid/pikaid-specs/v1.0.4/logo.png" alt="pikaid" width="300">
|
|
4
|
+
</a>
|
|
5
|
+
</p>
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<strong>Small · Sortable · Secure ID generator</strong>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
<div align="center">
|
|
12
|
+
|
|
13
|
+
[](LICENSE)
|
|
14
|
+
[](https://github.com/pikaid/pikaid-ruby)
|
|
15
|
+
[](https://github.com/pikaid/pikaid-ruby/actions/workflows/ci.yml)
|
|
16
|
+
[](https://github.com/pikaid/pikaid-specs)
|
|
17
|
+
[](https://github.com/pikaid/pikaid-specs/tree/v1.0.4)
|
|
18
|
+
|
|
19
|
+
</div>
|
|
20
|
+
|
|
21
|
+
**pikaid** is the **official Ruby implementation** of a compact, chronologically sortable, collision-resistant identifier format for databases, APIs, and distributed systems.
|
|
22
|
+
|
|
23
|
+
This is the immutable, unversioned [pikaid format](https://github.com/pikaid/pikaid-specs/tree/v1.0.4), documented in specification revision **1.0.4**.
|
|
24
|
+
|
|
25
|
+
## ✨ Why pikaid?
|
|
26
|
+
|
|
27
|
+
- **Short:** Compact, readable IDs for URLs, logs, and API resources.
|
|
28
|
+
- **Sortable:** Lexicographically ordered by timestamp, with **one-second precision**.
|
|
29
|
+
- **Collision-resistant:** 96 bits of cryptographic randomness make collisions extremely unlikely, never impossible.
|
|
30
|
+
- **Storage-friendly:** Use `BINARY(17)` or readable `CHAR(26)`.
|
|
31
|
+
- **Distributed-friendly:** No central sequence generator or coordination required.
|
|
32
|
+
|
|
33
|
+
## 🧬 Identifier format
|
|
34
|
+
|
|
35
|
+
A pikaid contains **26 lowercase Base36 characters**: a **7-character Unix-seconds timestamp** followed by **19 characters encoding 96 bits of cryptographic randomness**. Its binary form is **17 bytes**: 5 unsigned big-endian timestamp bytes followed by 12 randomness bytes.
|
|
36
|
+
|
|
37
|
+

|
|
38
|
+
|
|
39
|
+
> **Ordering:** Different seconds sort chronologically. Within the same second, randomness—not issuance order—decides the order.
|
|
40
|
+
|
|
41
|
+
> **Security:** These are identifiers, **not secrets**, session credentials, authentication tokens, or API keys.
|
|
42
|
+
|
|
43
|
+
## ⚙️ Requirements
|
|
44
|
+
|
|
45
|
+
- Ruby **2.6 or newer** (`required_ruby_version >= 2.6.0`).
|
|
46
|
+
- No runtime dependencies beyond the Ruby standard library
|
|
47
|
+
(`securerandom` is the only cryptographic dependency).
|
|
48
|
+
|
|
49
|
+
### Support policy
|
|
50
|
+
|
|
51
|
+
Version `1.0.0` declares MRI/YARV **2.6 through 4.0** and is verified on every
|
|
52
|
+
minor line from 2.6 to 4.0. Ruby 2.6 and 2.7 are end-of-life upstream: they
|
|
53
|
+
remain in the declared range, in CI, and in the verification matrix because the
|
|
54
|
+
gemspec still accepts them, but they are **compatibility-only** and no newer
|
|
55
|
+
language features are used. Ruby 4.0 is the newest stable line; preview and RC
|
|
56
|
+
releases are not supported.
|
|
57
|
+
|
|
58
|
+
The full native suite and the pinned official adapter were executed on Linux
|
|
59
|
+
containers for Ruby 2.6.10, 2.7.8, 3.0.7, 3.1.7, 3.2.11, 3.3.13, 3.4.12, and
|
|
60
|
+
4.0.9; the host macOS run used Ruby 2.6.10. The push-triggered GitHub Actions
|
|
61
|
+
workflow is configured to test Ruby 2.6 on `ubuntu-22.04`, Ruby 2.7 through 4.0
|
|
62
|
+
on `ubuntu-latest`, and Ruby 4.0 on `macos-latest` and `windows-latest`. A
|
|
63
|
+
separate job installs the built gem into an isolated `GEM_HOME` and exercises
|
|
64
|
+
it from a consumer script, and verifies the packaged file inventory, so
|
|
65
|
+
packaging regressions are caught outside the checkout.
|
|
66
|
+
|
|
67
|
+
## 📦 Installation
|
|
68
|
+
|
|
69
|
+
Version `1.0.0` is prepared but the gem is **not published** to RubyGems yet, so
|
|
70
|
+
`gem install pikaid` does not apply. Install it from source:
|
|
71
|
+
|
|
72
|
+
```sh
|
|
73
|
+
git clone https://github.com/pikaid/pikaid-ruby.git
|
|
74
|
+
cd pikaid-ruby
|
|
75
|
+
bundle install
|
|
76
|
+
bundle exec rake test
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
To build and install a local gem:
|
|
80
|
+
|
|
81
|
+
```sh
|
|
82
|
+
gem build pikaid.gemspec
|
|
83
|
+
gem install ./pikaid-1.0.0.gem
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The built package contains only `lib/**/*.rb`, `README.md`, `LICENSE`, and
|
|
87
|
+
`CHANGELOG.md`. Local `.ai/` workspace artifacts, tests, CI configuration, and
|
|
88
|
+
build leftovers are never packaged.
|
|
89
|
+
|
|
90
|
+
## 🚀 Basic Usage
|
|
91
|
+
|
|
92
|
+
After installing the local gem, save the following as `demo.rb` and execute `ruby demo.rb`:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
require "pikaid"
|
|
96
|
+
|
|
97
|
+
id = Pikaid.generate
|
|
98
|
+
parsed = Pikaid.parse(id)
|
|
99
|
+
bytes = parsed.to_binary
|
|
100
|
+
raise "Invalid identifier" unless Pikaid.valid?(id)
|
|
101
|
+
raise "Binary round-trip failed" unless Pikaid.from_binary(bytes) == parsed
|
|
102
|
+
puts "#{id} @ #{parsed.timestamp}"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## 💡 Example Use Case
|
|
106
|
+
|
|
107
|
+
Use pikaid to identify **users, orders, invoices, uploads, events, and API resources**. Independent services can generate compact IDs without a shared counter, while timestamp-based ordering helps with indexing, sorting, and logs.
|
|
108
|
+
|
|
109
|
+
## 📚 Canonical specification
|
|
110
|
+
|
|
111
|
+
- [Canonical specification](https://github.com/pikaid/pikaid-specs)
|
|
112
|
+
- [Revision 1.0.4](https://github.com/pikaid/pikaid-specs/tree/v1.0.4)
|
|
113
|
+
|
|
114
|
+
Specification revisions describe the format; they do not change the encoded identifiers.
|
|
115
|
+
|
|
116
|
+
## 🧩 API Reference
|
|
117
|
+
|
|
118
|
+
```ruby
|
|
119
|
+
require "pikaid"
|
|
120
|
+
|
|
121
|
+
# Generate using the current whole second.
|
|
122
|
+
id = Pikaid.generate #=> "0s44we8..." (26 characters)
|
|
123
|
+
id.class #=> String
|
|
124
|
+
id.bytesize #=> 26
|
|
125
|
+
|
|
126
|
+
# Generate with an explicit Unix timestamp (whole seconds).
|
|
127
|
+
Pikaid.generate(timestamp: 1_700_000_000) #=> 26-character String
|
|
128
|
+
|
|
129
|
+
# Generate the 17-byte binary form directly.
|
|
130
|
+
Pikaid.generate_binary(timestamp: 1_700_000_000).bytesize #=> 17
|
|
131
|
+
|
|
132
|
+
# Parse and decode.
|
|
133
|
+
parsed = Pikaid.parse("0s44we8002loz3po6mcf6vsn23")
|
|
134
|
+
parsed.timestamp #=> 1700000000
|
|
135
|
+
parsed.randomness #=> "\x00\x11\"3DUfw\x88\x99\xAA\xBB"
|
|
136
|
+
parsed.randomness_hex #=> "00112233445566778899aabb"
|
|
137
|
+
parsed.entropy #=> same bytes as #randomness
|
|
138
|
+
parsed.to_s #=> "0s44we8002loz3po6mcf6vsn23"
|
|
139
|
+
parsed.to_binary.bytesize #=> 17
|
|
140
|
+
parsed.to_binary.unpack("H*")[0]
|
|
141
|
+
#=> "006553f10000112233445566778899aabb"
|
|
142
|
+
|
|
143
|
+
# Pikaid stores whole Unix seconds, not a timezone. Time.at(...).utc is the
|
|
144
|
+
# explicit UTC conversion; no timezone is implied by the identifier itself.
|
|
145
|
+
Time.at(parsed.timestamp).utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
146
|
+
#=> "2023-11-14T22:13:20Z"
|
|
147
|
+
|
|
148
|
+
# Binary round trip.
|
|
149
|
+
Pikaid.from_binary(parsed.to_binary) == parsed #=> true
|
|
150
|
+
|
|
151
|
+
# Strict validation.
|
|
152
|
+
Pikaid.valid?("0s44we8002loz3po6mcf6vsn23") #=> true
|
|
153
|
+
Pikaid.valid_binary?(parsed.to_binary) #=> true
|
|
154
|
+
Pikaid.well_formed?("0000000zzzzzzzzzzzzzzzzzzz") #=> true (shape only)
|
|
155
|
+
Pikaid.valid?("0000000zzzzzzzzzzzzzzzzzzz") #=> false (96-bit overflow)
|
|
156
|
+
|
|
157
|
+
# Helper functions accept either a String or a Pikaid::Id.
|
|
158
|
+
Pikaid.timestamp(parsed) #=> 1700000000
|
|
159
|
+
Pikaid.randomness(parsed) #=> 12-byte String
|
|
160
|
+
Pikaid.decode(parsed) #=> [1700000000, "..."]
|
|
161
|
+
Pikaid.to_binary(parsed) #=> 17-byte String
|
|
162
|
+
Pikaid.compare(parsed, parsed) #=> -1, 0, or 1
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
### Errors
|
|
166
|
+
|
|
167
|
+
All errors inherit from `Pikaid::Error`:
|
|
168
|
+
|
|
169
|
+
```ruby
|
|
170
|
+
begin
|
|
171
|
+
Pikaid.parse("not-a-pikaid")
|
|
172
|
+
rescue Pikaid::InvalidIdError => error
|
|
173
|
+
error.reason #=> :invalid_string_length or :invalid_character, ...
|
|
174
|
+
error.value #=> the rejected input
|
|
175
|
+
end
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
- `Pikaid::InvalidIdError` — text or binary input is not a valid pikaid.
|
|
179
|
+
- `Pikaid::InvalidTimestampError` — a supplied timestamp is negative, out of
|
|
180
|
+
range, or not an Integer.
|
|
181
|
+
- `Pikaid::RandomnessError` — the cryptographic source failed or returned the
|
|
182
|
+
wrong number of bytes. Generation fails closed; there is no fallback.
|
|
183
|
+
|
|
184
|
+
## 🔄 Order Guarantee
|
|
185
|
+
|
|
186
|
+
- Canonical bytewise/ASCII lexicographic order is chronological **across
|
|
187
|
+
different seconds**.
|
|
188
|
+
- Identifiers generated within the **same second are not monotonic**. Their
|
|
189
|
+
order is determined by their random suffix, not by generation time.
|
|
190
|
+
- The timestamp has **whole-second precision only**; there is no sub-second
|
|
191
|
+
ordering guarantee.
|
|
192
|
+
- Randomness comes from `SecureRandom.random_bytes(12)`. pikaid is **not** an
|
|
193
|
+
authentication token, does not provide secrecy, and does not guarantee
|
|
194
|
+
uniqueness. Treat it as a compact, sortable identifier, not as a capability
|
|
195
|
+
or a security credential.
|
|
196
|
+
|
|
197
|
+
## 🛢 Storage Recommendations
|
|
198
|
+
|
|
199
|
+
| Representation | Size | Recommended for |
|
|
200
|
+
| --- | ---: | --- |
|
|
201
|
+
| **BINARY(17)** | 17 bytes | Compact primary keys and indexes |
|
|
202
|
+
| **CHAR(26)** | 26 characters | Readable SQL, debugging, URLs, and logs |
|
|
203
|
+
|
|
204
|
+
Use an **ASCII bytewise/binary collation** for text columns (for example, `ascii_bin` on MariaDB/MySQL). Case-insensitive or locale-aware sorting is not a substitute for canonical bytewise comparison.
|
|
205
|
+
|
|
206
|
+
## ✅ Verification and release status
|
|
207
|
+
|
|
208
|
+
The [CI workflow](https://github.com/pikaid/pikaid-ruby/actions/workflows/ci.yml) runs native tests, pinned automated conformance checks, and language-specific quality gates. To run the native checks locally from a checkout, use `bundle exec rake test`.
|
|
209
|
+
|
|
210
|
+
**Automated passing tests are not a security certification.** The independent CSPRNG source and failure-path audits (`P-GENERATE-RNG-SOURCE` and `P-GENERATE-RNG-FAILURE`) still require separate human evidence.
|
|
211
|
+
|
|
212
|
+
<details>
|
|
213
|
+
<summary>Platform, tooling, and conformance details</summary>
|
|
214
|
+
|
|
215
|
+
```sh
|
|
216
|
+
bundle exec rake syntax # ruby -c over lib, test, Rakefile, gemspec
|
|
217
|
+
bundle exec rake test # Minitest suite
|
|
218
|
+
bundle exec rake coverage # stdlib line coverage; fails below 90%
|
|
219
|
+
gem build pikaid.gemspec # build the gem
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
The test suite covers every official PHP v1.2.0 vector, specification
|
|
223
|
+
known-answer cases, malformed and overflow inputs, boundary values, randomized
|
|
224
|
+
round trips against an independent oracle, generated-ID sanity checks, and
|
|
225
|
+
cryptographic failure paths.
|
|
226
|
+
|
|
227
|
+
The official cross-language suite is
|
|
228
|
+
[pikaid-specs `1.0.0`](https://github.com/pikaid/pikaid-specs/tree/059780cc0e5b113443812720d95c630649bf3aa3/conformance),
|
|
229
|
+
pinned to the immutable commit `059780cc0e5b113443812720d95c630649bf3aa3`
|
|
230
|
+
(specification revision `1.0.4`). `test/support/official_adapter.rb` is a
|
|
231
|
+
test-only JSON-Lines bridge: every operation calls the real public `Pikaid`
|
|
232
|
+
API, the file is not packaged in the gem, and it advertises only capabilities
|
|
233
|
+
the library genuinely provides. `parse_datetime` is omitted because the Ruby
|
|
234
|
+
API exposes whole-second timestamps rather than a datetime accessor.
|
|
235
|
+
|
|
236
|
+
```sh
|
|
237
|
+
# Run from the pinned suite's conformance/ directory.
|
|
238
|
+
python tools/run_adapter.py --automated-only \
|
|
239
|
+
-- ruby -I/path/to/pikaid-ruby/lib /path/to/pikaid-ruby/test/support/official_adapter.rb
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
An `--automated-only` run exiting `0` means the automated checks passed. It is
|
|
243
|
+
**not** a conformance or security certification. A default run (without
|
|
244
|
+
`--automated-only`) exits `3` while the two manual CSPRNG review obligations,
|
|
245
|
+
`P-GENERATE-RNG-SOURCE` and `P-GENERATE-RNG-FAILURE`, remain outstanding.
|
|
246
|
+
Those require independent human review of the entropy source and of the
|
|
247
|
+
failure path, and no automated run can satisfy them. The
|
|
248
|
+
`Official conformance (pinned suite)` CI job executes the automated subset
|
|
249
|
+
only.
|
|
250
|
+
|
|
251
|
+
</details>
|
|
252
|
+
|
|
253
|
+
Registry publication is not implied by a green CI: use the installation method documented above.
|
|
254
|
+
|
|
255
|
+
## 🤝 Contributing
|
|
256
|
+
|
|
257
|
+
Contributions are always welcome! 💙
|
|
258
|
+
|
|
259
|
+
Whether you're fixing a typo, improving documentation, adding tests, or making pikaid even better, every contribution is appreciated.
|
|
260
|
+
|
|
261
|
+
**Want to help? Here's how:**
|
|
262
|
+
|
|
263
|
+
- 🐛 **Found a bug?** [Open an issue](https://github.com/pikaid/pikaid-ruby/issues) with clear reproduction steps.
|
|
264
|
+
- 💡 **Have an idea?** Start with an issue before making major changes.
|
|
265
|
+
- 🛠️ **Ready to contribute?** Open a pull request explaining your changes.
|
|
266
|
+
- 🧪 **Keep it tested.** Add meaningful regression tests for behavior changes and follow the existing conventions.
|
|
267
|
+
- ✅ **Make sure CI passes.** All required checks must be green before a pull request is merged. No shortcuts!
|
|
268
|
+
|
|
269
|
+
**One important rule:** pikaid's identifier format is immutable. Contributions must preserve the [canonical specification (revision 1.0.4)](https://github.com/pikaid/pikaid-specs/tree/v1.0.4).
|
|
270
|
+
|
|
271
|
+
🔐 **Found a security vulnerability?** Report it privately using GitHub's security reporting (when available), or [contact the maintainer](mailto:larananas.dev@proton.me)—not through a public issue.
|
|
272
|
+
|
|
273
|
+
**Every improvement matters, big or small. Thanks for helping make pikaid better!** 🐇
|
|
274
|
+
|
|
275
|
+
## 📄 License
|
|
276
|
+
|
|
277
|
+
The implementation is available under the [MIT License](LICENSE).
|
|
278
|
+
|
|
279
|
+
## ✉️ Contact
|
|
280
|
+
|
|
281
|
+
For licensing or project-related questions: [larananas.dev@proton.me](mailto:larananas.dev@proton.me).
|
|
282
|
+
|
data/lib/pikaid/codec.rb
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikaid
|
|
4
|
+
# Internal Base36 and big-endian byte helpers.
|
|
5
|
+
#
|
|
6
|
+
# This module is not part of the stable public API; use the methods on
|
|
7
|
+
# {Pikaid} and {Pikaid::Id} instead.
|
|
8
|
+
module Codec
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Encode a non-negative Integer as fixed-width lowercase Base36.
|
|
12
|
+
def encode_base36(value, width)
|
|
13
|
+
unless value.is_a?(Integer) && value >= 0
|
|
14
|
+
raise ArgumentError, "value must be a non-negative Integer"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
encoded = value.to_s(36)
|
|
18
|
+
if encoded.length > width
|
|
19
|
+
raise RangeError, "#{value} does not fit in #{width} Base36 characters"
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
encoded.rjust(width, "0")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Decode a Base36 string. Callers must validate the alphabet first.
|
|
26
|
+
def decode_base36(value)
|
|
27
|
+
value.to_i(36)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Encode an unsigned Integer as +width+ big-endian bytes.
|
|
31
|
+
def int_to_bytes(value, width)
|
|
32
|
+
unless value.is_a?(Integer) && value >= 0 && value < (1 << (8 * width))
|
|
33
|
+
raise RangeError, "#{value.inspect} does not fit in #{width} bytes"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
bytes = Array.new(width)
|
|
37
|
+
(width - 1).downto(0) do |index|
|
|
38
|
+
bytes[index] = value & 0xff
|
|
39
|
+
value >>= 8
|
|
40
|
+
end
|
|
41
|
+
bytes.pack("C*")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Decode big-endian bytes as an unsigned Integer.
|
|
45
|
+
def bytes_to_int(bytes)
|
|
46
|
+
bytes.each_byte.inject(0) { |value, byte| (value << 8) | byte }
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikaid
|
|
4
|
+
# Total length of the canonical text representation, in ASCII characters.
|
|
5
|
+
STRING_LENGTH = 26
|
|
6
|
+
|
|
7
|
+
# Total length of the canonical binary representation, in bytes.
|
|
8
|
+
BINARY_LENGTH = 17
|
|
9
|
+
|
|
10
|
+
# Width of the timestamp prefix in the text representation.
|
|
11
|
+
TIMESTAMP_LENGTH = 7
|
|
12
|
+
|
|
13
|
+
# Width of the randomness suffix in the text representation.
|
|
14
|
+
RANDOMNESS_LENGTH = 19
|
|
15
|
+
|
|
16
|
+
# Width of the timestamp field in the binary representation.
|
|
17
|
+
TIMESTAMP_BYTES = 5
|
|
18
|
+
|
|
19
|
+
# Width of the randomness field in the binary representation.
|
|
20
|
+
RANDOMNESS_BYTES = 12
|
|
21
|
+
|
|
22
|
+
# Largest valid timestamp: 36^7 - 1 (4453-04-05 15:21:35 UTC).
|
|
23
|
+
MAX_TIMESTAMP = 78_364_164_095
|
|
24
|
+
|
|
25
|
+
# Largest valid randomness value: 2^96 - 1.
|
|
26
|
+
MAX_RANDOMNESS = 79_228_162_514_264_337_593_543_950_335
|
|
27
|
+
|
|
28
|
+
# Permitted alphabet, in ascending digit order.
|
|
29
|
+
ALPHABET = "0123456789abcdefghijklmnopqrstuvwxyz".freeze
|
|
30
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikaid
|
|
4
|
+
# Base class for every error raised by pikaid.
|
|
5
|
+
class Error < StandardError; end
|
|
6
|
+
|
|
7
|
+
# Raised when a text or binary value is not a valid pikaid.
|
|
8
|
+
#
|
|
9
|
+
# +reason+ is a stable machine-readable symbol describing the first failed
|
|
10
|
+
# check. +value+ is the rejected input (not included in the message).
|
|
11
|
+
class InvalidIdError < Error
|
|
12
|
+
attr_reader :value, :reason
|
|
13
|
+
|
|
14
|
+
def initialize(message = "invalid pikaid", value: nil, reason: nil)
|
|
15
|
+
@value = value
|
|
16
|
+
@reason = reason
|
|
17
|
+
super(message)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Raised when a timestamp cannot be generated or represented.
|
|
22
|
+
class InvalidTimestampError < Error
|
|
23
|
+
attr_reader :timestamp, :reason
|
|
24
|
+
|
|
25
|
+
def initialize(message = "invalid pikaid timestamp", timestamp: nil, reason: nil)
|
|
26
|
+
@timestamp = timestamp
|
|
27
|
+
@reason = reason
|
|
28
|
+
super(message)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Raised when secure randomness cannot be obtained or has the wrong size.
|
|
33
|
+
#
|
|
34
|
+
# Generation fails closed: this error is never replaced by a non-cryptographic
|
|
35
|
+
# fallback.
|
|
36
|
+
class RandomnessError < Error; end
|
|
37
|
+
end
|
data/lib/pikaid/id.rb
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikaid
|
|
4
|
+
# Immutable value object for a decoded pikaid.
|
|
5
|
+
#
|
|
6
|
+
# The canonical text and binary representations are computed once at
|
|
7
|
+
# construction. Use {Pikaid.parse} or {Pikaid.from_binary} to decode an
|
|
8
|
+
# existing value; use {Pikaid.generate} for new identifiers.
|
|
9
|
+
#
|
|
10
|
+
# id = Pikaid.parse("0s44we8002loz3po6mcf6vsn23")
|
|
11
|
+
# id.timestamp #=> 1700000000
|
|
12
|
+
# id.randomness #=> "\x00\x11\"3DUfw\x88\x99\xAA\xBB"
|
|
13
|
+
# id.randomness_hex #=> "00112233445566778899aabb"
|
|
14
|
+
# id.to_binary.bytesize #=> 17
|
|
15
|
+
class Id
|
|
16
|
+
include Comparable
|
|
17
|
+
|
|
18
|
+
# Unix timestamp in whole seconds.
|
|
19
|
+
attr_reader :timestamp
|
|
20
|
+
|
|
21
|
+
# Exactly 12 bytes of randomness, as an ASCII-8BIT String.
|
|
22
|
+
attr_reader :randomness
|
|
23
|
+
|
|
24
|
+
# Compatibility alias for the canonical conceptual name +randomness+.
|
|
25
|
+
alias entropy randomness
|
|
26
|
+
|
|
27
|
+
# Build an identifier from decoded components.
|
|
28
|
+
#
|
|
29
|
+
# This constructor validates its inputs but does not generate randomness.
|
|
30
|
+
# Prefer {Pikaid.generate} for creating new identifiers.
|
|
31
|
+
#
|
|
32
|
+
# @param timestamp [Integer] whole Unix seconds in 0..MAX_TIMESTAMP
|
|
33
|
+
# @param randomness [String] exactly 12 bytes
|
|
34
|
+
# @raise [InvalidTimestampError] if the timestamp is out of range
|
|
35
|
+
# @raise [InvalidIdError] if the randomness is not exactly 12 bytes
|
|
36
|
+
def initialize(timestamp, randomness)
|
|
37
|
+
@timestamp = Validation.timestamp!(timestamp)
|
|
38
|
+
@randomness = Validation.randomness!(randomness).b.freeze
|
|
39
|
+
|
|
40
|
+
@string = (
|
|
41
|
+
Codec.encode_base36(@timestamp, TIMESTAMP_LENGTH) +
|
|
42
|
+
Codec.encode_base36(Codec.bytes_to_int(@randomness), RANDOMNESS_LENGTH)
|
|
43
|
+
).freeze
|
|
44
|
+
|
|
45
|
+
@binary = (
|
|
46
|
+
Codec.int_to_bytes(@timestamp, TIMESTAMP_BYTES) + @randomness
|
|
47
|
+
).freeze
|
|
48
|
+
|
|
49
|
+
freeze
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# The canonical 26-character lowercase Base36 identifier.
|
|
53
|
+
#
|
|
54
|
+
# @return [String] a frozen String
|
|
55
|
+
def to_s
|
|
56
|
+
@string
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# The canonical 17-byte binary representation.
|
|
60
|
+
#
|
|
61
|
+
# Layout: five big-endian timestamp bytes followed by the original twelve
|
|
62
|
+
# randomness bytes.
|
|
63
|
+
#
|
|
64
|
+
# @return [String] a frozen ASCII-8BIT String
|
|
65
|
+
def to_binary
|
|
66
|
+
@binary
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# The randomness field as 24 lowercase hexadecimal characters.
|
|
70
|
+
#
|
|
71
|
+
# @return [String]
|
|
72
|
+
def randomness_hex
|
|
73
|
+
@randomness.unpack("H*")[0]
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Compatibility alias for {#randomness_hex}.
|
|
77
|
+
alias entropy_hex randomness_hex
|
|
78
|
+
|
|
79
|
+
# Compare two identifiers by canonical bytewise order.
|
|
80
|
+
#
|
|
81
|
+
# Identifiers with different timestamps sort chronologically. Identifiers
|
|
82
|
+
# generated within the same second sort by their random suffix, not by
|
|
83
|
+
# generation order.
|
|
84
|
+
#
|
|
85
|
+
# @param other [Object]
|
|
86
|
+
# @return [Integer, nil] -1, 0, 1, or nil when +other+ is not an Id
|
|
87
|
+
def <=>(other)
|
|
88
|
+
return nil unless other.is_a?(Id)
|
|
89
|
+
|
|
90
|
+
@string.b <=> other.to_s.b
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Value equality for identifiers.
|
|
94
|
+
def ==(other)
|
|
95
|
+
other.is_a?(Id) && @string.b == other.to_s.b
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
alias eql? ==
|
|
99
|
+
|
|
100
|
+
# Hash consistent with {#==} and {#eql?}.
|
|
101
|
+
#
|
|
102
|
+
# Equality is defined by the canonical value, not by the concrete class, so
|
|
103
|
+
# the hash must be derived from the value alone. Including +self.class+
|
|
104
|
+
# would let a subclass instance compare equal to a base instance while
|
|
105
|
+
# hashing differently, breaking Hash lookup for equal values.
|
|
106
|
+
def hash
|
|
107
|
+
@string.hash
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def inspect
|
|
111
|
+
"#<#{self.class} #{@string.inspect}>"
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Pikaid
|
|
4
|
+
# Internal strict validation shared by generation and value objects.
|
|
5
|
+
module Validation
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
# Ensure +timestamp+ is an Integer in 0..MAX_TIMESTAMP.
|
|
9
|
+
def timestamp!(timestamp)
|
|
10
|
+
unless timestamp.is_a?(Integer)
|
|
11
|
+
raise InvalidTimestampError.new(
|
|
12
|
+
"timestamp must be an Integer, got #{timestamp.class}",
|
|
13
|
+
timestamp: timestamp,
|
|
14
|
+
reason: :not_an_integer
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
if timestamp < 0
|
|
19
|
+
raise InvalidTimestampError.new(
|
|
20
|
+
"timestamp must not be negative, got #{timestamp}",
|
|
21
|
+
timestamp: timestamp,
|
|
22
|
+
reason: :negative
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
return timestamp if timestamp <= MAX_TIMESTAMP
|
|
27
|
+
|
|
28
|
+
raise InvalidTimestampError.new(
|
|
29
|
+
"timestamp #{timestamp} exceeds the maximum #{MAX_TIMESTAMP}",
|
|
30
|
+
timestamp: timestamp,
|
|
31
|
+
reason: :overflow
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Ensure +randomness+ is exactly 12 bytes.
|
|
36
|
+
def randomness!(randomness)
|
|
37
|
+
unless randomness.is_a?(String)
|
|
38
|
+
raise InvalidIdError.new(
|
|
39
|
+
"randomness must be a 12-byte String, got #{randomness.class}",
|
|
40
|
+
value: randomness,
|
|
41
|
+
reason: :not_a_string
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
return randomness if randomness.bytesize == RANDOMNESS_BYTES
|
|
46
|
+
|
|
47
|
+
raise InvalidIdError.new(
|
|
48
|
+
"randomness must be exactly #{RANDOMNESS_BYTES} bytes, got #{randomness.bytesize}",
|
|
49
|
+
value: randomness,
|
|
50
|
+
reason: :invalid_randomness_length
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
data/lib/pikaid.rb
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
require_relative "pikaid/version"
|
|
6
|
+
require_relative "pikaid/constants"
|
|
7
|
+
require_relative "pikaid/errors"
|
|
8
|
+
require_relative "pikaid/codec"
|
|
9
|
+
require_relative "pikaid/validation"
|
|
10
|
+
require_relative "pikaid/id"
|
|
11
|
+
|
|
12
|
+
# Official Ruby implementation of pikaid.
|
|
13
|
+
#
|
|
14
|
+
# A pikaid is exactly 26 lowercase ASCII Base36 characters: a seven-character
|
|
15
|
+
# whole-second Unix timestamp followed by nineteen characters encoding 96 bits
|
|
16
|
+
# of cryptographic randomness. Its binary representation is exactly 17 bytes:
|
|
17
|
+
# five big-endian timestamp bytes followed by the same twelve randomness bytes.
|
|
18
|
+
#
|
|
19
|
+
# The format is immutable and unversioned. See the canonical specification:
|
|
20
|
+
# https://github.com/pikaid/pikaid-specs/tree/v1.0.4
|
|
21
|
+
module Pikaid
|
|
22
|
+
class << self
|
|
23
|
+
# Generate a new pikaid as a 26-character String.
|
|
24
|
+
#
|
|
25
|
+
# Entropy comes from +SecureRandom.random_bytes(12)+. If the cryptographic
|
|
26
|
+
# source fails, {RandomnessError} is raised; there is no fallback.
|
|
27
|
+
#
|
|
28
|
+
# @param timestamp [Integer, nil] whole Unix seconds, or nil for Time.now
|
|
29
|
+
# @return [String] the canonical text form
|
|
30
|
+
# @raise [InvalidTimestampError] if the timestamp is out of range
|
|
31
|
+
# @raise [RandomnessError] if secure randomness cannot be obtained
|
|
32
|
+
def generate(timestamp: nil)
|
|
33
|
+
build_id(timestamp).to_s
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Generate a new pikaid as a 17-byte binary String.
|
|
37
|
+
#
|
|
38
|
+
# @param timestamp [Integer, nil] whole Unix seconds, or nil for Time.now
|
|
39
|
+
# @return [String] the canonical binary form
|
|
40
|
+
# @raise [InvalidTimestampError] if the timestamp is out of range
|
|
41
|
+
# @raise [RandomnessError] if secure randomness cannot be obtained
|
|
42
|
+
def generate_binary(timestamp: nil)
|
|
43
|
+
build_id(timestamp).to_binary
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Strictly parse a text pikaid.
|
|
47
|
+
#
|
|
48
|
+
# Rejects non-String values, non-ASCII bytes, uppercase characters,
|
|
49
|
+
# invalid characters, wrong lengths, and randomness values above 2^96 - 1.
|
|
50
|
+
#
|
|
51
|
+
# @param value [String]
|
|
52
|
+
# @return [Id]
|
|
53
|
+
# @raise [InvalidIdError] if +value+ is not a valid pikaid
|
|
54
|
+
def parse(value)
|
|
55
|
+
unless value.is_a?(String)
|
|
56
|
+
raise InvalidIdError.new(
|
|
57
|
+
"pikaid must be a String, got #{value.class}",
|
|
58
|
+
value: value,
|
|
59
|
+
reason: :not_a_string
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
bytes = value.b
|
|
64
|
+
if bytes.bytesize != STRING_LENGTH
|
|
65
|
+
raise InvalidIdError.new(
|
|
66
|
+
"pikaid must be exactly #{STRING_LENGTH} bytes, got #{bytes.bytesize}",
|
|
67
|
+
value: value,
|
|
68
|
+
reason: :invalid_string_length
|
|
69
|
+
)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
unless bytes.match?(/\A[0-9a-z]{26}\z/n)
|
|
73
|
+
raise InvalidIdError.new(
|
|
74
|
+
"pikaid must contain only lowercase ASCII Base36 characters",
|
|
75
|
+
value: value,
|
|
76
|
+
reason: :invalid_character
|
|
77
|
+
)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
timestamp = Codec.decode_base36(bytes.byteslice(0, TIMESTAMP_LENGTH))
|
|
81
|
+
randomness_value = Codec.decode_base36(
|
|
82
|
+
bytes.byteslice(TIMESTAMP_LENGTH, RANDOMNESS_LENGTH)
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if randomness_value > MAX_RANDOMNESS
|
|
86
|
+
raise InvalidIdError.new(
|
|
87
|
+
"pikaid randomness exceeds the 96-bit maximum",
|
|
88
|
+
value: value,
|
|
89
|
+
reason: :randomness_overflow
|
|
90
|
+
)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
Id.new(timestamp, Codec.int_to_bytes(randomness_value, RANDOMNESS_BYTES))
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Strictly decode a 17-byte binary pikaid.
|
|
97
|
+
#
|
|
98
|
+
# @param value [String] exactly 17 bytes
|
|
99
|
+
# @return [Id]
|
|
100
|
+
# @raise [InvalidIdError] if the length or timestamp is invalid
|
|
101
|
+
def from_binary(value)
|
|
102
|
+
unless value.is_a?(String)
|
|
103
|
+
raise InvalidIdError.new(
|
|
104
|
+
"binary pikaid must be a String, got #{value.class}",
|
|
105
|
+
value: value,
|
|
106
|
+
reason: :not_a_string
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
bytes = value.b
|
|
111
|
+
if bytes.bytesize != BINARY_LENGTH
|
|
112
|
+
raise InvalidIdError.new(
|
|
113
|
+
"binary pikaid must be exactly #{BINARY_LENGTH} bytes, got #{bytes.bytesize}",
|
|
114
|
+
value: value,
|
|
115
|
+
reason: :invalid_binary_length
|
|
116
|
+
)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
timestamp = Codec.bytes_to_int(bytes.byteslice(0, TIMESTAMP_BYTES))
|
|
120
|
+
if timestamp > MAX_TIMESTAMP
|
|
121
|
+
raise InvalidIdError.new(
|
|
122
|
+
"binary pikaid timestamp #{timestamp} exceeds the maximum #{MAX_TIMESTAMP}",
|
|
123
|
+
value: value,
|
|
124
|
+
reason: :timestamp_overflow
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
Id.new(timestamp, bytes.byteslice(TIMESTAMP_BYTES, RANDOMNESS_BYTES))
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Full semantic validation of a text pikaid.
|
|
132
|
+
#
|
|
133
|
+
# Unlike {well_formed?}, this also rejects randomness above 2^96 - 1.
|
|
134
|
+
# Returns false for non-String values instead of raising.
|
|
135
|
+
#
|
|
136
|
+
# @param value [Object]
|
|
137
|
+
# @return [Boolean]
|
|
138
|
+
def valid?(value)
|
|
139
|
+
parse(value)
|
|
140
|
+
true
|
|
141
|
+
rescue InvalidIdError
|
|
142
|
+
false
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# Full semantic validation of a binary pikaid.
|
|
146
|
+
#
|
|
147
|
+
# @param value [Object]
|
|
148
|
+
# @return [Boolean]
|
|
149
|
+
def valid_binary?(value)
|
|
150
|
+
from_binary(value)
|
|
151
|
+
true
|
|
152
|
+
rescue InvalidIdError
|
|
153
|
+
false
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Shape-only check: exactly 26 bytes of [0-9a-z].
|
|
157
|
+
#
|
|
158
|
+
# This deliberately does not check the 96-bit randomness limit. A value can
|
|
159
|
+
# be well formed but not valid; use {valid?} for full validation.
|
|
160
|
+
#
|
|
161
|
+
# @param value [Object]
|
|
162
|
+
# @return [Boolean]
|
|
163
|
+
def well_formed?(value)
|
|
164
|
+
return false unless value.is_a?(String)
|
|
165
|
+
|
|
166
|
+
bytes = value.b
|
|
167
|
+
bytes.bytesize == STRING_LENGTH && bytes.match?(/\A[0-9a-z]{26}\z/n)
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Extract the timestamp from a text id or {Id}.
|
|
171
|
+
#
|
|
172
|
+
# @param value [String, Id]
|
|
173
|
+
# @return [Integer]
|
|
174
|
+
# @raise [InvalidIdError]
|
|
175
|
+
def timestamp(value)
|
|
176
|
+
coerce_id(value).timestamp
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
# Extract the 12 randomness bytes from a text id or {Id}.
|
|
180
|
+
#
|
|
181
|
+
# @param value [String, Id]
|
|
182
|
+
# @return [String] ASCII-8BIT String
|
|
183
|
+
# @raise [InvalidIdError]
|
|
184
|
+
def randomness(value)
|
|
185
|
+
coerce_id(value).randomness
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# Compatibility alias for {randomness}.
|
|
189
|
+
def entropy(value)
|
|
190
|
+
randomness(value)
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# Decode a text id or {Id} into [timestamp, randomness bytes].
|
|
194
|
+
#
|
|
195
|
+
# @param value [String, Id]
|
|
196
|
+
# @return [Array(Integer, String)]
|
|
197
|
+
# @raise [InvalidIdError]
|
|
198
|
+
def decode(value)
|
|
199
|
+
id = coerce_id(value)
|
|
200
|
+
[id.timestamp, id.randomness]
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Convert a text id or {Id} to its 17-byte binary representation.
|
|
204
|
+
#
|
|
205
|
+
# @param value [String, Id]
|
|
206
|
+
# @return [String]
|
|
207
|
+
# @raise [InvalidIdError]
|
|
208
|
+
def to_binary(value)
|
|
209
|
+
coerce_id(value).to_binary
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
# Compare two text ids or {Id}s by canonical bytewise order.
|
|
213
|
+
#
|
|
214
|
+
# @param left [String, Id]
|
|
215
|
+
# @param right [String, Id]
|
|
216
|
+
# @return [Integer] -1, 0, or 1
|
|
217
|
+
# @raise [InvalidIdError]
|
|
218
|
+
def compare(left, right)
|
|
219
|
+
coerce_id(left) <=> coerce_id(right)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
private
|
|
223
|
+
|
|
224
|
+
def build_id(timestamp)
|
|
225
|
+
value = timestamp.nil? ? Time.now.to_i : timestamp
|
|
226
|
+
Validation.timestamp!(value)
|
|
227
|
+
Id.new(value, secure_random_bytes(RANDOMNESS_BYTES))
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def secure_random_bytes(count)
|
|
231
|
+
bytes = SecureRandom.random_bytes(count)
|
|
232
|
+
unless bytes.is_a?(String) && bytes.bytesize == count
|
|
233
|
+
raise RandomnessError,
|
|
234
|
+
"secure random source returned #{bytes.inspect} instead of #{count} bytes"
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
bytes.b
|
|
238
|
+
rescue RandomnessError
|
|
239
|
+
raise
|
|
240
|
+
rescue StandardError, SecurityError => error
|
|
241
|
+
raise RandomnessError,
|
|
242
|
+
"secure randomness unavailable (#{error.class}: #{error.message})"
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
def coerce_id(value)
|
|
246
|
+
case value
|
|
247
|
+
when Id
|
|
248
|
+
value
|
|
249
|
+
when String
|
|
250
|
+
parse(value)
|
|
251
|
+
else
|
|
252
|
+
raise InvalidIdError.new(
|
|
253
|
+
"expected a pikaid String or Pikaid::Id, got #{value.class}",
|
|
254
|
+
value: value,
|
|
255
|
+
reason: :not_a_string
|
|
256
|
+
)
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: pikaid
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- larananas (Lara G.)
|
|
8
|
+
bindir: bin
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: minitest
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '5.11'
|
|
19
|
+
- - "<"
|
|
20
|
+
- !ruby/object:Gem::Version
|
|
21
|
+
version: '6.0'
|
|
22
|
+
type: :development
|
|
23
|
+
prerelease: false
|
|
24
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
25
|
+
requirements:
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: '5.11'
|
|
29
|
+
- - "<"
|
|
30
|
+
- !ruby/object:Gem::Version
|
|
31
|
+
version: '6.0'
|
|
32
|
+
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: rake
|
|
34
|
+
requirement: !ruby/object:Gem::Requirement
|
|
35
|
+
requirements:
|
|
36
|
+
- - ">="
|
|
37
|
+
- !ruby/object:Gem::Version
|
|
38
|
+
version: '12.0'
|
|
39
|
+
- - "<"
|
|
40
|
+
- !ruby/object:Gem::Version
|
|
41
|
+
version: '14.0'
|
|
42
|
+
type: :development
|
|
43
|
+
prerelease: false
|
|
44
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
45
|
+
requirements:
|
|
46
|
+
- - ">="
|
|
47
|
+
- !ruby/object:Gem::Version
|
|
48
|
+
version: '12.0'
|
|
49
|
+
- - "<"
|
|
50
|
+
- !ruby/object:Gem::Version
|
|
51
|
+
version: '14.0'
|
|
52
|
+
description: Compact 26-character sortable Base36 identifiers with 96-bit cryptographic
|
|
53
|
+
randomness and a 17-byte binary representation.
|
|
54
|
+
executables: []
|
|
55
|
+
extensions: []
|
|
56
|
+
extra_rdoc_files: []
|
|
57
|
+
files:
|
|
58
|
+
- CHANGELOG.md
|
|
59
|
+
- LICENSE
|
|
60
|
+
- README.md
|
|
61
|
+
- lib/pikaid.rb
|
|
62
|
+
- lib/pikaid/codec.rb
|
|
63
|
+
- lib/pikaid/constants.rb
|
|
64
|
+
- lib/pikaid/errors.rb
|
|
65
|
+
- lib/pikaid/id.rb
|
|
66
|
+
- lib/pikaid/validation.rb
|
|
67
|
+
- lib/pikaid/version.rb
|
|
68
|
+
homepage: https://github.com/pikaid/pikaid-ruby
|
|
69
|
+
licenses:
|
|
70
|
+
- MIT
|
|
71
|
+
metadata:
|
|
72
|
+
homepage_uri: https://github.com/pikaid/pikaid-ruby
|
|
73
|
+
source_code_uri: https://github.com/pikaid/pikaid-ruby
|
|
74
|
+
changelog_uri: https://github.com/pikaid/pikaid-ruby/blob/main/CHANGELOG.md
|
|
75
|
+
rubygems_mfa_required: 'true'
|
|
76
|
+
rdoc_options: []
|
|
77
|
+
require_paths:
|
|
78
|
+
- lib
|
|
79
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
80
|
+
requirements:
|
|
81
|
+
- - ">="
|
|
82
|
+
- !ruby/object:Gem::Version
|
|
83
|
+
version: 2.6.0
|
|
84
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - ">="
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '0'
|
|
89
|
+
requirements: []
|
|
90
|
+
rubygems_version: 3.6.9
|
|
91
|
+
specification_version: 4
|
|
92
|
+
summary: Official Ruby implementation of pikaid
|
|
93
|
+
test_files: []
|