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.
data/lib/oxygene/cid.rb CHANGED
@@ -1,45 +1,338 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'base32'
3
4
  require_relative 'errors'
5
+ require_relative 'extensions'
4
6
 
5
- require 'base32'
7
+ module Oxygene
6
8
 
7
- # CIDs in DAG-CBOR: https://ipld.io/specs/codecs/dag-cbor/spec/
8
- # CIDs in JSON: https://ipld.io/specs/codecs/dag-json/spec/
9
- # multibase: https://github.com/multiformats/multibase
9
+ #
10
+ # Represents a Content Identifier (CID) of some piece of content like an ATProto
11
+ # record or a CAR section.
12
+ #
13
+ # Only the [DASL](https://dasl.ing)-compatible CID versions that are used in ATProto are supported:
14
+ #
15
+ # - only CIDv1, not v0
16
+ # - in binary CBOR form: with SHA-256 hash, 32 bytes hash size, and codec being
17
+ # either 0x71 (DRISL) or 0x55 (raw) (DRISL is used in CIDs of CAR sections,
18
+ # and raw codec is used in CIDs of blobs)
19
+ # - in JSON string form: with 'b' prefix and Base32-encoded data, using lowercase
20
+ # alphabet and no '=' padding, and the CBOR form parameters listed above,
21
+ # resulting in an either "bafyrei" or "bafkrei" prefix
22
+ #
23
+ # The CID instances can be created either from JSON strings or binary CBOR form,
24
+ # and the binary form can optionally include the \\x00 prefix byte that is used
25
+ # when the binary CID is encoded in a CBOR tag 42 object. The instances will lazily
26
+ # convert between the two forms only when needed.
27
+ #
28
+ # The code in this class is heavily optimized for performance when creating and
29
+ # processing a large number of CIDs e.g. when parsing a CAR repo or processing
30
+ # firehose events, for example:
31
+ #
32
+ # - the input JSON or binary string is stored without copying or allocating new
33
+ # strings for modified versions, if possible
34
+ # - the other form (JSON/CBOR) is memoized and only created on demand, not up front
35
+ # - equality check tries to use whichever form exists in both instances if possible,
36
+ # to avoid unnecessary conversion
37
+ # - validation code uses pre-generated header constants to avoid checking the headers
38
+ # byte by byte, and operates mostly on byte code numbers to avoid unnecessary string allocations
39
+ #
40
+ # Related specifications:
41
+ #
42
+ # - [IPFS CID spec](https://specs.ipfs.tech/cid/)
43
+ # - [DASL CID spec](https://dasl.ing/cid.html)
44
+ # - [ATProto link and CID formats](https://atproto.com/specs/data-model#link-and-cid-formats)
45
+ # - [CIDs in DAG-CBOR](https://ipld.io/specs/codecs/dag-cbor/spec/)
46
+ # - [CIDs in DAG-JSON](https://ipld.io/specs/codecs/dag-json/spec/)
47
+ # - [multicodec](https://github.com/multiformats/multicodec),
48
+ # [multihash](https://github.com/multiformats/multihash)
49
+ # and [multibase](https://github.com/multiformats/multibase)
50
+ #
10
51
 
11
- module Oxygene
12
52
  class CID
13
- attr_reader :data
53
+ using Oxygene::Extensions
54
+
55
+ # Multibase prefix code for Base32, required for CIDs in JSON string form.
56
+ JSON_PREFIX = 'b'
57
+
58
+ # Pre-generated JSON string headers for quick checking
59
+ DRISL_JSON_PREFIX = "bafyrei"
60
+ RAW_JSON_PREFIX = "bafkrei"
61
+
62
+ private_constant :DRISL_JSON_PREFIX, :RAW_JSON_PREFIX
63
+
64
+ # Multibase "identity" prefix code, required for binary CIDs stored in a CBOR tag 42 object.
65
+ CBOR_TAG_PREFIX = "\x00".b.freeze
66
+
67
+ # The two possible CID codec IDs from multicodec:
68
+
69
+ # DAG-CBOR or DRISL (used e.g. in CIDs of CAR sections)
70
+ DRISL_CODEC_ID = 0x71
14
71
 
