oxygene 0.0.1 → 0.1.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 +4 -4
- data/AGENTS.md +9 -0
- data/CHANGELOG.md +28 -0
- data/README.md +141 -1
- data/lib/oxygene/base32.rb +284 -0
- data/lib/oxygene/car_archive.rb +179 -49
- data/lib/oxygene/car_repo.rb +121 -0
- data/lib/oxygene/car_section.rb +59 -0
- data/lib/oxygene/cid.rb +311 -18
- data/lib/oxygene/extensions.rb +13 -2
- data/lib/oxygene/version.rb +1 -1
- data/lib/oxygene.rb +18 -0
- metadata +5 -15
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d85155d1ce53a32be48e557bd81995deea20b096f0d5ac7797aad798f6527680
|
|
4
|
+
data.tar.gz: 70d1b127ab875cc3a7d2f04b922aa48c950cb2db33b46240e35a29080edc5fc8
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ec92728d10052f07d4fced7e9aa973bb14f655f67fb1f4c1684535b7d4e2dcaac2fda995fa64243253c1ef56e0ee66eb33d1ba66b31fb76c37437beaacf0f85e
|
|
7
|
+
data.tar.gz: 133a216c0b0016c18d269355eb5336fcaa754cb30b8fa648786644648f06820ccec4e9608d00f8de57229ea3d493bd10de4bc06368ac901a71c674605d841159
|
data/AGENTS.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
* Ruby versions:
|
|
2
|
+
- for any testing, use the Ruby 3.4 configured in `asdf` instead of the ancient system Ruby 2.6
|
|
3
|
+
- since Ruby 3.2 you don't need to require 'set', it's a built-in class
|
|
4
|
+
|
|
5
|
+
* unit tests:
|
|
6
|
+
- don't add automated tests to the repo while adding/changing library code unless specifically asked, or when changing the code breaks some existing tests and they need to be fixed
|
|
7
|
+
- use the classic RSpec `foo.should be_...` matcher style instead of `expect()`, except for calling matchers on blocks as in `expect { ... }.to(not) raise_error...`
|
|
8
|
+
- use the classic `it "should do this"` naming for test cases instead of `it "does this"`
|
|
9
|
+
- use raw `describe` instead of `RSpec.describe`
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,31 @@
|
|
|
1
|
+
## [0.1.0] - 2026-08-17
|
|
2
|
+
|
|
3
|
+
* added custom, much faster Base32 encoder/decoder, dropping `base32` gem dependency
|
|
4
|
+
* added `CARRepo` (subclass of `CARArchive`) for parsing and processing ATProto repo CAR archives:
|
|
5
|
+
- use `#walk_all_nodes { |path, section_cid| ... }` to iterate over all records in the repo
|
|
6
|
+
- use `#commit` or `#commit_section` to access repo commit data
|
|
7
|
+
* reorganized `CID` class:
|
|
8
|
+
- it now stores only the initial JSON/binary form and avoids generating the other until requested
|
|
9
|
+
- `#json_form` returns JSON string starting with 'b' (`to_s` is an alias)
|
|
10
|
+
- `#cbor_form` returns the binary string starting with `\x00`
|
|
11
|
+
- `#raw_data` returns the binary string *without* the CBOR `\x00` prefix
|
|
12
|
+
- `#data` is an alias for `#raw_data` for backwards compatibility
|
|
13
|
+
- both the input and output strings for JSON/binary form are frozen so they can't be modified from outside
|
|
14
|
+
- added more data validations
|
|
15
|
+
* optimized section parsing & lookup in `CARArchive`:
|
|
16
|
+
- it keeps an internal Hash of sections as { raw CID data => section }, but doesn't build it by default unless explicitly enabled
|
|
17
|
+
- use `section_with_cid(cid, use_map: true)` to populate and use the lookup hashmap
|
|
18
|
+
- either a `CID` object or its `#cbor_form` can be used for lookup
|
|
19
|
+
- use `section_with_cid(cid, return_body: false)` to return a `CARSection` instead of its decoded body object directly; this will be the default behavior in a future version
|
|
20
|
+
- various internal optimizations in the decoding code
|
|
21
|
+
- added more data validations
|
|
22
|
+
- added `#parsed_sections` helper
|
|
23
|
+
* changed the API of `CARSection`:
|
|
24
|
+
- use `#json_body` to return the body with values recursively converted to use `$bytes` and `$link` where needed, as before
|
|
25
|
+
- use `#decoded_body` to return the body only decoded from CBOR, but without the recursive conversion
|
|
26
|
+
- `#body` is an alias for `#json_body` for now for backwards compatibility
|
|
27
|
+
* added unit tests & YARD docs
|
|
28
|
+
|
|
1
29
|
## [0.0.1] - 2026-08-05
|
|
2
30
|
|
|
3
31
|
- initial release - extracted `CID` and `CARArchive` from Skyfall
|
data/README.md
CHANGED
|
@@ -1,13 +1,153 @@
|
|
|
1
1
|
# Oxygène
|
|
2
2
|
|
|
3
|
+
[](https://rubygems.org/gems/oxygene) [](https://rubydoc.info/gems/oxygene)
|
|
4
|
+
|
|
3
5
|
*(n) Gaz incolore, inodore et sans saveur qui compose 1/5ème de l'air atmosphérique.*
|
|
4
6
|
|
|
5
|
-
Various data decoding primitives for working with AT Protocol and the Atmosphere (including CAR, CID, CBOR).
|
|
7
|
+
Various data decoding primitives for working with AT Protocol and the Atmosphere (including CAR, CID, CBOR).
|
|
6
8
|
|
|
7
9
|
> [!NOTE]
|
|
8
10
|
> Part of ATProto Ruby SDK: [ruby.sdk.blue](https://ruby.sdk.blue)
|
|
9
11
|
|
|
10
12
|
|
|
13
|
+
## Purpose
|
|
14
|
+
|
|
15
|
+
Oxygene includes various data decoding related primitives for working with AT Protocol repositories and firehose events. Provides at least partial implementations of standards such as [CAR](https://dasl.ing/car.html), [CID](https://dasl.ing/cid.html) and [Base 32](https://datatracker.ietf.org/doc/html/rfc4648#section-6).
|
|
16
|
+
|
|
17
|
+
This gem was extracted from [Skyfall](https://ruby.sdk.blue/skyfall/) and is mostly used there for the CBOR firehose decoding, but can also be used standalone for other ATProto data handling purposes, like decoding downloaded .car account repos.
|
|
18
|
+
|
|
19
|
+
The functionality currently includes:
|
|
20
|
+
|
|
21
|
+
- Base32 encoder/decoder
|
|
22
|
+
- CAR archive/repo parser
|
|
23
|
+
- CID wrapper
|
|
24
|
+
- and CBOR decoding (through the [cbor gem](https://github.com/cabo/cbor-ruby) for now)
|
|
25
|
+
|
|
26
|
+
The gem intentionally only includes support for the parts of these standards that are in use in ATProto, i.e. it doesn't handle all possible types of CIDs defined in the CID standard, only generates lowercase Base32, and so on.
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
## Installation
|
|
30
|
+
|
|
31
|
+
To use Oxygene, you need a reasonably new version of Ruby – it should run on Ruby 2.6 and above, although it's recommended to use a version that's still getting maintainance updates, i.e. currently 3.3+. A compatible version should be preinstalled on macOS Big Sur and above and on many Linux systems. Otherwise, you can install one using tools such as [RVM](https://rvm.io), [asdf](https://asdf-vm.com), [ruby-install](https://github.com/postmodern/ruby-install) or [ruby-build](https://github.com/rbenv/ruby-build), or `rpm` or `apt-get` on Linux (see more installation options on [ruby-lang.org](https://www.ruby-lang.org/en/downloads/)).
|
|
32
|
+
|
|
33
|
+
To install the gem, run the command:
|
|
34
|
+
|
|
35
|
+
[sudo] gem install oxygene
|
|
36
|
+
|
|
37
|
+
Or add this to your app's `Gemfile`:
|
|
38
|
+
|
|
39
|
+
gem 'oxygene', '~> 0.1'
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
## Usage
|
|
43
|
+
|
|
44
|
+
### CID
|
|
45
|
+
|
|
46
|
+
The `Oxygene::CID` class is an immutable object wrapper for CIDs (Content Identifiers), both in the binary/CBOR form and in the Base32-encoded JSON string form.
|
|
47
|
+
|
|
48
|
+
To create one from a CBOR tag object (`CBOR::Tagged`):
|
|
49
|
+
|
|
50
|
+
```rb
|
|
51
|
+
data = CBOR.decode(file)
|
|
52
|
+
cid = CID.from_cbor_tag(data['cid'])
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
To create one from a JSON representation:
|
|
56
|
+
|
|
57
|
+
```rb
|
|
58
|
+
cid = CID.from_json(event['cid'])
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Both can be converted to the other representation:
|
|
62
|
+
|
|
63
|
+
```rb
|
|
64
|
+
cid.cbor_form
|
|
65
|
+
# => "\x00\x01q\x12 \x1A\x88\xF3Z\xFC..."
|
|
66
|
+
|
|
67
|
+
cid.raw_data
|
|
68
|
+
# same but without the 0 prefix
|
|
69
|
+
# => "\x01q\x12 \x1A\x88\xF3Z\xFC..."
|
|
70
|
+
|
|
71
|
+
cid.json_form
|
|
72
|
+
# => "bafyreia2rdzvv7fjlp..."
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
### CAR archives & repos
|
|
77
|
+
|
|
78
|
+
`Oxygene::CARArchive` is a wrapper for any CAR archive file, including e.g. in particular the kind that's contained in a `blocks` field in a `:commit` firehose message.
|
|
79
|
+
|
|
80
|
+
Create it passing it the binary data to decode:
|
|
81
|
+
|
|
82
|
+
```rb
|
|
83
|
+
car = Oxygene::CARArchive.new(message.blocks)
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
The decoder automatically parses the CAR file header containing the "root" CIDs, and makes those available as `#roots` array. The remaining sections are parsed lazily on demand.
|
|
87
|
+
|
|
88
|
+
You can parse and access the sections in the following ways:
|
|
89
|
+
|
|
90
|
+
- `#sections` returns an array of all sections in the archive, parsing them if needed
|
|
91
|
+
- `#parsed_sections` returns the sections parsed so far (initially `[]`)
|
|
92
|
+
- `section_with_cid(cid)` looks up a section by CID; if it was already parsed, it's returned immediately, otherwise remaining sections are parsed until a match is found (or the end of the file is reached)
|
|
93
|
+
- **Note:** parsed sections are searched sequentially with `detect`. If you need to look up multiple sections repeatedly, use the second variant below.
|
|
94
|
+
- `section_with_cid(cid, use_map: true)` also looks up a section, but builds up and uses a `Hash` mapping CIDs to sections for quick lookup. For performance, creating this section index is opt-in, since it's not needed if only one section will be looked up.
|
|
95
|
+
|
|
96
|
+
Sections are represented as `Oxygene::CARSection` objects. A section has a `#cid`, and can return the body decoded from the binary CBOR data in two ways:
|
|
97
|
+
|
|
98
|
+
- `#decoded_body` decodes the CBOR into a Ruby `Hash`, but besides that leaves it as is; this means that e.g. CIDs are included as `CBOR::Tagged` objects
|
|
99
|
+
- `#json_body` additionally makes a recursive conversion of the object to an "ATProto JSON" as described in [ATProto Data Model](https://atproto.com/specs/data-model):
|
|
100
|
+
- CIDs are converted to `{ "$link": "(base32 representation)" }`
|
|
101
|
+
- binary strings are converted to `{ "$bytes": "(base64 encoded data)" }`
|
|
102
|
+
|
|
103
|
+
For backwards compatibility reasons, `#section_with_cid` currently returns the `#json_body` of a section directly, unless you pass `return_body: false` to return a `CARSection`. This will be changed in a future version.
|
|
104
|
+
|
|
105
|
+
For .car account repos, there is additionally a subclass of `CARArchive` called `CARRepo`, which lets you extract the repo's records from the archive's Merkle Search Tree.
|
|
106
|
+
|
|
107
|
+
Create a repo reader like this:
|
|
108
|
+
|
|
109
|
+
```rb
|
|
110
|
+
car = Oxygene::CARRepo.new(File.read(repo_path))
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Then, iterate over the records using the `#walk_all_nodes` method. The records are indexed by the record "path", which is the NSID collection + the rkey joined with a `/`, and are returned in alphabetical order sorted by that path key (so by collection first and then by rkey within a collection):
|
|
114
|
+
|
|
115
|
+
```rb
|
|
116
|
+
car.walk_all_nodes do |key, cid|
|
|
117
|
+
collection, rkey = key.split('/')
|
|
118
|
+
if collection == 'app.bsky.feed.post'
|
|
119
|
+
record = car.section_with_cid(cid, use_map: true)
|
|
120
|
+
# ... save or print record w/ rkey
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
### Base32
|
|
127
|
+
|
|
128
|
+
To encode a binary string into Base32:
|
|
129
|
+
|
|
130
|
+
```rb
|
|
131
|
+
string = Oxygene::Base32.encode(data)
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
To decode Base32 into binary data:
|
|
135
|
+
|
|
136
|
+
```rb
|
|
137
|
+
data = Oxygene::Base32.decode(string)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
As an optimization, both methods also allow you to specify an offset in the input string from which to start reading, and a prefix to put at the beginning of the output buffer, so you can avoid some string allocations in a hot code path:
|
|
141
|
+
|
|
142
|
+
```rb
|
|
143
|
+
# skip the \x00 and add a starting 'b'
|
|
144
|
+
json_cid = Oxygene::Base32.encode(cbor_tag_cid, 1, 'b')
|
|
145
|
+
|
|
146
|
+
# skip the 'b' and add a starting \x00
|
|
147
|
+
binary_cid = Oxygene::Base32.decode(json_cid, 1, "\x00")
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
|
|
11
151
|
## Credits
|
|
12
152
|
|
|
13
153
|
Copyright © 2026 Kuba Suder ([@mackuba.eu](https://bsky.app/profile/did:plc:oio4hkxaop4ao4wz2pp3f4cr)).
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative 'errors'
|
|
4
|
+
|
|
5
|
+
module Oxygene
|
|
6
|
+
|
|
7
|
+
#
|
|
8
|
+
# Fast, optimized implementation of [RFC 4648 Base32](https://datatracker.ietf.org/doc/html/rfc4648#section-6)
|
|
9
|
+
# for encoding and decoding binary data.
|
|
10
|
+
#
|
|
11
|
+
# Decoding accepts either lowercase or uppercase input and accepts `=` padding if present. Encoder only
|
|
12
|
+
# creates Base32 output using lowercase alphabet and without padding, since that's what it used in
|
|
13
|
+
# DASL/ATProto CIDs.
|
|
14
|
+
#
|
|
15
|
+
# Based on the code of the [base32 gem](https://github.com/stesla/base32) by Samantha Tesla (MIT).
|
|
16
|
+
#
|
|
17
|
+
|
|
18
|
+
module Base32
|
|
19
|
+
BASE32_ALPHABET = "abcdefghijklmnopqrstuvwxyz234567".freeze
|
|
20
|
+
|
|
21
|
+
BASE32_ENCODE_TABLE = Array.new(1024) { |i|
|
|
22
|
+
(BASE32_ALPHABET.getbyte(i >> 5).chr + BASE32_ALPHABET.getbyte(i & 31).chr).freeze
|
|
23
|
+
}.freeze
|
|
24
|
+
|
|
25
|
+
BASE32_DECODE_TABLE = begin
|
|
26
|
+
table = Array.new(256, 255)
|
|
27
|
+
|
|
28
|
+
BASE32_ALPHABET.each_byte.with_index do |byte, value|
|
|
29
|
+
table[byte] = value
|
|
30
|
+
table[byte - 32] = value if byte >= 97 && byte <= 122
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
table.freeze
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private_constant :BASE32_ALPHABET, :BASE32_ENCODE_TABLE, :BASE32_DECODE_TABLE
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Encodes a binary string into Base32 (lowercase and without padding).
|
|
40
|
+
#
|
|
41
|
+
# @param data [String] the input string to encode
|
|
42
|
+
# @param start_offset [Integer] byte offset in `data` from which bytes should be read
|
|
43
|
+
# @param prefix [String] string to add at the beginning of the encoded result
|
|
44
|
+
#
|
|
45
|
+
# @return [String] a new string containing the prefix and the input encoded into Base32
|
|
46
|
+
# @raise [ArgumentError] if `start_offset` is negative or beyond the end of `data`
|
|
47
|
+
|
|
48
|
+
def self.encode(data, start_offset = 0, prefix = "")
|
|
49
|
+
total_size = data.bytesize
|
|
50
|
+
raise ArgumentError, "Start offset can't be negative" if start_offset < 0
|
|
51
|
+
raise ArgumentError, "Start offset is larger than the length of data" if start_offset > total_size
|
|
52
|
+
|
|
53
|
+
encoded_size = total_size - start_offset
|
|
54
|
+
output = prefix.dup
|
|
55
|
+
offset = start_offset
|
|
56
|
+
full_block_end = total_size - (encoded_size % 5)
|
|
57
|
+
|
|
58
|
+
# A single base32 character is one of the 32 values in the BASE32_ALPHABET string
|
|
59
|
+
# above, i.e. 5 bits. The BASE32_ENCODE_TABLE array stores a (flattened) 32x32 table
|
|
60
|
+
# of all possible combinations of two-character pairs (10 bits).
|
|
61
|
+
#
|
|
62
|
+
# Instead of taking the 40 bits of a 5-byte slice of the original string, slicing
|
|
63
|
+
# it into 8 5-bit pieces and looking up 8 separate base32 characters, we process
|
|
64
|
+
# 10 bits at a time here, looking up 4 two-character pairs. This allows us to do
|
|
65
|
+
# only half the amount of array lookups, shifts and bitwise ands.
|
|
66
|
+
|
|
67
|
+
while offset < full_block_end
|
|
68
|
+
value = (data.getbyte(offset) << 32) |
|
|
69
|
+
(data.getbyte(offset + 1) << 24) |
|
|
70
|
+
(data.getbyte(offset + 2) << 16) |
|
|
71
|
+
(data.getbyte(offset + 3) << 8) |
|
|
72
|
+
(data.getbyte(offset + 4))
|
|
73
|
+
|
|
74
|
+
output << BASE32_ENCODE_TABLE[(value >> 30) & 1023]
|
|
75
|
+
output << BASE32_ENCODE_TABLE[(value >> 20) & 1023]
|
|
76
|
+
output << BASE32_ENCODE_TABLE[(value >> 10) & 1023]
|
|
77
|
+
output << BASE32_ENCODE_TABLE[(value) & 1023]
|
|
78
|
+
|
|
79
|
+
offset += 5
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Finish the output for the final incomplete block of less than 40 bits:
|
|
83
|
+
|
|
84
|
+
case total_size - offset
|
|
85
|
+
when 1
|
|
86
|
+
output << BASE32_ENCODE_TABLE[data.getbyte(offset) << 2]
|
|
87
|
+
when 2
|
|
88
|
+
value = (data.getbyte(offset) << 8) | data.getbyte(offset + 1)
|
|
89
|
+
|
|
90
|
+
output << BASE32_ENCODE_TABLE[value >> 6]
|
|
91
|
+
output << BASE32_ENCODE_TABLE[(value & 63) << 4]
|
|
92
|
+
when 3
|
|
93
|
+
value = (data.getbyte(offset) << 16) |
|
|
94
|
+
(data.getbyte(offset + 1) << 8) |
|
|
95
|
+
(data.getbyte(offset + 2))
|
|
96
|
+
|
|
97
|
+
output << BASE32_ENCODE_TABLE[value >> 14]
|
|
98
|
+
output << BASE32_ENCODE_TABLE[(value >> 4) & 1023]
|
|
99
|
+
output << BASE32_ALPHABET.getbyte((value & 15) << 1)
|
|
100
|
+
when 4
|
|
101
|
+
value = (data.getbyte(offset) << 24) |
|
|
102
|
+
(data.getbyte(offset + 1) << 16) |
|
|
103
|
+
(data.getbyte(offset + 2) << 8) |
|
|
104
|
+
(data.getbyte(offset + 3))
|
|
105
|
+
|
|
106
|
+
output << BASE32_ENCODE_TABLE[value >> 22]
|
|
107
|
+
output << BASE32_ENCODE_TABLE[(value >> 12) & 1023]
|
|
108
|
+
output << BASE32_ENCODE_TABLE[(value >> 2) & 1023]
|
|
109
|
+
output << BASE32_ALPHABET.getbyte((value & 3) << 3)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
output
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
# Decodes Base32-encoded data into the original byte string.
|
|
116
|
+
#
|
|
117
|
+
# For compatibility, the decoder accepts both lowercase or uppercase inputs and trailing
|
|
118
|
+
# `=` padding if present. If you want to reject uppercase characters or padding, you need
|
|
119
|
+
# to perform such checks manually.
|
|
120
|
+
#
|
|
121
|
+
# @param data [String] the Base32-encoded input string
|
|
122
|
+
# @param start_offset [Integer] byte offset in `data` from which Base32 characters should be read
|
|
123
|
+
# @param prefix [String] string to add at the beginning of the decoded result
|
|
124
|
+
#
|
|
125
|
+
# @return [String] a new binary string containing the prefix and the decoded bytes
|
|
126
|
+
# @raise [ArgumentError] if `start_offset` is negative or beyond the end of `data`
|
|
127
|
+
# @raise [DecodeError] if the data length, padding, characters used or trailing bits are invalid
|
|
128
|
+
|
|
129
|
+
def self.decode(data, start_offset = 0, prefix = "")
|
|
130
|
+
total_size = data.bytesize
|
|
131
|
+
raise ArgumentError, "Start offset can't be negative" if start_offset < 0
|
|
132
|
+
raise ArgumentError, "Start offset is larger than the length of data" if start_offset > total_size
|
|
133
|
+
|
|
134
|
+
encoded_end = total_size
|
|
135
|
+
|
|
136
|
+
if encoded_end > start_offset && data.getbyte(encoded_end - 1) == 61 # '='
|
|
137
|
+
# Only decode until the beginning of padding
|
|
138
|
+
encoded_end -= 1 while encoded_end > start_offset && data.getbyte(encoded_end - 1) == 61
|
|
139
|
+
|
|
140
|
+
padding_size = total_size - encoded_end
|
|
141
|
+
|
|
142
|
+
# Full blocks are 8 5-bit Base32 characters decoded into 5 8-bit bytes.
|
|
143
|
+
# Here, we check if in the last incomplete block the padding (if included)
|
|
144
|
+
# covers the whole rest of the block.
|
|
145
|
+
#
|
|
146
|
+
# Because how 5-bit characters map into 8-bit bytes, only a final incomplete
|
|
147
|
+
# block of 2, 4, 5 or 7 characters makes sense - 1, 3 and 6 don't include enough
|
|
148
|
+
# bits to create another byte. Which is why padding_size can't be 7 here.
|
|
149
|
+
#
|
|
150
|
+
# "n & 7" is the same as "n % 8", just slightly faster.
|
|
151
|
+
|
|
152
|
+
unpadded_remainder = (encoded_end - start_offset) & 7
|
|
153
|
+
expected_padding = (8 - unpadded_remainder) & 7
|
|
154
|
+
|
|
155
|
+
if ((total_size - start_offset) & 7) != 0 || padding_size != expected_padding || padding_size > 6
|
|
156
|
+
raise DecodeError, "Invalid Base32 padding"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
encoded_size = encoded_end - start_offset
|
|
161
|
+
remainder = encoded_size & 7
|
|
162
|
+
|
|
163
|
+
# See above for why only these are allowed
|
|
164
|
+
|
|
165
|
+
unless remainder == 0 || remainder == 2 || remainder == 4 || remainder == 5 || remainder == 7
|
|
166
|
+
raise DecodeError, "Invalid Base32 length"
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
output = prefix.dup.force_encoding(Encoding::BINARY)
|
|
170
|
+
offset = start_offset
|
|
171
|
+
full_block_end = encoded_end - remainder
|
|
172
|
+
table = BASE32_DECODE_TABLE
|
|
173
|
+
|
|
174
|
+
# Process full blocks of 8 5-bit Base32 characters and decode them into 5 8-bit
|
|
175
|
+
# bytes. The characters are first mapped into indexes of 0...32, and then combined
|
|
176
|
+
# into a single 40-bit number, which is sliced into 5 bytes.
|
|
177
|
+
#
|
|
178
|
+
# The BASE32_DECODE_TABLE table is a table mapping character indexes 0...256 to
|
|
179
|
+
# the 0...32 Base32 value. Using this table, a character's index can be looked up in
|
|
180
|
+
# O(1) time using its .ord code. The special value 255 means that there is no Base32
|
|
181
|
+
# character with a given ASCII code and that an exception should be raised.
|
|
182
|
+
|
|
183
|
+
while offset < full_block_end
|
|
184
|
+
v0 = table[data.getbyte(offset)]
|
|
185
|
+
v1 = table[data.getbyte(offset + 1)]
|
|
186
|
+
v2 = table[data.getbyte(offset + 2)]
|
|
187
|
+
v3 = table[data.getbyte(offset + 3)]
|
|
188
|
+
v4 = table[data.getbyte(offset + 4)]
|
|
189
|
+
v5 = table[data.getbyte(offset + 5)]
|
|
190
|
+
v6 = table[data.getbyte(offset + 6)]
|
|
191
|
+
v7 = table[data.getbyte(offset + 7)]
|
|
192
|
+
|
|
193
|
+
# Combine the bits of the 8 index values using OR. If any of the 8 characters
|
|
194
|
+
# has any bits above the 6th (in practice it could only be 255), the combined
|
|
195
|
+
# value will also have the bits set so it will be higher than 31.
|
|
196
|
+
|
|
197
|
+
invalid_value = v0 | v1 | v2 | v3 | v4 | v5 | v6 | v7
|
|
198
|
+
invalid_character!(data, offset, offset + 8, table) if invalid_value > 31
|
|
199
|
+
|
|
200
|
+
value = (v0 << 35) | (v1 << 30) | (v2 << 25) | (v3 << 20) | (v4 << 15) | (v5 << 10) | (v6 << 5) | v7
|
|
201
|
+
|
|
202
|
+
output << ((value >> 32) & 255)
|
|
203
|
+
output << ((value >> 24) & 255)
|
|
204
|
+
output << ((value >> 16) & 255)
|
|
205
|
+
output << ((value >> 8) & 255)
|
|
206
|
+
output << (value & 255)
|
|
207
|
+
|
|
208
|
+
offset += 8
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Finish the output for the final incomplete block of less than 40 bits:
|
|
212
|
+
|
|
213
|
+
case remainder
|
|
214
|
+
when 2
|
|
215
|
+
v0 = table[data.getbyte(offset)]
|
|
216
|
+
v1 = table[data.getbyte(offset + 1)]
|
|
217
|
+
invalid_character!(data, offset, encoded_end, table) if (v0 | v1) > 31
|
|
218
|
+
|
|
219
|
+
value = (v0 << 5) | v1
|
|
220
|
+
raise DecodeError, "Invalid Base32 trailing bits" unless (value & 3) == 0
|
|
221
|
+
|
|
222
|
+
output << (value >> 2)
|
|
223
|
+
|
|
224
|
+
when 4
|
|
225
|
+
v0 = table[data.getbyte(offset)]
|
|
226
|
+
v1 = table[data.getbyte(offset + 1)]
|
|
227
|
+
v2 = table[data.getbyte(offset + 2)]
|
|
228
|
+
v3 = table[data.getbyte(offset + 3)]
|
|
229
|
+
invalid_character!(data, offset, encoded_end, table) if (v0 | v1 | v2 | v3) > 31
|
|
230
|
+
|
|
231
|
+
value = (v0 << 15) | (v1 << 10) | (v2 << 5) | v3
|
|
232
|
+
raise DecodeError, "Invalid Base32 trailing bits" unless (value & 15) == 0
|
|
233
|
+
|
|
234
|
+
output << ((value >> 12) & 255)
|
|
235
|
+
output << ((value >> 4) & 255)
|
|
236
|
+
|
|
237
|
+
when 5
|
|
238
|
+
v0 = table[data.getbyte(offset)]
|
|
239
|
+
v1 = table[data.getbyte(offset + 1)]
|
|
240
|
+
v2 = table[data.getbyte(offset + 2)]
|
|
241
|
+
v3 = table[data.getbyte(offset + 3)]
|
|
242
|
+
v4 = table[data.getbyte(offset + 4)]
|
|
243
|
+
invalid_character!(data, offset, encoded_end, table) if (v0 | v1 | v2 | v3 | v4) > 31
|
|
244
|
+
|
|
245
|
+
value = (v0 << 20) | (v1 << 15) | (v2 << 10) | (v3 << 5) | v4
|
|
246
|
+
raise DecodeError, "Invalid Base32 trailing bits" unless (value & 1) == 0
|
|
247
|
+
|
|
248
|
+
output << ((value >> 17) & 255)
|
|
249
|
+
output << ((value >> 9) & 255)
|
|
250
|
+
output << ((value >> 1) & 255)
|
|
251
|
+
|
|
252
|
+
when 7
|
|
253
|
+
v0 = table[data.getbyte(offset)]
|
|
254
|
+
v1 = table[data.getbyte(offset + 1)]
|
|
255
|
+
v2 = table[data.getbyte(offset + 2)]
|
|
256
|
+
v3 = table[data.getbyte(offset + 3)]
|
|
257
|
+
v4 = table[data.getbyte(offset + 4)]
|
|
258
|
+
v5 = table[data.getbyte(offset + 5)]
|
|
259
|
+
v6 = table[data.getbyte(offset + 6)]
|
|
260
|
+
invalid_character!(data, offset, encoded_end, table) if (v0 | v1 | v2 | v3 | v4 | v5 | v6) > 31
|
|
261
|
+
|
|
262
|
+
value = (v0 << 30) | (v1 << 25) | (v2 << 20) | (v3 << 15) |
|
|
263
|
+
(v4 << 10) | (v5 << 5) | v6
|
|
264
|
+
raise DecodeError, "Invalid Base32 trailing bits" unless (value & 7) == 0
|
|
265
|
+
|
|
266
|
+
output << ((value >> 27) & 255)
|
|
267
|
+
output << ((value >> 19) & 255)
|
|
268
|
+
output << ((value >> 11) & 255)
|
|
269
|
+
output << ((value >> 3) & 255)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
output
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def self.invalid_character!(data, offset, end_offset, table)
|
|
277
|
+
offset += 1 while offset < end_offset && table[data.getbyte(offset)] <= 31
|
|
278
|
+
character = data.byteslice(offset, 1)
|
|
279
|
+
raise DecodeError, "Invalid Base32 character: #{character.inspect}"
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
private_class_method :invalid_character!
|
|
283
|
+
end
|
|
284
|
+
end
|