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.
@@ -1,5 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative 'car_section'
3
4
  require_relative 'cid'
4
5
  require_relative 'errors'
5
6
  require_relative 'extensions'
@@ -8,60 +9,128 @@ require 'base64'
8
9
  require 'cbor'
9
10
  require 'stringio'
10
11
 
11
- # CAR v1: https://ipld.io/specs/transport/car/carv1/
12
- # multicodec codes: https://github.com/multiformats/multicodec/blob/master/table.csv
13
-
14
12
  module Oxygene
15
- class CARSection
16
- attr_reader :cid
17
13
 
18
- def initialize(cid, body_data)
19
- @cid = cid
20
- @body_data = body_data
21
- end
22
-
23
- def body
24
- @body ||= CARArchive.convert_data(CBOR.decode(@body_data))
25
- end
26
- end
14
+ #
15
+ # Parses a Content Addressable Archive (CAR) bundle loaded e.g. from an ATProto firehose
16
+ # message or from a repo .car exported from a PDS. The header part is decoded immediately,
17
+ # while the subsequent body/data sections are lazily decoded only as requested.
18
+ #
19
+ # Only CAR version v1 is supported, and only limited to the features or variants used in ATProto
20
+ # (e.g. CIDs only in the v1 version as defined in [DASL](https://dasl.ing)).
21
+ #
22
+ # Related specifications:
23
+ #
24
+ # - [IPLD CAR v1 spec](https://ipld.io/specs/transport/car/carv1/)
25
+ # - [DASL CAR](https://dasl.ing/car.html)
26
+ # - [ATProto CAR file serialization](https://atproto.com/specs/repository#car-file-serialization)
27
+ # - [ATProto data model](https://atproto.com/specs/data-model)
28
+ # - [multicodec](https://github.com/multiformats/multicodec),
29
+ # [multihash](https://github.com/multiformats/multihash)
30
+ # and [multibase](https://github.com/multiformats/multibase)
31
+ #
27
32
 
28
33
  class CARArchive
29
34
  using Oxygene::Extensions
30
35
 
31
- attr_reader :roots, :sections
36
+ # @return [Array<CID>] array of root CIDs listed in the archive header
37
+ attr_reader :roots
38
+
32
39
 
40
+ # Creates a CAR archive reader from an in-memory CAR file.
41
+ #
42
+ # @param data [String] CAR archive file as a binary string
43
+ # @raise [DecodeError] if the archive header has missing or invalid fields
44
+ # @raise [UnsupportedError] if the archive uses an unsupported CAR version
45
+ #
33
46
  def initialize(data)
34
47
  @sections = []
48
+ @section_map = {}
35
49
  @buffer = StringIO.new(data)
50
+ @map_needs_update = false
36
51
 
37
52
  read_header(@buffer)
38
53
  end
39
54
 
40
- def section_with_cid(cid)
41
- if section = @sections.detect { |s| s.cid == cid }
42
- return section.body
55
+ # Looks up a section with a given CID in the archive, decoding sections as needed.
56
+ #
57
+ # Sections are parsed lazily – if the requested section has already been loaded,
58
+ # it's returned without parsing any more parts of the archive, otherwise unread
59
+ # sections are parsed only until a match is found.
60
+ #
61
+ # When making repeated lookups for sections in one archive, e.g. when walking
62
+ # the MST tree of a CAR repo, pass the `use_map: true` option, which tells
63
+ # `CARArchive` to build and use an index that maps CIDs to sections for quicker lookup
64
+ # (otherwise, parsed sections are searched sequentially). For performance, this isn't
65
+ # enabled by default, since the common case of processing firehose commit messages only
66
+ # does a single lookup to find the record data.
67
+ #
68
+ # The CID may be passed as either an {Oxygene::CID} object, or its {CID#cbor_form} string.
69
+ #
70
+ # @param cid [CID, String] a CID object or its binary CBOR representation, including the leading null byte
71
+ # @param use_map [Boolean] whether to use and update the lookup index that maps CIDs to sections
72
+ # @param return_body [Boolean] whether to return the section's JSON body directly instead of an {Oxygene::CARSection} object
73
+ #
74
+ # @return [Hash, Array, CARSection, nil] the requested section or its decoded body converted to ATProto JSON, or nil if not found
75
+ # @raise [DecodeError] if a section is truncated or malformed
76
+ # @raise [UnsupportedError] if a section uses an unsupported CID encoding
77
+
78
+ def section_with_cid(cid, use_map: false, return_body: true)
79
+ if found_section = find_parsed_section(cid, use_map)
80
+ return (return_body ? found_section.json_body : found_section)
43
81
  end