72
+ # Raw binary (used in CIDs of blobs)
73
+ RAW_CODEC_ID = 0x55
74
+
75
+ # Pre-generated binary string headers for quick checking
76
+ DRISL_BINARY_PREFIX = "\x01\x71\x12\x20".b.freeze
77
+ RAW_BINARY_PREFIX = "\x01\x55\x12\x20".b.freeze
78
+
79
+ # Pre-generated binary string header variants with identity prefix byte added
80
+ TAG_DRISL_BINARY_PREFIX = (CBOR_TAG_PREFIX + DRISL_BINARY_PREFIX).freeze
81
+ TAG_RAW_BINARY_PREFIX = (CBOR_TAG_PREFIX + RAW_BINARY_PREFIX).freeze
82
+
83
+ private_constant :DRISL_BINARY_PREFIX, :RAW_BINARY_PREFIX, :TAG_DRISL_BINARY_PREFIX, :TAG_RAW_BINARY_PREFIX
84
+
85
+
86
+ # Builds a CID from a CBOR tag 42 value.
87
+ #
88
+ # Expects a `CBOR::Tagged` object returned from `CBOR.decode` or `CBOR.decode_sequence`.
89
+ #
90
+ # @param tag [CBOR::Tagged] tagged CBOR object containing binary CID data with identity (0) prefix
91
+ # @return [CID] CID object wrapping that content identifier
92
+ # @raise [DecodeError] if the CID has an invalid size or is missing an expected prefix/suffix
93
+ # @raise [UnsupportedError] if the CID is in a form that is technically valid, but not supported here
94
+ #
15
95
  def self.from_cbor_tag(tag)
16
- data = tag.value
17
- raise DecodeError.new("Unexpected first byte of CID: #{data[0]}") unless data[0] == "\x00"
18
- CID.new(data[1..-1])
96
+ CID.new(tag.value, binary: true, cbor_prefix: true)
19
97
  end
20
98
 
99
+ # Builds a CID from a Base32-encoded JSON form string.
100
+ #
101
+ # @param string [String] 59-character string with a `b` prefix encoded with Base32
102
+ # @return [CID] CID object wrapping that content identifier
103
+ # @raise [ArgumentError] if the input string is nil
104
+ # @raise [DecodeError] if the CID has an invalid size or is missing an expected prefix/suffix
105
+ # @raise [UnsupportedError] if the CID is in a form that is technically valid, but not supported here
106
+ #
21
107
  def self.from_json(string)
22
- raise DecodeError.new("Unexpected CID length") unless string.length == 59
23
- raise DecodeError.new("Unexpected CID prefix") unless string[0] == 'b'
108
+ CID.new(string, binary: false)
109
+ end
110
+
111
+ # Creates a CID instance from binary/CBOR data or a JSON string form.
112
+ #
113
+ # Three possible kinds of values are accepted as input:
114
+ # - a binary string with \\x00 identity prefix, as used when included in a
115
+ # CBOR tag object's value - use `binary: true` and `cbor_prefix: true`
116
+ # - a binary string without the identity prefix, as used e.g. when decoded
117
+ # from a CAR archive - use `binary: true` without `cbor_prefix`
118
+ # - a JSON string - use `binary: false`
119
+ #
120
+ # For backwards compatibility, the default when only the input argument is
121
+ # passed is to assume the "binary without CBOR prefix" form. For performance,
122
+ # for the "binary with CBOR prefix" and the JSON form types, the input string
123
+ # itself is stored without copying and frozen to prevent modification. If you
124
+ # need to modify the string later at the call site, call {dup} when passing the
125
+ # input argument here. (For the non-prefixed binary version, the input string is
126
+ # copied to add the prefix.)
127
+ #
128
+ # Optionally, the initializer can also enforce that the CID has to use a selected one
129
+ # of the two available codecs (DRISL/DAG-CBOR vs. raw binary).
130
+ #
131
+ # @param data [String] raw CID bytes, identity-prefixed CBOR bytes, or a Base32 JSON string
132
+ # @param binary [Boolean] true if `data` is in binary form, false if it's a JSON string
133
+ # @param cbor_prefix [Boolean] true if the `data` includes the leading null byte used inside CBOR tag 42
134
+ # @param codec [:drisl, :raw, nil] required content codec, or `nil` to accept either of the two supported codecs
135
+ #
136
+ # @raise [ArgumentError] if data is nil, codec param is invalid, or invalid combination of options is used
137
+ # @raise [DecodeError] if the CID has an invalid size or is missing an expected prefix/suffix
138
+ # @raise [UnsupportedError] if the CID is in a form that is technically valid, but not supported here
139
+ #
140
+ def initialize(data, binary: true, cbor_prefix: false, codec: nil)
141
+ raise ArgumentError.new("Data cannot be nil") if data.nil?
24
142
 
