/
XML.rb
376 lines (327 loc) · 12.9 KB
/
XML.rb
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
# ***************************************************************************
#
# Copyright (c) 2002 - 2020 SUSE LLC
# All Rights Reserved.
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of version 2 of the GNU General Public License as
# published by the Free Software Foundation.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, contact SUSE LCC.
#
# To contact Novell about this file by physical or electronic mail,
# you may find current contact information at www.suse.com
#
# ***************************************************************************
require "yast"
require "nokogiri"
require "pathname"
module Yast
# Exception used when trying to serialize a Ruby object that we cannot
# represent in XML:
# - non-String Hash key
# - a special object, like a Regexp
# - nil
class XMLSerializationError < RuntimeError
attr_reader :object
def initialize(message, object)
@object = object
super(message)
end
end
# Exception used when parsing a XML string:
# - syntax error (Nokogiri::XML::SyntaxError is reraised as this)
# - cannot be represented as Ruby data
class XMLDeserializationError < RuntimeError
def self.for_node(node, message)
new("Element <#{node.name}> at line #{node.line}: #{message}")
end
end
class XMLClass < Module
include Yast::Logger
def main
# Sections in XML file that should be treated as CDATA when saving
@cdataSections = []
# How to call a list entry in the XML output
@listEntries = {}
# The system ID, or the DTD URI
@systemID = nil
# root element of the XML file
@rootElement = ""
# Global Namespace xmlns=...
@nameSpace = "http://www.suse.com/1.0/yast2ns"
# Type name space xmlns:config for YCP data (http://www.suse.com/1.0/configns)
@typeNamespace = "http://www.suse.com/1.0/configns"
@docs = {}
end
# define a new doc type with custom settings, if not defined, global settings will
# be used.
# @param symbol Document type identifier
# @param map Document type Settings
# @return [void]
def xmlCreateDoc(doc, doc_settings)
doc_settings = deep_copy(doc_settings)
current_settings = {
"cdataSections" => Ops.get_list(
doc_settings,
"cdataSections",
@cdataSections
),
"systemID" => doc_settings["systemID"],
"rootElement" => Ops.get_string(
doc_settings,
"rootElement",
@rootElement
),
"listEntries" => Ops.get_map(doc_settings, "listEntries", @listEntries)
}
if Ops.get_string(doc_settings, "typeNamespace", "") != ""
Ops.set(
current_settings,
"typeNamespace",
Ops.get_string(doc_settings, "typeNamespace", "")
)
end
if Ops.get_string(doc_settings, "nameSpace", "") != ""
Ops.set(
current_settings,
"nameSpace",
Ops.get_string(doc_settings, "nameSpace", "")
)
end
Ops.set(@docs, doc, current_settings)
nil
end
# YCPToXMLFile()
# Write YCP data into formated XML file
# @param [Symbol] doc_type Document type identifier
# @param [Hash] contents a map with YCP data
# @param [String] output_path the path of the XML file
# @return [Boolean] true on sucess
# @raise [XMLSerializationError] when non supported contents is passed
def YCPToXMLFile(doc_type, contents, output_path)
xml = YCPToXMLString(doc_type, contents)
return false unless xml
SCR.Write(path(".target.string"), output_path, xml)
end
# Write YCP data into formated XML string
# @param [Symbol] doc_type Document type identifier
# @param [Hash] contents a map with YCP data
# @return [String, nil] String with XML data or nil if error happen
# @raise [XMLSerializationError] when non supported content is passed
def YCPToXMLString(doc_type, contents)
metadata = @docs[doc_type]
if !metadata
log.error "Calling YCPToXML with unknown doc_type #{doc_type.inspect}. " \
"Known types #{@docs.keys.inspect}"
return nil
end
doc = Nokogiri::XML::Document.new("1.0")
root_name = metadata["rootElement"]
if !root_name || root_name.empty?
log.warn "root element missing in docs #{metadata.inspect}"
return nil
end
doc.create_internal_subset(root_name, nil, metadata["systemID"])
root = doc.create_element root_name
root.default_namespace = metadata["nameSpace"] if metadata["nameSpace"]
root.add_namespace("config", metadata["typeNamespace"]) if metadata["typeNamespace"]
add_element(doc, metadata, root, contents)
doc.root = root
doc.to_xml
end
# Reads XML file
# @param [String] xml_file XML file name to read
# @return [Hash] parsed content
# @raise [XMLDeserializationError] when non supported XML is passed
def XMLToYCPFile(xml_file)
raise ArgumentError, "XMLToYCPFile() received nil" if xml_file.nil?
raise XMLDeserializationError, "Cannot find XML file #{xml_file}" if SCR.Read(path(".target.size"), xml_file) <= 0
log.info "Reading #{xml_file}"
XMLToYCPString(SCR.Read(path(".target.string"), xml_file))
end
# Reads XML string
# @param [String] xml_string to read
# @return [Hash] parsed content
# @raise [XMLDeserializationError] when non supported xml is passed
def XMLToYCPString(xml_string)
raise XMLDeserializationError, "Cannot convert empty XML string" if !xml_string || xml_string.empty?
doc = Nokogiri::XML(xml_string, &:strict)
doc.remove_namespaces! # remove fancy namespaces to make user life easier
result = {}
# inspect only element nodes
doc.root.children.select(&:element?).each { |n| parse_node(n, result) }
result
rescue Nokogiri::XML::SyntaxError => e
raise XMLDeserializationError, e.message
end
# Validates a XML document against a given schema.
#
# @param xml [Pathname, String] path or content of XML (if the string contains a new
# line character it is considered as a XML string, otherwise as a file name)
# @param schema [Pathname, String] path or content of relax ng schema
# @return [Array<String>] array of strings with errors or empty array if
# well formed and valid
# @raise [Yast::XMLDeserializationError] if the document is not well formed
# (there are syntax errors)
def validate(xml, schema)
xml = SCR.Read(path(".target.string"), xml.to_s) if xml.is_a?(Pathname)
if schema.is_a?(::String)
validator = Nokogiri::XML::RelaxNG(schema)
else
schema_content = SCR.Read(path(".target.string"), schema.to_s)
schema_path = File.dirname(schema)
# change directory so relative include works
Dir.chdir(schema_path) { validator = Nokogiri::XML::RelaxNG(schema_content) }
end
doc = Nokogiri::XML(xml, &:strict)
validator.validate(doc).map(&:message)
rescue Nokogiri::XML::SyntaxError => e
raise XMLDeserializationError, e.message
end
# The error string from the XML parser.
# It should be used when the agent did not return content.
# A reset happens before a new XML parsing starts.
# @return nil
# @deprecated Exception is used instead
def XMLError
nil
end
publish function: :xmlCreateDoc, type: "void (symbol, map)"
publish function: :YCPToXMLFile, type: "boolean (symbol, map, string)"
publish function: :YCPToXMLString, type: "string (symbol, map)"
publish function: :XMLToYCPFile, type: "map <string, any> (string)"
publish function: :XMLToYCPString, type: "map <string, any> (string)"
publish function: :XMLError, type: "string ()"
private
BOOLEAN_MAP = {
"true" => true,
"false" => false
}.freeze
def parse_node(node, result)
name = node.name
# use only element children
children = node.children
children = children.select(&:element?)
# we need just direct text under node. Can be splitted with another elements
# but remove whitespace only text
#
# Do NOT strip surrounding white space in CDATA blocks (bsc#1195910).
# This restores the behavior prior to SLE15-SP3.
#
# See also #add_element.
text_nodes = node.xpath("text()")
text = text_nodes.map { |n| n.cdata? ? n.content : n.content.strip }.join
type = fetch_type(text, children, node)
result[name] = case type
# disksize is our own design mistake. deprecated.
when "string", "disksize" then text
when "symbol"
raise XMLDeserializationError.for_node(node, "empty symbols are invalid") if text.empty?
text.to_sym
when "integer"
raise XMLDeserializationError.for_node(node, "cannot be parsed as an integer") if text !~ /-?\d+/
text.to_i
when "boolean"
v = BOOLEAN_MAP[text]
raise XMLDeserializationError.for_node(node, "booleans can only be 'true' or 'false'") if v.nil?
v
when "list"
children.map do |kid|
# always pass new hash to prevent overwrite for list with same elements
r = {}
parse_node(kid, r)
r.values.first
end
when "map"
v = {}
children.each { |kid| parse_node(kid, v) }
v
else
raise XMLDeserializationError.for_node(node, "invalid type #{type.inspect}")
end
result
end
# @param [Nokogiri::XML::Document] doc
# @param [Hash] metadata for current doc
# @param [Nokogiri::XML::Node] parent
# @param [Hash] contents content to write
# @param [Array] parent_array (for better error reporting)
# @return [void]
def add_element(doc, metadata, parent, contents, parent_array: nil)
# backward compatibility. Keys are sorted and needs old ycp sort to be able to compare also classes
Builtins.sort(contents.keys).each do |key|
raise XMLSerializationError.new("Cannot represent non-string key '#{key.inspect}', part of #{contents.inspect}", contents) unless key.is_a?(::String)
value = contents[key]
type_attr = "t"
element = Nokogiri::XML::Node.new(key, doc)
case value
when ::String
# Write CDATA block if string has surrounding white space. This matches
# the stripping in #parse_node.
if value.match?(/\A\s|\s\z/)
element.add_child(doc.create_cdata(value))
else
element.content = value
end
when ::Integer
element[type_attr] = "integer"
element.content = value.to_s
when ::Symbol
element[type_attr] = "symbol"
element.content = value.to_s
when true, false
element[type_attr] = "boolean"
element.content = value.to_s
when ::Array
element[type_attr] = "list"
special_names = metadata["listEntries"] || {}
element_name = special_names[key] || "listentry"
value.each do |list_value|
add_element(doc, metadata, element, { element_name => list_value }, parent_array: value)
end
when ::Hash
element[type_attr] = "map"
add_element(doc, metadata, element, value)
else # including nil
# without parent_array, a ["foo", nil, "bar"] would always
# unhelpfully report {"listentry"=> nil} as the parent object
o = parent_array || contents
raise XMLSerializationError.new("Cannot represent #{value.inspect}, part of #{o.inspect}", o)
end
parent << element
end
end
# @return [String] the 't' or 'type' attribute, or inferred
# @raise if there is conflicting type information
def fetch_type(text, children, node)
raise XMLDeserializationError.for_node(node, "contains both 't' and 'type' attributes") if node["t"] && node["type"]
type = node["t"] || node["type"] || detect_type(text, children, node)
type
end
def detect_type(text, children, node)
# backward compatibility. Newly maps should have its type
if text.empty? && !children.empty?
"map"
elsif !text.empty? && children.empty?
"string"
# keep cdata trick to create empty string
elsif !node.children.reject(&:text?).select(&:cdata?).empty?
"string"
elsif text.empty? && children.empty?
# default type is text if nothing is specified and cannot interfere
"string"
else
raise XMLDeserializationError.for_node(node, "contains both text #{text.inspect} and elements #{children.inspect}.")
end
end
end
XML = XMLClass.new
XML.main
end