44
82
 
45
- if @buffer
46
- while !@buffer.eof?
47
- section = read_section(@buffer)
48
- return section.body if section.cid == cid
49
- end
83
+ if found_section = parse_sections_until_match(cid, use_map)
84
+ return (return_body ? found_section.json_body : found_section)
50
85
  end
51
86
 
52
- @buffer = nil
53
87
  nil
54
88
  end
55
89
 
90
+ # Returns the list of archive sections that have been parsed so far,
91
+ # without parsing any more sections.
92
+ #
93
+ # @return [Array<CARSection>] sections parsed so far, in archive order
94
+
95
+ def parsed_sections
96
+ @sections.dup.freeze
97
+ end
98
+
99
+ # Parses all sections in the archive if they haven't been loaded yet, and
100
+ # returns the list of all sections in the order as listed in the archive.
101
+ #
102
+ # Once all sections have been read, the original archive data buffer is
103
+ # released so it can be garbage-collected.
104
+ #
105
+ # @return [Array<CARSection>] all sections in the archive
106
+ # @raise [DecodeError] if a section is truncated or malformed
107
+ # @raise [UnsupportedError] if a section uses an unsupported CID encoding
108
+
56
109
  def sections
57
110
  if @buffer
58
- read_section(@buffer) while !@buffer.eof?
111
+ if !@buffer.eof?
112
+ read_section(@buffer) while !@buffer.eof?
113
+ @map_needs_update = true
114
+ end
115
+
59
116
  @buffer = nil
60
117
  end
61
118
 
62
119
  @sections
63
120
  end
64
121
 
122
+ # @api private
123
+ #
124
+ # Converts decoded CBOR values to their ATProto-specific JSON representation.
125
+ #
126
+ # The passed hash or array is converted recursively in place. The conversion involves:
127
+ # - replacing binary strings with `$bytes` objects with Base64-encoded data
128
+ # - converting CBOR CID tags to `$link` objects holding an {Oxygene::CID}
129
+ #
130
+ # @param object [Hash, Array] decoded CBOR object to convert
131
+ # @return [Hash, Array] the same object, updated in place
132
+ # @raise [DecodeError] if the top-level value is not a hash or array
133
+
65
134
  def self.convert_data(object)
66
135
  if object.is_a?(Hash)
67
136
  object.each do |k, v|
@@ -88,16 +157,39 @@ module Oxygene
88
157
  end
89
158
  end
90
159
 
160
+ # @api private
161
+ #
162
+ # Converts a CBOR CID tag to an ATProto JSON
163
+ # [$link object](https://atproto.com/specs/data-model#json-representation).
164
+ #
165
+ # @param cid [CBOR::Tagged] CBOR tag containing the CID binary data
166
+ # @return [Hash{String => CID}] a `$link` JSON object
167
+ # @raise [DecodeError] if the value is not a supported CID
168
+
91
169
  def self.make_cid_link(cid)
92
170
  { '$link' => CID.from_cbor_tag(cid) }
93
171
  end
94
172
 
173
+ # @api private
174
+ #
175
+ # Converts a binary string to an ATProto JSON
176
+ # [$bytes object](https://atproto.com/specs/data-model#json-representation).
177
+ #
178
+ # @param data [String] binary data to encode
179
+ # @return [Hash{String => String}] a `$bytes` JSON object
180
+
95
181
  def self.make_bytes(data)
96
- { '$bytes' => Base64.encode64(data).chomp.gsub(/=+$/, '') }
182
+ string = Base64.strict_encode64(data)
183
+ string.chomp!('=') while string.getbyte(-1) == 61
184
+
185
+ { '$bytes' => string }
97
186
  end