25
- data = Base32.decode(string[1..-1].upcase)
26
- CID.new(data)
143
+ codec_id = case codec
144
+ when nil then nil
145
+ when :drisl then DRISL_CODEC_ID
146
+ when :raw then RAW_CODEC_ID
147
+ else raise ArgumentError.new("Unexpected CID codec: #{codec.inspect}")
148
+ end
149
+
150
+ if binary
151
+ if cbor_prefix
152
+ validate_binary_form(data, true, codec_id)
153
+ @cbor_form = data.freeze
154
+ else
155
+ validate_binary_form(data, false, codec_id)
156
+ @cbor_form = (CBOR_TAG_PREFIX + data).freeze
157
+ end
158
+ else
159
+ raise ArgumentError.new("cbor_prefix cannot be used with JSON input") if cbor_prefix
160
+
161
+ validate_json_form(data, codec_id)
162
+ @json_form = data.freeze
163
+ end
164
+ end
165
+
166
+ # Returns the CID data in binary form with a null byte identity prefix, as stored in CBOR tag 42.
167
+ #
168
+ # If the CID was created from a JSON form, the data is decoded from Base32 (and memoized).
169
+ # If it was created from a CBOR tag, the input is returned directly.
170
+ #
171
+ # @return [String] frozen binary string beginning with {CBOR_TAG_PREFIX}
172
+ # @raise [ArgumentError] if a JSON-form CID contains invalid Base32
173
+
174
+ def cbor_form
175
+ @cbor_form ||= Base32.decode(@json_form, 1, CBOR_TAG_PREFIX).freeze
176
+ end
177
+
178
+ # Returns the CID data in binary form, without the null byte identity prefix from CBOR tag.
179
+ #
180
+ # If the CID was created from a JSON form, the data is decoded from Base32 (and memoized).
181
+ #
182
+ # @return [String] frozen binary CID bytes
183
+ # @raise [ArgumentError] if a JSON-form CID contains invalid Base32
184
+
185
+ def raw_data
186
+ @raw_data ||= if @cbor_form
187
+ @cbor_form.byteslice(1, @cbor_form.bytesize - 1).freeze
188
+ else
189
+ Base32.decode(@json_form, 1).freeze
190
+ end
27
191
  end
28
192
 
29
- def initialize(data)
30
- @data = data
193
+ alias data raw_data
194
+
195
+ # Returns the CID's Base32-encoded JSON string representation.
196
+ #
197
+ # If the CID was created from a binary form, the data is encoded into Base32 (and memoized).
198
+ # If it was created from a JSON form, the input is returned directly.
199
+ #
200
+ # @return [String] frozen CID string beginning with {JSON_PREFIX}
201
+
202
+ def json_form
203
+ @json_form ||= Base32.encode(@cbor_form, 1, JSON_PREFIX).freeze
31
204
  end
32
205
 
206
+ # Returns the CID's Base32-encoded JSON string representation (same as {#json_form}).
207
+ #
208
+ # @return [String] the CID in the JSON form
209
+
33
210
  def to_s
34
- 'b' + Base32.encode(@data).downcase.gsub(/=+$/, '')
211
+ json_form
35
212
  end
36
213
 
214
+ # @return [String] a representation of the CID object for debugging
215
+ #
37
216
  def inspect
38
- "CID(\"#{to_s}\")"
217
+ "CID(\"#{json_form}\")"
39
218
  end
40
219
 
220
+ # Compares this CID with another CID to see if they're equal.
221
+ #
222
+ # If both CIDs have a generated CBOR or JSON form, those forms are used
223
+ # for comparison without conversion. If the two only have different forms,
224
+ # the JSON CID is converted to binary for comparison.
225
+ #
226
+ # @param other [Object] object to compare
227
+ # @return [Boolean] whether `other` is a CID with the same value
228
+
41
229
  def ==(other)
