lib/facter/custom_facts/util/parser.rb
# frozen_string_literal: true
# This class acts as the factory and parent class for parsed
# facts such as scripts, text, json and yaml files.
#
# Parsers must subclass this class and provide their own #results method.
module LegacyFacter
module Util
module Parser
STDERR_MESSAGE = 'Command %s completed with the following stderr message: %s'
@parsers = []
# For support mutliple extensions you can pass an array of extensions as
# +ext+.
def self.extension_matches?(filename, ext)
extension = case ext
when String
ext.downcase
when Enumerable
ext.collect(&:downcase)
end
[extension].flatten.to_a.include?(file_extension(filename).downcase)
end
def self.file_extension(filename)
File.extname(filename).sub('.', '')
end
def self.register(klass, &suitable)
@parsers << [klass, suitable]
end
def self.parser_for(filename)
registration = @parsers.detect { |k| k[1].call(filename) }
if registration.nil?
NothingParser.new
else
registration[0].new(filename)
end
end
class Base
attr_reader :filename
def initialize(filename, content = nil)
@filename = filename
@content = content
end
def content
@content ||= Facter::Util::FileHelper.safe_read(filename, nil)
end
# results on the base class is really meant to be just an exception handler
# wrapper.
def results
parse_results
rescue StandardError => e
Facter.log_exception(e, "Failed to handle #{filename} as #{self.class} facts: #{e.message}")
nil
end
def parse_results
raise ArgumentError, 'Subclasses must respond to parse_results'
end
def parse_executable_output(output)
res = nil
begin
res = if Gem::Version.new(Psych::VERSION) >= Gem::Version.new('3.1.0') # Ruby 2.6+
YAML.safe_load(output, permitted_classes: [Symbol, Time])
else
YAML.safe_load(output, [Symbol, Time])
end
rescue StandardError => e
Facter.debug("Could not parse executable fact output as YAML or JSON (#{e.message})")
end
res = KeyValuePairOutputFormat.parse output unless res.is_a?(Hash)
res
end
def log_stderr(msg, command, file)
return if !msg || msg.empty?
file_name = file.split('/').last
logger = Facter::Log.new(file_name)
logger.warn(format(STDERR_MESSAGE, command, msg.strip))
end
end
module KeyValuePairOutputFormat
def self.parse(output)
return {} if output.nil?
result = {}
re = /^(.+?)=(.+)$/
output.each_line do |line|
if (match_data = re.match(line.chomp))
result[match_data[1]] = match_data[2]
end
end
result
end
end
# This regex was taken from Psych and adapted
# https://github.com/ruby/psych/blob/d2deaa9adfc88fc0b870df022a434d6431277d08/lib/psych/scalar_scanner.rb#L9
# It is used to detect Time in YAML, but we use it to wrap time objects in quotes to be treated as strings.
TIME =
/(\d{4}-\d{1,2}-\d{1,2}(?:[Tt]|\s+)\d{1,2}:\d\d:\d\d(?:\.\d*)?(?:\s*(?:Z|[-+]\d{1,2}:?(?:\d\d)?))?\s*$)/.freeze
class YamlParser < Base
def parse_results
# Add quotes to Yaml time
cont = content.gsub(TIME, '"\1"')
if Gem::Version.new(Psych::VERSION) >= Gem::Version.new('3.1.0') # Ruby 2.6+
YAML.safe_load(cont, permitted_classes: [Date], aliases: true)
else
YAML.safe_load(cont, [Date], [], [], true)
end
end
end
register(YamlParser) do |filename|
extension_matches?(filename, 'yaml')
end
class TextParser < Base
def parse_results
KeyValuePairOutputFormat.parse content
end
end
register(TextParser) do |filename|
extension_matches?(filename, 'txt')
end
class JsonParser < Base
def parse_results
if LegacyFacter.json?
JSON.parse(content)
else
log.warnonce "Cannot parse JSON data file #{filename} without the json library."
log.warnonce 'Suggested next step is `gem install json` to install the json library.'
nil
end
end
private
def log
@log ||= Facter::Log.new(self)
end
end
register(JsonParser) do |filename|
extension_matches?(filename, 'json')
end
class ScriptParser < Base
def parse_results
stdout, stderr = Facter::Core::Execution.execute_command(quote(filename))
log_stderr(stderr, filename, filename)
parse_executable_output(stdout)
end
private
def quote(filename)
filename.index(' ') ? "\"#{filename}\"" : filename
end
end
register(ScriptParser) do |filename|
if LegacyFacter::Util::Config.windows?
extension_matches?(filename, %w[bat cmd com exe]) && FileTest.file?(filename)
else
File.executable?(filename) && FileTest.file?(filename) && !extension_matches?(filename, %w[bat cmd com exe])
end
end
# Executes and parses the key value output of Powershell scripts
class PowershellParser < Base
# Returns a hash of facts from powershell output
def parse_results
powershell =
if File.readable?("#{ENV['SYSTEMROOT']}\\sysnative\\WindowsPowershell\\v1.0\\powershell.exe")
"#{ENV['SYSTEMROOT']}\\sysnative\\WindowsPowershell\\v1.0\\powershell.exe"
elsif File.readable?("#{ENV['SYSTEMROOT']}\\system32\\WindowsPowershell\\v1.0\\powershell.exe")
"#{ENV['SYSTEMROOT']}\\system32\\WindowsPowershell\\v1.0\\powershell.exe"
else
'powershell.exe'
end
shell_command =
"\"#{powershell}\" -NoProfile -NonInteractive -NoLogo -ExecutionPolicy Bypass -File \"#{filename}\""
stdout, stderr = Facter::Core::Execution.execute_command(shell_command)
log_stderr(stderr, shell_command, filename)
parse_executable_output(stdout)
end
end
register(PowershellParser) do |filename|
LegacyFacter::Util::Config.windows? && extension_matches?(filename, 'ps1') && FileTest.file?(filename)
end
# A parser that is used when there is no other parser that can handle the file
# The return from results indicates to the caller the file was not parsed correctly.
class NothingParser
def results
nil
end
end
end
end
end