iodef 0.1.0 → 0.2.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,123 +1,22 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Iodef
4
- # Bounds allocations and rejects duplicate keys before the native decoder builds objects.
4
+ # Validate the wire representation and resource bounds while decoding it once.
5
5
  class CBORGuard
6
6
  def initialize(bytes, limits)
7
7
  @bytes = bytes
8
8
  @limits = limits
9
- @offset = 0
10
- @nodes = 0
11
- @preserved_tags = []
12
9
  end
13
10
 
14
11
  def validate!
15
- item(0)
16
- raise ParseError, "trailing CBOR input" unless @offset == @bytes.bytesize
17
-
18
- # Preserve URI spelling and avoid backend-specific bignum conversion.
19
- @preserved_tags.reverse_each.with_object(@bytes.dup) do |(offset, size, tag), bytes|
20
- bytes[offset, size] = [0xd8, tag].pack("C*")
21
- end
22
- end
23
-
24
- private
25
-
26
- def byte
27
- result = @bytes.getbyte(@offset)
28
- raise ParseError, "truncated CBOR input" unless result
29
-
30
- @offset += 1
31
- result
32
- end
33
-
34
- def take(length)
35
- raise ParseError, "truncated CBOR input" if length > @bytes.bytesize - @offset
36
-
37
- start = @offset
38
- @offset += length
39
- @bytes.byteslice(start, length)
40
- end
41
-
42
- def argument(info)
43
- return info if info < 24
44
- return nil if info == 31
45
-
46
- width = { 24 => 1, 25 => 2, 26 => 4, 27 => 8 }[info]
47
- raise ParseError, "reserved CBOR additional information" unless width
48
-
49
- take(width).unpack1({ 1 => "C", 2 => "n", 4 => "N", 8 => "Q>" }.fetch(width))
50
- end
51
-
52
- def item(depth)
53
- @nodes += 1
54
- raise LimitError, "CBOR exceeds max_nodes" if @nodes > @limits[:max_nodes]
55
- raise LimitError, "CBOR exceeds max_depth" if depth > @limits[:max_depth]
56
-
57
- start = @offset
58
- initial = byte
59
- major = initial >> 5
60
- info = initial & 31
61
- length = argument(info)
62
- case major
63
- when 0, 1
64
- raise ParseError, "indefinite CBOR integer" unless length
65
- when 2, 3 then string(major, length, depth)
66
- when 4, 5 then collection(major, length, depth)
67
- when 6
68
- raise ParseError, "indefinite CBOR tag" unless length
69
- raise ConversionError, "unsupported CBOR tag" unless [0, 2, 3, 22, 23, 32].include?(length)
70
-
71
- @preserved_tags << [start, @offset - start, { 2 => 253, 3 => 254, 32 => 255 }.fetch(length)] if [2, 3, 32].include?(length)
72
- item(depth + 1)
73
- when 7
74
- raise ParseError, "unexpected CBOR break" if info == 31
75
- end
76
- end
77
-
78
- def string(major, length, depth)
79
- if length
80
- take(length)
81
- raise LimitError, "binary exceeds max_binary_size" if major == 2 && length > @limits[:max_binary_size]
82
-
83
- length
84
- else
85
- total = 0
86
- until @bytes.getbyte(@offset) == 255
87
- initial = @bytes.getbyte(@offset)
88
- raise ParseError, "invalid indefinite CBOR string chunk" unless initial && (initial >> 5) == major && (initial & 31) < 28
89
-
90
- total += item(depth + 1)
91
- raise LimitError, "binary exceeds max_binary_size" if major == 2 && total > @limits[:max_binary_size]
92
- end
93
- byte
94
- total
95
- end
96
- end
97
-
98
- def collection(major, length, depth)
99
- raise LimitError, "CBOR exceeds max_collection_size" if length && length > @limits[:max_collection_size]
100
-
101
- count = 0
102
- keys = {}
103
- until length ? count == length : @bytes.getbyte(@offset) == 255
104
- count += 1
105
- raise LimitError, "CBOR exceeds max_collection_size" if count > @limits[:max_collection_size]
106
-
107
- if major == 5
108
- start = @offset
109
- initial = @bytes.getbyte(start)
110
- raise ConversionError, "CBOR map keys must be text or integers" unless initial && [0, 1, 3].include?(initial >> 5)
111
-
112
- item(depth + 1)
113
- key = CBOR.decode(@bytes.byteslice(start, @offset - start))
114
- raise ParseError, "duplicate CBOR map key" if keys.key?(key)
115
-
116
- keys[key] = true
117
- end
118
- item(depth + 1)
119
- end
120
- byte unless length
12
+ BinaryCBOR.decode(@bytes, **@limits.slice(:max_input_size, :max_depth, :max_nodes, :max_collection_size, :max_binary_size),
13
+ max_string_size: @limits[:max_input_size], allowed_tags: [0, 2, 3, 22, 23, 32])
14
+ rescue BinaryCBOR::LimitError => e
15
+ raise LimitError, e.message
16
+ rescue BinaryCBOR::UnsupportedError => e
17
+ raise ConversionError, e.message
18
+ rescue BinaryCBOR::Error => e
19
+ raise ParseError, e.message
121
20
  end
