yast/yast-yast2

View on GitHub
library/xml/src/modules/XML.rb

Summary

Maintainability
C
7 hrs
Test Coverage
# ***************************************************************************
#
# 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"]

      node["t"] || node["type"] || detect_type(text, children, node)
    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?) ||
          # keep cdata trick to create empty string
          !node.children.reject(&:text?).select(&:cdata?).empty? ||
          # default type is text if nothing is specified and cannot interfere
          (text.empty? && children.empty?)
        "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