98
187
 
188
+ # Returns a string with a representation of the object for debugging purposes.
189
+ # @return [String]
190
+
99
191
  def inspect
100
- vars = instance_variables.map { |v|
192
+ vars = (instance_variables - [:@section_map]).map { |v|
101
193
  if v == :@sections && @buffer
102
194
  "#{v}=[...]"
103
195
  else
@@ -108,50 +200,88 @@ module Oxygene
108
200
  "#<#{self.class}:0x#{object_id} #{vars.join(", ")}>"
109
201
  end
110
202
 
203
+
111
204
  private
112
205
 
206
+ def find_parsed_section(cid, use_map)
207
+ if use_map
208
+ if @map_needs_update
209
+ @sections.each { |s| @section_map[s.cid.cbor_form] ||= s }
210
+ @map_needs_update = false
211
+ end
212
+
213
+ key = cid.is_a?(CID) ? cid.cbor_form : cid
214
+ @section_map[key]
215
+ else
216
+ if cid.is_a?(CID)
217
+ @sections.detect { |s| s.cid == cid }
218
+ else
219
+ @sections.detect { |s| s.cid.cbor_form == cid }
220
+ end
221
+ end
222
+ end
223
+
224
+ def parse_sections_until_match(cid, use_map)
225
+ return if @buffer.nil?
226
+
227
+ is_cid = cid.is_a?(CID)
228
+
229
+ while !@buffer.eof?
230
+ section = read_section(@buffer)
231
+
232
+ if use_map
233
+ @section_map[section.cid.cbor_form] = section
234
+ else
235
+ @map_needs_update = true
236
+ end
237
+
238
+ match = is_cid ? section.cid == cid : section.cid.cbor_form == cid
239
+ return section if match
240
+ end
241
+
242
+ @buffer = nil
243
+ end
244
+
113
245
  def read_header(buffer)
114
246
  len = buffer.read_varint
247
+ raise DecodeError.new("Header length cannot be 0") if len == 0
115
248
 
116
249
  header_data = buffer.read(len)
117
250
  raise DecodeError.new("Header too short: #{header_data}") unless header_data.length == len
118
251
 
119
252
  header = CBOR.decode(header_data)
253
+ raise DecodeError.new("Metadata object should be a hash") unless header.is_a?(Hash)
120
254
  raise UnsupportedError.new("Unexpected CAR version: #{header['version']}") unless header['version'] == 1
121
- @roots = header['roots'].map { |x| CID.from_cbor_tag(x) }
255
+
256
+ roots = header['roots']
257
+ raise DecodeError.new("Missing 'roots' field") if roots.nil?
258
+ raise DecodeError.new("Invalid 'roots' field: #{roots.inspect}") unless roots.is_a?(Array)
259
+
260
+ @roots = header['roots'].map { |x|
261
+ if x.is_a?(CBOR::Tagged) && x.tag == 42
262
+ CID.from_cbor_tag(x)
263
+ else
264
+ raise DecodeError.new("Unexpected value in the roots array: #{x.inspect}")
265
+ end
266
+ }.freeze
122
267
  end
123
268
 
124
269
  def read_section(buffer)
125
270
  len = buffer.read_varint
126
271
 
272
+ # TODO: verify content CIDs
127
273
  section_data = buffer.read(len)
128
274
  raise DecodeError.new("Section too short: #{section_data}") unless section_data.length == len
129
275
 
130
- sbuffer = StringIO.new(section_data)
131
-
132
- version = sbuffer.read_varint
133
- raise UnsupportedError.new("Unexpected CID version: #{version}") unless version == 1
134
-
135
- codec = sbuffer.read_varint
136
- raise UnsupportedError.new("Unexpected CID codec: #{codec}") unless codec == 0x71 # dag-cbor
276
+ cid_data = section_data.byteslice(0, 36)
277
+ body_data = section_data.byteslice(36..)
278
+ cid_data.prepend(CID::CBOR_TAG_PREFIX)
279
+ cid = CID.new(cid_data, binary: true, cbor_prefix: true, codec: :drisl)
137
280
 
138
- hash = sbuffer.read_varint
139
- raise UnsupportedError.new("Unexpected CID hash: #{hash}") unless hash == 0x12 # sha2-256
140
-
141
- clen = sbuffer.read_varint
142
- raise UnsupportedError.new("Unexpected CID length: #{clen}") unless clen == 32
143
-
144
- prefix = section_data[0...sbuffer.pos]
145
-
146
- cid_data = sbuffer.read(clen)
147
- raise DecodeError.new("CID too short: #{cid_data}") unless cid_data.length == clen
148
-
149
- cid = CID.new(prefix + cid_data)
150
-
151
- body_data = sbuffer.read
152
281
  new_section = CARSection.new(cid, body_data)
153
282
 
154
283
  @sections << new_section
284
+
155
285
  new_section
156
286
  end
157
287
  end
@@ -0,0 +1,121 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'car_archive'
4
+ require_relative 'errors'
5
+
6
+ module Oxygene
7
+
8
+ #
9
+ # A CAR archive containing an account's ATProto repository, as returned by the
10
+ # `com.atproto.sync.getRepo` PDS endpoint.
11
+ #
12
+ # Oxygene currently supports repositories in the v3 format version.
13
+ #
14
+ # Related specifications:
15
+ # - [ATProto repository](https://atproto.com/specs/repository)
16
+ #
17
+
18
+ class CARRepo < CARArchive
19
+
20
+ # Returns the CAR archive section containing the repository's root commit.
21
+ # @return [CARSection] section identified by the first CAR root CID
22
+ attr_reader :commit_section
23
+
24
+ # Creates an ATProto repository reader from an in-memory CAR file.
25
+ #
26
+ # @param data [String] CAR archive file as a binary string
27
+ # @raise [DecodeError] if the CAR header has missing or invalid fields, the root commit is missing, or a section has invalid data
28
+ # @raise [UnsupportedError] if the archive, section CID or repository is in an unsupported version
29
+ #
30
+ def initialize(data)
31
+ super
32
+
33
+ raise DecodeError, "CAR repository has no root commit" if roots.empty?
34
+
35
+ @commit_section = section_with_cid(roots.first, use_map: true, return_body: false)
36
+ raise DecodeError, "Root commit not found in the archive: #{roots.first.inspect}" if @commit_section.nil?
37
+
38
+ commit_body = @commit_section.decoded_body
39
+ raise DecodeError, "Commit object should be a hash" unless commit_body.is_a?(Hash)
40
+
41
+ repo_version = commit_body['version']
42
+ raise UnsupportedError, "Unexpected repository version: #{repo_version.inspect}" unless repo_version == 3
43
+ end
44
+
45
+ # Returns the repository commit data.
46
+ #
47
+ # The commit is decoded from the body of the {#commit_section}. The data is
48
+ # returned in the ATProto JSON representation – CID links and binary strings
49
+ # are represented using `$link` and `$bytes` objects respectively. Use
50
+ # {#commit_section} to access the original CBOR bytes or decoded CBOR values
51
+ # without conversion.
52
+ #
53
+ # See the [ATProto repository spec](https://atproto.com/specs/repository#commit-objects)
54
+ # for what fields the commit object is expected to contain.
55
+ #
56
+ # @return [Hash] root commit data
57
+
58
+ def commit
59
+ commit_section.json_body
60
+ end
61
+
62
+ # Walks through the repository MST tree, running the passed block for each record
63
+ # in the repo in alphabetical key order.
64
+ #
65
+ # Records are stored in the Merkle Search Tree structure in nodes with assigned keys,
66
+ # where each key is a record path (NSID collection + rkey, e.g.
67
+ # `app.bsky.feed.post/3juhznhw65225`). The iterator returns all records one by one
68
+ # sorted alphabetically, so the list is sorted by collection first and then by rkey
69
+ # within a collection. The callback is passed the path key and the {CID} of the section
70
+ # which contains the actual record data, The CID can be used to extract the data
71
+ # through a call to {#section_with_cid} (you almost certainly want to pass
72
+ # `use_map: true`).
73
+ #
74
+ # Normally you can skip the `starting_node_cid` parameter, in which case the tree
75
+ # traversal begins at the repository root referenced by the CAR header and covers the
76
+ # entire tree. If a starting CID is passed, the traversal will start from a given node
77
+ # covering only a subtree.
78
+ #
79
+ # @param starting_node_cid [CID, String, nil] CID of the tree node to start from, or `nil` to start at the repository root
80
+ #
81
+ # @yield [key, cid] path key of a given record and the CID of its value block
82
+ # @yieldparam key [String] record path key, i.e. collection + / + rkey
83
+ # @yieldparam cid [CID] content identifier of the section containing the record value
84
+ # @raise [DecodeError] if a requested section is missing, a section is truncated or malformed, or a CID is invalid
85
+ # @raise [UnsupportedError] if a section uses an unsupported CID encoding
86
+
87
+ def walk_all_nodes(starting_node_cid = nil, &block)
88
+ if starting_node_cid.nil?
89
+ commit = commit_section.decoded_body
90
+ tree_top_cid = commit['data'].value
91
+ return walk_all_nodes(tree_top_cid, &block)
92
+ end
93
+
94
+ data = section_with_cid(starting_node_cid, use_map: true, return_body: false)&.decoded_body
95
+ raise DecodeError, "MST node not found in the archive: #{starting_node_cid.inspect}" if data.nil?
96
+
97
+ if data['l']
98
+ walk_all_nodes(data['l'].value, &block)
99
+ end
100
+
101
+ previous = nil
102
+
103
+ (data['e'] || []).each do |e|
104
+ if previous
105
+ key = previous[0...e['p']]
106
+ key << e['k']
107
+ else
108
+ key = e['k']
109
+ end
110
+
111
+ previous = key
112
+
113
+ block.call(key, CID.from_cbor_tag(e['v']))
114
+
115
+ if e['t']
116
+ walk_all_nodes(e['t'].value, &block)
117
+ end
118
+ end
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'car_archive'
4
+ require 'cbor'
5
+
6
+ module Oxygene
7
+
8
+ #
9
+ # Represents a single CID-addressed block from a {CARArchive}.
10
+ #
11
+
12
+ class CARSection
13
+
14
+ # @return [CID] CID of this section
15
+ attr_reader :cid
16
+
17
+ # @return [String] raw CBOR body binary data
18
+ attr_reader :data
19
+
20
+ # Creates a section from its CID and encoded body.
21
+ #
22
+ # @param cid [CID] content identifier of the section
23
+ # @param data [String] raw CBOR body data
24
+ #
25
+ def initialize(cid, data)
26
+ @cid = cid
27
+ @data = data
28
+ end
29
+
30
+ # Decodes the body from CBOR, without performing ATProto JSON specific conversions.
31
+ #
32
+ # The result is decoded once and memoized. CID links are represented as {CBOR::Tagged}
33
+ # objects, and byte strings remain binary strings.
34
+ #
35
+ # @return [Object] section data decoded from CBOR
36
+
37
+ def decoded_body
38
+ @decoded_body ||= CBOR.decode(@data)
39
+ end
40
+
41
+ # Decodes the body and converts it to an ATProto JSON compatible format.
42
+ #
43
+ # The conversion involves:
44
+ # - replacing binary strings with `$bytes` objects with Base64-encoded data
45
+ # - converting CBOR CID tags to `$link` objects holding an {Oxygene::CID}
46
+ #
47
+ # The result is decoded and stored separately from {#decoded_body}, so calling this
48
+ # method does not mutate the value returned by {#decoded_body}.
49
+ #
50
+ # @return [Hash, Array] decoded ATProto JSON compatible body
51
+ # @raise [DecodeError] if the decoded top-level value is not a hash or array
52
+
53
+ def json_body
54
+ @json_body ||= CARArchive.convert_data(CBOR.decode(@data))
55
+ end
56
+
57
+ alias body json_body
58
+ end
59
+ end