122
21
  end
123
22
  end
data/lib/iodef/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Iodef
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -0,0 +1,363 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Iodef
4
+ class XMLCodec
5
+ NS = "urn:ietf:params:xml:ns:iodef-2.0"
6
+ SCI = "urn:ietf:params:xml:ns:iodef-sci-1.0"
7
+ ENUM = "urn:ietf:params:xml:ns:iodef-enum-1.0"
8
+ DS = "http://www.w3.org/2000/09/xmldsig#"
9
+ XSI = "http://www.w3.org/2001/XMLSchema-instance"
10
+ TEXT = { "IncidentID" => "id", "IndicatorID" => "id", "RegistryHandle" => "handle",
11
+ "Address" => "value", "Counter" => "value", "TimeImpact" => "value", "MonetaryImpact" => "value",
12
+ "Confidence" => "value", "RecordPattern" => "value", "MLString" => "value",
13
+ "Extension" => "value", "SoftwareReference" => "value" }.freeze
14
+ WRAPPERS = { "Incident" => { "Indicator" => "IndicatorData" }, "EventData" => { "System" => "Flow", "RecordData" => "Record" },
15
+ "Service" => { "ApplicationHeaderField" => "ApplicationHeader" },
16
+ "EmailData" => { "Signature" => "SignatureData" }, "File" => { "Signature" => "SignatureData" } }.freeze
17
+ IMPACTS = %w[SystemImpact BusinessImpact TimeImpact MonetaryImpact IntendedImpact].freeze
18
+ OPAQUE = %w[Signature X509Data DigestMethod CanonicalizationMethod].freeze
19
+ STRUCTURED = %w[StructuredInformation Platform Scoring].freeze
20
+ # XML has ordered children; JSON property insertion order carries no such meaning.
21
+ ORDER = {
22
+ "Document" => %w[Incident AdditionalData],
23
+ "Incident" => %w[IncidentID AlternativeID RelatedActivity DetectTime StartTime EndTime RecoveryTime ReportTime GenerationTime Description Discovery Assessment Method Contact EventData Indicator History AdditionalData],
24
+ "RelatedActivity" => %w[IncidentID URL ThreatActor Campaign IndicatorID Confidence Description AdditionalData],
25
+ "ThreatActor" => %w[ThreatActorID URL Description AdditionalData], "Campaign" => %w[CampaignID URL Description AdditionalData],
26
+ "Contact" => %w[ContactName ContactTitle Description RegistryHandle PostalAddress Email Telephone Timezone Contact AdditionalData],
27
+ "PostalAddress" => %w[PAddress Description], "Email" => %w[EmailTo Description], "Telephone" => %w[TelephoneNumber Description],
28
+ "DetectionPattern" => %w[Application Description DetectionConfiguration], "Method" => %w[Reference Description AttackPattern Vulnerability Weakness AdditionalData],
29
+ "Reference" => %w[ReferenceName URL Description], "Assessment" => %w[IncidentCategory Impact Counter MitigatingFactor Cause Confidence AdditionalData],
30
+ "HistoryItem" => %w[DateTime IncidentID Contact Description DefinedCOA AdditionalData],
31
+ "EventData" => %w[Description DetectTime StartTime EndTime RecoveryTime ReportTime Contact Discovery Assessment Method System Expectation RecordData EventData AdditionalData],
32
+ "Expectation" => %w[Description DefinedCOA StartTime EndTime Contact],
33
+ "System" => %w[Node NodeRole Service OperatingSystem Counter AssetID Description AdditionalData],
34
+ "Service" => %w[ServiceName Port Portlist ProtoType ProtoCode ProtoField ApplicationHeaderField EmailData Application],
35
+ "ServiceName" => %w[IANAService URL Description],
36
+ "RecordData" => %w[DateTime Description Application RecordPattern RecordItem URL FileData WindowsRegistryKeysModified CertificateData AdditionalData],
37
+ "Certificate" => %w[X509Data Description], "File" => %w[FileName FileSize FileType URL HashData Signature AssociatedSoftware FileProperties],
38
+ "FuzzyHash" => %w[FuzzyHashValue Application AdditionalData],
39
+ "Indicator" => %w[IndicatorID AlternativeIndicatorID Description StartTime EndTime Confidence Contact Observable uid-ref IndicatorExpression IndicatorReference NodeRole AttackPhase Reference AdditionalData],
40
+ "BulkObservable" => %w[BulkObservableFormat BulkObservableList AdditionalData], "BulkObservableFormat" => %w[Hash AdditionalData],
41
+ "IndicatorExpression" => %w[IndicatorExpression Observable uid-ref IndicatorReference Confidence AdditionalData],
42
+ "AttackPhase" => %w[AttackPhaseID URL Description AdditionalData], "Software" => %w[SoftwareReference URL Description],
43
+ "StructuredInformation" => %w[RawData Reference Platform Scoring], "Platform" => %w[RawData Reference], "Scoring" => %w[RawData Reference]
44
+ }.freeze
45
+
46
+ def initialize(limits)
47
+ @limits = limits
48
+ @tree = XMLTree.new(limits)
49
+ end
50
+
51
+ def decode(root)
52
+ expect_name(root, NS, "IODEF-Document")
53
+ read_object(root, "Document")
54
+ end
55
+
56
+ def encode(data)
57
+ writer = XMLTree::Writer.new(@limits)
58
+ writer.append('<?xml version="1.0" encoding="UTF-8"?>')
59
+ write_object(writer, "IODEF-Document", data, "Document", nil)
60
+ output = writer.value
61
+ # The same structural limits apply to output, including embedded XML fragments.
62
+ @tree.parse(output)
63
+ output
64
+ end
65
+
66
+ private
67
+
68
+ def fields(type)
69
+ SCHEMA.fetch(type).fetch("fields")
70
+ end
71
+
72
+ def descriptor(type, field)
73
+ fields(type).fetch(field).delete_suffix("!")
74
+ end
75
+
76
+ def attribute?(type, field)
77
+ return false if TEXT[type] == field || (%w[Indicator IndicatorExpression].include?(type) && field == "uid-ref")
78
+
79
+ field.match?(/\A[a-z]/) || (STRUCTURED.include?(type) && %w[SpecID ContentID].include?(field))
80
+ end
81
+
82
+ def attributes(node)
83
+ node.attributes.reject { |key, _| key == "xmlns" || key.start_with?("xmlns:") }
84
+ end
85
+
86
+ def children(node)
87
+ node.parts.grep(XMLTree::Node)
88
+ end
89
+
90
+ def text(node)
91
+ raise ConversionError, "nested XML has no scalar JSON representation" unless children(node).empty?
92
+
93
+ node.parts.grep(String).join
94
+ end
95
+
96
+ def element_content(node)
97
+ raise ConversionError, "mixed XML content has no JSON object representation" unless node.parts.grep(String).all? { |item| item.strip.empty? }
98
+ end
99
+
100
+ def expect_name(node, uri, name)
101
+ raise ConversionError, "unsupported XML element or namespace" unless node.uri == uri && node.local == name
102
+ end
103
+
104
+ def read_object(node, type)
105
+ if node.uri == SCI && node.local == "AttackPattern" && children(node).any? { |child| child.uri == SCI && child.local == "Scoring" }
106
+ raise ConversionError, "AttackPattern cannot contain XML Scoring"
107
+ end
108
+ if STRUCTURED.include?(type) && children(node).any? { |child| child.local == "Reference" }
109
+ raise ConversionError, "SCI Reference uses an incompatible IODEF namespace"
110
+ end
111
+ data = {}
112
+ attributes(node).each do |name, value|
113
+ uri, local = @tree.expand(name, node.namespaces, attribute: true)
114
+ next if uri == XSI && %w[schemaLocation noNamespaceSchemaLocation].include?(local)
115
+
116
+ field = uri == XMLTree::XML_NS && local == "lang" ? "lang" : local
117
+ unless (uri.empty? || (uri == XMLTree::XML_NS && local == "lang")) && fields(type).key?(field) && attribute?(type, field) &&
118
+ (field != "lang" || uri == XMLTree::XML_NS)
119
+ raise ConversionError, "XML attribute has no JSON representation"
120
+ end
121
+ raise ConversionError, "duplicate mapped XML attribute" if data.key?(field)
122
+
123
+ data[field] = scalar(value, descriptor(type, field))
124
+ end
125
+ if TEXT.key?(type)
126
+ field = TEXT.fetch(type)
127
+ if %w[Extension SoftwareReference].include?(type) && data["dtype"] == "xml"
128
+ data[field] = fragment(node)
129
+ else
130
+ value = text(node)
131
+ if %w[Extension SoftwareReference].include?(type) && %w[byte file bytes frame packet ipv4-packet ipv6-packet].include?(data["dtype"])
132
+ value = value.gsub(/[\t\r\n ]/, "")
133
+ end
134
+ data[field] = scalar(value, descriptor(type, field)) unless value.empty? && !fields(type)[field].end_with?("!")
135
+ end
136
+ else
137
+ element_content(node)
138
+ children(node).each { |child| read_child(data, child, type) }
139
+ end
140
+ data["type"] ||= "unknown" if %w[SystemImpact BusinessImpact].include?(type)
141
+ data["category"] ||= "ipv6-addr" if type == "Address"
142
+ data
143
+ end
144
+
145
+ def read_child(data, child, type)
146
+ wrapper = WRAPPERS.fetch(type, {}).key(child.local) if child.uri == NS
147
+ if wrapper
148
+ raise ConversionError, "wrapper attributes cannot be flattened without loss" unless attributes(child).empty?
149
+ element_content(child)
150
+ raise ConversionError, "empty XML wrapper" if children(child).empty?
151
+
152
+ children(child).each do |nested|
153
+ expect_name(nested, namespace(type, wrapper), xml_name(type, wrapper))
154
+ add(data, wrapper, read_value(nested, descriptor(type, wrapper).delete_suffix("[]"), wrapper), type)
155
+ end
156
+ return
157
+ end
158
+ if type == "Assessment" && child.uri == NS && IMPACTS.include?(child.local)
159
+ impact_type = child.local == "IntendedImpact" ? "BusinessImpact" : child.local
160
+ add(data, "Impact", { child.local => read_object(child, impact_type) }, type)
161
+ return
162
+ end
163
+ if %w[Indicator IndicatorExpression].include?(type) && child.uri == NS && child.local == "ObservableReference"
164
+ raise ConversionError, "unsupported ObservableReference attributes" unless attributes(child).keys == ["uid-ref"]
165
+ raise ConversionError, "ObservableReference must be empty" unless text(child).strip.empty?
166
+
167
+ add(data, "uid-ref", scalar(child.attributes["uid-ref"], "id"), type)
168
+ return
169
+ end
170
+ field = fields(type).keys.find { |candidate| !attribute?(type, candidate) && xml_name(type, candidate) == child.local && namespace(type, candidate) == child.uri }
171
+ raise ConversionError, "XML child has no JSON representation" unless field && field != TEXT[type]
172
+ raise ConversionError, "XML child requires its wrapper" if WRAPPERS.fetch(type, {}).key?(field)
173
+
174
+ add(data, field, read_value(child, descriptor(type, field).delete_suffix("[]"), field), type)
175
+ end
176
+
177
+ def read_value(node, type, field)
178
+ raise ConversionError, "XML cryptographic structures have no unambiguous JSON byte mapping" if OPAQUE.include?(field)
179
+
180
+ if type == "mlstring"
181
+ data = read_object(node, "MLString")
182
+ data["lang"] ||= node.lang if node.lang
183
+ return data["value"] if (data.keys - %w[value lang]).empty? && [nil, "en"].include?(data["lang"])
184
+
185
+ data
186
+ elsif SCHEMA.key?(type)
187
+ read_object(node, type)
188
+ elsif field == "RawData"
189
+ raise ConversionError, "RawData attributes have no JSON representation" unless attributes(node) == { "dtype" => "xml" }
190
+ element_content(node)
191
+ raise ConversionError, "RawData must contain one XML document" unless children(node).size == 1
192
+
193
+ bytes = @tree.serialize(children(node).first)
194
+ raise LimitError, "RawData exceeds max_binary_size" if bytes.bytesize > @limits[:max_binary_size]
195
+
196
+ [bytes].pack("m0")
197
+ else
198
+ raise ConversionError, "scalar XML attributes have no JSON representation" unless attributes(node).empty?
199
+
200
+ scalar(text(node), type)
201
+ end
202
+ end
203
+
204
+ def add(data, field, value, type)
205
+ if descriptor(type, field).end_with?("[]")
206
+ values = data[field] ||= []
207
+ raise LimitError, "collection exceeds max_collection_size" if values.size >= @limits[:max_collection_size]
208
+
209
+ values << value
210
+ else
211
+ raise ConversionError, "repeated XML element has no singular JSON representation" if data.key?(field)
212
+
213
+ data[field] = value
214
+ end
215
+ end
216
+
217
+ def scalar(value, type)
218
+ case type
219
+ when "integer"
220
+ raise ConversionError, "invalid XML integer" unless value.strip.match?(/\A[+-]?[0-9]+\z/)
221
+
222
+ Integer(value.strip, 10)
223
+ when "number", "positive"
224
+ number = value.strip
225
+ raise ConversionError, "invalid XML number" unless number.match?(/\A[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?\z/)
226
+
227
+ result = number.match?(/\A[+-]?[0-9]+\z/) ? Integer(number, 10) : Float(number)
228
+ raise ConversionError, "non-finite XML number" if result.is_a?(Float) && !result.finite?
229
+
230
+ result
231
+ when "id", "language", "datetime", "uri", "timezone", "portlist" then value.strip.gsub(/[\t\r\n ]+/, " ")
232
+ when "binary" then value.gsub(/[\t\r\n ]/, "")
233
+ else ENUMS.key?(type) ? value.strip.gsub(/[\t\r\n ]+/, " ") : value
234
+ end
235
+ end
236
+
237
+ def fragment(node)
238
+ writer = XMLTree::Writer.new(@limits)
239
+ node.parts.each do |part|
240
+ case part
241
+ when XMLTree::Node then @tree.write_node(writer, part)
242
+ when XMLTree::Markup
243
+ writer.append(part.kind == :comment ? "<!--#{part.value}-->" : "<?#{part.value}?>")
244
+ else writer.text(part)
245
+ end
246
+ end
247
+ writer.value
248
+ end
249
+
250
+ def namespace(type, field)
251
+ return DS if OPAQUE.include?(field) || (type == "Digest" && field == "DigestValue")
252
+ return ENUM if field == "ReferenceName" || type == "ReferenceName"
253
+ return SCI if %w[AttackPattern Vulnerability Weakness Platform Scoring RawData].include?(field)
254
+
255
+ NS
256
+ end
257
+
258
+ def xml_name(type, field)
259
+ return "Nameservers" if type == "DomainData" && field == "NameServers"
260
+ return "Value" if type == "Key" && field == "KeyValue"
261
+
262
+ field
263
+ end
264
+
265
+ def write_object(writer, name, data, type, language, extra_attributes = {})
266
+ raise ConversionError, "unknown JSON property has no XML representation" unless (data.keys - fields(type).keys).empty?
267
+ raise ConversionError, "AttackPattern cannot contain XML Scoring" if name == "AttackPattern" && data.key?("Scoring")
268
+ raise ConversionError, "SCI Reference uses an incompatible IODEF namespace" if STRUCTURED.include?(type) && data.key?("Reference")
269
+
270
+ attrs = extra_attributes.dup
271
+ attrs["xmlns"] = NS if type == "Document"
272
+ data.each do |field, value|
273
+ next unless attribute?(type, field)
274
+
275
+ attrs[field == "lang" ? "xml:lang" : field] = type == "Document" && field == "version" ? "2.00" : value
276
+ end
277
+ language = data.fetch("lang", language)
278
+ writer.open(name, attrs)
279
+ if TEXT.key?(type)
280
+ value = data[TEXT.fetch(type)]
281
+ if value
282
+ if %w[Extension SoftwareReference].include?(type) && data["dtype"] == "xml"
283
+ write_fragment(writer, value)
284
+ else
285
+ writer.text(value)
286
+ end
287
+ end
288
+ else
289
+ order = ORDER.fetch(type) { fields(type).keys.reject { |field| attribute?(type, field) } }
290
+ order.each do |field|
291
+ next unless data.key?(field)
292
+
293
+ write_field(writer, field, data[field], type, language)
294
+ end
295
+ omitted = data.keys.reject { |field| attribute?(type, field) || order.include?(field) }
296
+ raise ConversionError, "JSON property has no ordered XML representation" unless omitted.empty?
297
+ end
298
+ writer.close(name)
299
+ end
300
+
301
+ def write_field(writer, field, value, type, language)
302
+ values = descriptor(type, field).end_with?("[]") ? value : [value]
303
+ wrapper = WRAPPERS.fetch(type, {})[field]
304
+ writer.open(wrapper) if wrapper
305
+ values.each do |item|
306
+ if type == "Assessment" && field == "Impact"
307
+ raise ConversionError, "unknown Impact property has no XML representation" unless item.size == 1
308
+
309
+ impact, content = item.first
310
+ write_object(writer, impact, content, impact == "IntendedImpact" ? "BusinessImpact" : impact, language)
311
+ elsif %w[Indicator IndicatorExpression].include?(type) && field == "uid-ref"
312
+ writer.open("ObservableReference", { "uid-ref" => item })
313
+ writer.close("ObservableReference")
314
+ else
315
+ write_value(writer, field, item, descriptor(type, field).delete_suffix("[]"), type, language)
316
+ end
317
+ end
318
+ writer.close(wrapper) if wrapper
319
+ end
320
+
321
+ def write_value(writer, field, value, type, parent, language)
322
+ raise ConversionError, "XML cryptographic structures have no unambiguous JSON byte mapping" if OPAQUE.include?(field)
323
+
324
+ uri = namespace(parent, field)
325
+ name = xml_name(parent, field)
326
+ extra = {}
327
+ extra["xmlns"] = uri if uri != NS || STRUCTURED.include?(parent)
328
+ if type == "mlstring"
329
+ content = value.is_a?(String) ? { "value" => value } : value
330
+ extra["xml:lang"] = "en" if value.is_a?(String) && language && language != "en"
331
+ write_object(writer, name, content, "MLString", language, extra)
332
+ elsif SCHEMA.key?(type)
333
+ write_object(writer, name, value, type, language, extra)
334
+ elsif field == "RawData"
335
+ bytes = value.unpack1("m0")
336
+ node = @tree.parse(bytes)
337
+ writer.open(name, extra.merge("dtype" => "xml"))
338
+ @tree.write_node(writer, node)
339
+ writer.close(name)
340
+ else
341
+ writer.open(name, extra)
342
+ writer.text(value)
343
+ writer.close(name)
344
+ end
345
+ rescue ParseError
346
+ raise ConversionError, "embedded XML is not representable"
347
+ end
348
+
349
+ def write_fragment(writer, value)
350
+ root = @tree.parse("<fragment>#{value}</fragment>")
351
+ root.parts.each do |part|
352
+ case part
353
+ when XMLTree::Node then @tree.write_node(writer, part)
354
+ when XMLTree::Markup
355
+ writer.append(part.kind == :comment ? "<!--#{part.value}-->" : "<?#{part.value}?>")
356
+ else writer.text(part)
357
+ end
358
+ end
359
+ rescue ParseError
360
+ raise ConversionError, "embedded XML is not representable"
361
+ end
362
+ end
363
+ end