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 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: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
14
+ [![Ruby](https://img.shields.io/badge/language-Ruby-CC342D.svg)](https://github.com/pikaid/pikaid-ruby)
15
+ [![CI](https://github.com/pikaid/pikaid-ruby/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/pikaid/pikaid-ruby/actions/workflows/ci.yml)
16
+ [![Official implementation](https://img.shields.io/badge/official-implementation-2e7d32.svg)](https://github.com/pikaid/pikaid-specs)
17
+ [![Specification 1.0.4](https://img.shields.io/badge/spec-1.0.4-4b56a6.svg)](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
+ ![Identifier structure](https://raw.githubusercontent.com/pikaid/pikaid-specs/v1.0.4/structure.svg)
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
+
@@ -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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Pikaid
4
+ # Version of the official Ruby implementation.
5
+ #
6
+ # The pikaid identifier format itself is immutable and unversioned; this
7
+ # constant versions the Ruby library only.
8
+ VERSION = "1.0.0"
9
+ 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: []