42
- other.is_a?(CID) && @data == other.data
230
+ return false unless other.is_a?(CID)
231
+
232
+ if @cbor_form && (other_cbor = other.instance_variable_get('@cbor_form'))
233
+ @cbor_form == other_cbor
234
+ elsif @json_form && (other_json = other.instance_variable_get('@json_form'))
235
+ @json_form == other_json
236
+ else
237
+ self.cbor_form == other.cbor_form
238
+ end
239
+ end
240
+
241
+ alias eql? ==
242
+
243
+ # Returns a hash code for the purposes of a {Set} or {Hash}.
244
+ #
245
+ # The binary CBOR form of the CID is used to derive the hash.
246
+ #
247
+ # @return [Integer] hash code generated from the CID's binary form
248
+
249
+ def hash
250
+ cbor_form.hash
251
+ end
252
+
253
+
254
+ private
255
+
256
+ def validate_json_form(data, expected_codec)
257
+ raise DecodeError.new("Unexpected CID length") unless data.bytesize == 59
258
+ raise DecodeError.new("Unexpected CID prefix") unless data.getbyte(0) == 98 # 'b'
259
+
260
+ offset = 1
261
+
262
+ while offset < 59
263
+ byte = data.getbyte(offset)
264
+ raise DecodeError.new("Unexpected characters in CID") unless (byte >= 97 && byte <= 122) || (byte >= 50 && byte <= 55)
265
+ offset += 1
266
+ end
267
+
268
+ codec = if data.start_with?(DRISL_JSON_PREFIX)
269
+ DRISL_CODEC_ID
270
+ elsif data.start_with?(RAW_JSON_PREFIX)
271
+ RAW_CODEC_ID
272
+ else
273
+ raise UnsupportedError.new("Unexpected CID prefix")
274
+ end
275
+
276
+ if expected_codec && codec != expected_codec
277
+ raise UnsupportedError.new("Unexpected CID codec: #{codec}")
278
+ end
279
+
280
+ # We're checking for existence of DRISL_BINARY_PREFIX / RAW_BINARY_PREFIX at the beginning,
281
+ # but we want to avoid encoding each JSON CID to binary immediately just to check that.
282
+ #
283
+ # So instead we're looking for the equivalent in JSON encoding - 'bafyrei...' or 'bafkrei...'.
284
+ # But the last bits of the 4th byte (24...32) are encoded into two bits of the 7th character
285
+ # in the base32 version (after the 'b' prefix), so the one after 'i'. So here we check if the
286
+ # 8th character of the string is in the expected range that has such two first bits.
287
+
288
+ char8 = data.getbyte(7)
289
+ raise UnsupportedError.new("Unexpected CID prefix") unless char8 >= 97 && char8 <= 104 # 'a'..'h'
290
+
291
+ # And the final character needs to have last two bits set to 0:
292
+
293
+ trailing_byte = data.getbyte(58)
294
+ position = (trailing_byte >= 97 && trailing_byte <= 122) ? (trailing_byte - 97) : (trailing_byte - 50 + 26)
295
+ canonical_trailing_bits = (position & 3) == 0
296
+
297
+ raise DecodeError.new("Unexpected CID trailing bits") unless canonical_trailing_bits
298
+ end
299
+
300
+ def validate_binary_form(data, cbor_prefix, expected_codec)
301
+ expected_length = cbor_prefix ? 37 : 36
302
+ data_length = data.bytesize
303
+
304
+ raise DecodeError.new("CID too short: #{data}") if data_length < expected_length
305
+ raise DecodeError.new("CID too long: #{data}") if data_length > expected_length
306
+
307
+ if cbor_prefix
308
+ raise DecodeError.new("Unexpected first byte of CID: #{data[0]}") unless data.getbyte(0) == 0
309
+
310
+ return if data.start_with?(TAG_DRISL_BINARY_PREFIX) && (expected_codec.nil? || expected_codec == DRISL_CODEC_ID)
311
+ return if data.start_with?(TAG_RAW_BINARY_PREFIX) && (expected_codec.nil? || expected_codec == RAW_CODEC_ID)
312
+ else
313
+ return if data.start_with?(DRISL_BINARY_PREFIX) && (expected_codec.nil? || expected_codec == DRISL_CODEC_ID)
314
+ return if data.start_with?(RAW_BINARY_PREFIX) && (expected_codec.nil? || expected_codec == RAW_CODEC_ID)
315
+ end
316
+
317
+ # At this point we're rejecting the CID, we just check with what error message:
318
+
319
+ buffer = StringIO.new(data)
320
+ buffer.pos = 1 if cbor_prefix
321
+
322
+ version = buffer.read_varint
323
+ raise UnsupportedError.new("Unexpected CID version: #{version}") unless version == 1
324
+
325
+ codec = buffer.read_varint
326
+ supported_codec = expected_codec ? (codec == expected_codec) : (codec == DRISL_CODEC_ID || codec == RAW_CODEC_ID)
327
+ raise UnsupportedError.new("Unexpected CID codec: #{codec}") unless supported_codec
328
+
329
+ hash = buffer.read_varint
330
+ raise UnsupportedError.new("Unexpected CID hash: #{hash}") unless hash == 0x12
331
+
332
+ length = buffer.read_varint
333
+ raise UnsupportedError.new("Unexpected CID length: #{length}") unless length == 32
334
+
335
+ raise UnsupportedError.new("Non-canonical CID prefix")
43
336
  end
