inspec/lib/inspec/input.rb
Clinton Wolfe 88ed1794cb Revert to a small amount of duplicating to_hash in inspec/input.rb
Signed-off-by: Clinton Wolfe <clintoncwolfe@gmail.com>
2019-10-06 19:30:37 -04:00

421 lines
14 KiB
Ruby

require "inspec/utils/deprecation"
# For backwards compatibility during the rename (see #3802),
# maintain the Inspec::Attribute namespace for people checking for
# Inspec::Attribute::DEFAULT_ATTRIBUTE
module Inspec
class Attribute
# This only exists to create the Inspec::Attribute::DEFAULT_ATTRIBUTE symbol with a class
class DEFAULT_ATTRIBUTE; end # rubocop: disable Naming/ClassAndModuleCamelCase
end
end
module Inspec
class Input
class Error < Inspec::Error; end
class ValidationError < Error
attr_accessor :input_name
attr_accessor :input_value
attr_accessor :input_type
end
class TypeError < Error
attr_accessor :input_type
end
class RequiredError < Error
attr_accessor :input_name
end
#===========================================================================#
# Class Input::Event
#===========================================================================#
# TODO: break this out to its own file under inspec/input?
# Information about how the input obtained its value.
# Each time it changes, an Input::Event is added to the #events array.
class Event
EVENT_PROPERTIES = [
:action, # :create, :set, :fetch
:provider, # Name of the plugin
:priority, # Priority of this plugin for resolving conflicts. 1-100, higher numbers win.
:value, # New value, if provided.
:file, # File containing the input-changing action, if known
:line, # Line in file containing the input-changing action, if known
:hit, # if action is :fetch, true if the remote source had the input
].freeze
# Value has a special handler
EVENT_PROPERTIES.reject { |p| p == :value }.each do |prop|
attr_accessor prop
end
attr_reader :value
def initialize(properties = {})
@value_has_been_set = false
properties.each do |prop_name, prop_value|
if EVENT_PROPERTIES.include? prop_name
# OK, save the property
send((prop_name.to_s + "=").to_sym, prop_value)
else
raise "Unrecognized property to Input::Event: #{prop_name}"
end
end
end
def value=(the_val)
# Even if set to nil or false, it has indeed been set; note that fact.
@value_has_been_set = true
@value = the_val
end
def value_has_been_set?
@value_has_been_set
end
def diagnostic_string
to_h.reject { |_, val| val.nil? }.to_a.map { |pair| "#{pair[0]}: '#{pair[1]}'" }.join(", ")
end
def to_h
EVENT_PROPERTIES.each_with_object({}) do |prop, hash|
hash[prop] = send(prop)
end
end
def self.probe_stack
frames = caller_locations(2, 40)
frames.reject! { |f| f.path && f.path.include?("/lib/inspec/") }
frames.first
end
end # class Event
#===========================================================================#
# Class NO_VALUE_SET
#===========================================================================#
# This special class is used to represent the value when an input has
# not been assigned a value. This allows a user to explicitly assign nil
# to an input.
class NO_VALUE_SET # rubocop: disable Naming/ClassAndModuleCamelCase
def initialize(name)
@name = name
# output warn message if we are in a exec call
if Inspec::BaseCLI.inspec_cli_command == :exec
Inspec::Log.warn(
"Input '#{@name}' does not have a value. "\
"Use --input-file to provide a value for '#{@name}' or specify a "\
"value with `attribute('#{@name}', value: 'somevalue', ...)`."
)
end
end
def method_missing(*_)
self
end
def respond_to_missing?(_, _)
true
end
def to_s
"Input '#{@name}' does not have a value. Skipping test."
end
def is_a?(klass)
if klass == Inspec::Attribute::DEFAULT_ATTRIBUTE
Inspec.deprecate(:rename_attributes_to_inputs, "Don't check for `is_a?(Inspec::Attribute::DEFAULT_ATTRIBUTE)`, check for `Inspec::Input::NO_VALUE_SET")
true # lie for backward compatibility
else
super(klass)
end
end
def kind_of?(klass)
if klass == Inspec::Attribute::DEFAULT_ATTRIBUTE
Inspec.deprecate(:rename_attributes_to_inputs, "Don't check for `kind_of?(Inspec::Attribute::DEFAULT_ATTRIBUTE)`, check for `Inspec::Input::NO_VALUE_SET")
true # lie for backward compatibility
else
super(klass)
end
end
end # class NO_VALUE_SET
#===========================================================================#
# Class Inspec::Input
#===========================================================================#
# Validation types for input values
VALID_TYPES = %w{
String
Numeric
Regexp
Array
Hash
Boolean
Any
}.freeze
# TODO: this is not used anywhere?
# If you call `input` in a control file, the input will receive this priority.
# You can override that with a :priority option.
DEFAULT_PRIORITY_FOR_DSL_ATTRIBUTES = 20
# If you somehow manage to initialize an Input outside of the DSL,
# AND you don't provide an Input::Event, this is the priority you get.
DEFAULT_PRIORITY_FOR_UNKNOWN_CALLER = 10
# If you directly call value=, this is the priority assigned.
# This is the highest priority within InSpec core; though plugins
# are free to go higher.
DEFAULT_PRIORITY_FOR_VALUE_SET = 60
attr_reader :description, :events, :identifier, :name, :required, :title, :type
def initialize(name, options = {})
@name = name
@opts = options
if @opts.key?(:default)
Inspec.deprecate(:attrs_value_replaces_default, "input name: '#{name}'")
if @opts.key?(:value)
Inspec::Log.warn "Input #{@name} created using both :default and :value options - ignoring :default"
@opts.delete(:default)
end
end
# Array of Input::Event objects. These compete with one another to determine
# the value of the input when value() is called, as well as providing a
# debugging record of when and how the value changed.
@events = []
events.push make_creation_event(options)
update(options)
end
# TODO: is this here just for testing?
def set_events
events.select { |e| e.action == :set }
end
def diagnostic_string
"Input #{name}, with history:\n" +
events.map(&:diagnostic_string).map { |line| " #{line}" }.join("\n")
end
#--------------------------------------------------------------------------#
# Managing Value
#--------------------------------------------------------------------------#
def update(options)
_update_set_metadata(options)
normalize_type_restriction!
# Values are set by passing events in; but we can also infer an event.
if options.key?(:value) || options.key?(:default)
if options.key?(:event)
if options.key?(:value) || options.key?(:default)
Inspec::Log.warn "Do not provide both an Event and a value as an option to attribute('#{name}') - using value from event"
end
else
self.class.infer_event(options) # Sets options[:event]
end
end
events << options[:event] if options.key? :event
enforce_type_restriction!
end
# We can determine a value:
# 1. By event.value (preferred)
# 2. By options[:value]
# 3. By options[:default] (deprecated)
def self.infer_event(options)
# Don't rely on this working; you really should be passing a proper Input::Event
# with the context information you have.
location = Input::Event.probe_stack
event = Input::Event.new(
action: :set,
provider: options[:provider] || :unknown,
priority: options[:priority] || Inspec::Input::DEFAULT_PRIORITY_FOR_UNKNOWN_CALLER,
file: location.path,
line: location.lineno
)
if options.key?(:default)
Inspec.deprecate(:attrs_value_replaces_default, "attribute name: '#{name}'")
if options.key?(:value)
Inspec::Log.warn "Input #{@name} created using both :default and :value options - ignoring :default"
options.delete(:default)
else
options[:value] = options.delete(:default)
end
end
event.value = options[:value] if options.key?(:value)
options[:event] = event
end
private
def _update_set_metadata(options)
# Basic metadata
@title = options[:title] if options.key?(:title)
@description = options[:description] if options.key?(:description)
@required = options[:required] if options.key?(:required)
@identifier = options[:identifier] if options.key?(:identifier) # TODO: determine if this is ever used
@type = options[:type] if options.key?(:type)
end
def make_creation_event(options)
loc = options[:location] || Event.probe_stack
Input::Event.new(
action: :create,
provider: options[:provider],
file: loc.path,
line: loc.lineno
)
end
# Determine the current winning value, but don't validate it
def current_value
# Examine the events to determine highest-priority value. Tie-break
# by using the last one set.
events_that_set_a_value = events.select(&:value_has_been_set?)
winning_priority = events_that_set_a_value.map(&:priority).max
winning_events = events_that_set_a_value.select { |e| e.priority == winning_priority }
winning_event = winning_events.last # Last for tie-break
if winning_event.nil?
# No value has been set - return special no value object
NO_VALUE_SET.new(name)
else
winning_event.value # May still be nil
end
end
public
def value=(new_value, priority = DEFAULT_PRIORITY_FOR_VALUE_SET)
# Inject a new Event with the new value.
location = Event.probe_stack
events << Event.new(
action: :set,
provider: :value_setter,
priority: priority,
value: new_value,
file: location.path,
line: location.lineno
)
enforce_type_restriction!
end
def value
enforce_required_validation!
current_value
end
def has_value?
!current_value.is_a? NO_VALUE_SET
end
def to_hash
as_hash = { name: name, options: {} }
%i{description title identifier type required value}.each do |field|
val = send(field)
next if val.nil?
as_hash[:options][field] = val
end
as_hash
end
#--------------------------------------------------------------------------#
# Value Type Coercion
#--------------------------------------------------------------------------#
def to_s
"Input #{name} with #{current_value}"
end
#--------------------------------------------------------------------------#
# Validation
#--------------------------------------------------------------------------#
private
def enforce_required_validation!
return unless required
# skip if we are not doing an exec call (archive/vendor/check)
return unless Inspec::BaseCLI.inspec_cli_command == :exec
proposed_value = current_value
if proposed_value.nil? || proposed_value.is_a?(NO_VALUE_SET)
error = Inspec::Input::RequiredError.new
error.input_name = name
raise error, "Input '#{error.input_name}' is required and does not have a value."
end
end
def enforce_type_restriction! # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
return unless type
return unless has_value?
type_req = type
return if type_req == "Any"
proposed_value = current_value
invalid_type = false
if type_req == "Regexp"
invalid_type = true unless valid_regexp?(proposed_value)
elsif type_req == "Numeric"
invalid_type = true unless valid_numeric?(proposed_value)
elsif type_req == "Boolean"
invalid_type = true unless [true, false].include?(proposed_value)
elsif proposed_value.is_a?(Module.const_get(type_req)) == false
# TODO: why is this case here?
invalid_type = true
end
if invalid_type == true
error = Inspec::Input::ValidationError.new
error.input_name = @name
error.input_value = proposed_value
error.input_type = type_req
raise error, "Input '#{error.input_name}' with value '#{error.input_value}' does not validate to type '#{error.input_type}'."
end
end
def normalize_type_restriction!
return unless type
type_req = type.capitalize
abbreviations = {
"Num" => "Numeric",
"Regex" => "Regexp",
}
type_req = abbreviations[type_req] if abbreviations.key?(type_req)
unless VALID_TYPES.include?(type_req)
error = Inspec::Input::TypeError.new
error.input_type = type_req
raise error, "Type '#{error.input_type}' is not a valid input type."
end
@type = type_req
end
def valid_numeric?(value)
Float(value)
true
rescue
false
end
def valid_regexp?(value)
# check for invalid regex syntax
Regexp.new(value)
true
rescue
false
end
end
end