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
data/lib/oxygene/car_archive.rb
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|