44
337
  end
45
338
  end
@@ -2,6 +2,7 @@
2
2
 
3
3
  require 'cbor'
4
4
  require 'stringio'
5
+ require_relative 'errors'
5
6
 
6
7
  module Oxygene
7
8
 
@@ -22,13 +23,23 @@ module Oxygene
22
23
  end
23
24
 
24
25
  value
26
+ rescue EOFError
27
+ raise DecodeError, "Unexpected end of data while reading varint"
25
28
  end
26
29
  end
27
30
 
28
31
  refine CBOR.singleton_class do
29
32
  def decode_sequence(data)
30
- unpacker = CBOR::Unpacker.new(StringIO.new(data))
31
- unpacker.each.to_a
33
+ unpacker = CBOR::Unpacker.new
34
+ unpacker.feed(data)
35
+
36
+ items = []
37
+
38
+ while !unpacker.buffer.empty?
39
+ items << unpacker.read
40
+ end
41
+
42
+ items
32
43
  end
33
44
  end
34
45
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Oxygene
4
- VERSION = '0.0.1'
4
+ VERSION = '0.1.0'
5
5
  end
data/lib/oxygene.rb CHANGED
@@ -1,8 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'oxygene/base32'
3
4
  require_relative 'oxygene/car_archive'
5
+ require_relative 'oxygene/car_repo'
4
6
  require_relative 'oxygene/cid'
5
7
  require_relative 'oxygene/version'
6
8
 
9
+ #
10
+ # Various data decoding related primitives for working with AT Protocol repositories
11
+ # and firehose events.
12
+ #
13
+ # This gem was extracted from [Skyfall](https://ruby.sdk.blue/skyfall/) and is mostly
14
+ # used there for the CBOR firehose decoding, but can also be used standalone for other
15
+ # ATProto data handling purposes, like decoding downloaded .car account repos.
16
+ #
17
+ # The functionality currently includes: Base32 encoder/decoder, CAR archive/repo parser,
18
+ # CID wrapper, and CBOR decoding (through the `cbor` gem for now).
19
+ #
20
+ # The gem intentionally only includes support for the parts of these standards that are
21
+ # in use in ATProto, i.e. doesn't handle all possible types of CIDs defined in the CID
22
+ # standard, only generates lowercase Base32, and so on.
23
+ #
24
+
7
25
  module Oxygene
8
26
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: oxygene
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.0.1
4
+ version: 0.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kuba Suder
@@ -9,20 +9,6 @@ bindir: bin
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
12
- - !ruby/object:Gem::Dependency
13
- name: base32
14
- requirement: !ruby/object:Gem::Requirement
15
- requirements:
16
- - - "~>"
17
- - !ruby/object:Gem::Version
18
- version: '0.3'
19
- type: :runtime
20
- prerelease: false
21
- version_requirements: !ruby/object:Gem::Requirement
22
- requirements:
23
- - - "~>"
24
- - !ruby/object:Gem::Version
25
- version: '0.3'
26
12
  - !ruby/object:Gem::Dependency
27
13
  name: base64
28
14
  requirement: !ruby/object:Gem::Requirement
@@ -57,11 +43,15 @@ executables: []
57
43
  extensions: []
58
44
  extra_rdoc_files: []
59
45
  files:
46
+ - AGENTS.md
60
47
  - CHANGELOG.md
61
48
  - LICENSE.txt
62
49
  - README.md
63
50
  - lib/oxygene.rb
51
+ - lib/oxygene/base32.rb
64
52
  - lib/oxygene/car_archive.rb
53
+ - lib/oxygene/car_repo.rb
54
+ - lib/oxygene/car_section.rb
65
55
  - lib/oxygene/cid.rb
66
56
  - lib/oxygene/errors.rb
67
57
  - lib/oxygene/extensions.rb