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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 16f8b923311bdcd734666a03e4beff53a786b13a131a29e2ae834996d6c8545e
4
- data.tar.gz: c3c3ebc73bc19bdc937425757a1563c181e2790d6e3a87a76d086675540ed68c
3
+ metadata.gz: d85155d1ce53a32be48e557bd81995deea20b096f0d5ac7797aad798f6527680
4
+ data.tar.gz: 70d1b127ab875cc3a7d2f04b922aa48c950cb2db33b46240e35a29080edc5fc8
5
5
  SHA512:
6
- metadata.gz: 30e917d087c398a27af26984842f5a71901093599310244f12794614ec1f5326614a2465021d6d2936c633ca9429246be493fa21e13ef4464054e5b02f7096af
7
- data.tar.gz: 58c550605cdaf83a38aeb0a49c67fa4badafeed1ac5f2991b969179e609db485e05f17aa21cc4c42e092a06ec44f791c7fa02e3bcd6225602d7b7fbf69086941
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
+ [![Gem Version](https://badge.fury.io/rb/oxygene.svg?icon=si%3Arubygems&icon_color=%23ff6251)](https://rubygems.org/gems/oxygene) [![YARD Docs](http://img.shields.io/badge/yard-docs-blue.svg)](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). (Extracted from [Skyfall](https://tangled.org/mackuba.eu/skyfall), work in progress.)
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