lib/sym/application.rb

Summary

Maintainability
A
35 mins
Test Coverage
require 'colored2'
require 'sym'
require 'sym/app'
require 'openssl'
require 'json'

module Sym
  # Main Application controller class for Sym.
  #
  # Accepts a hash with CLI options set (as symbols), for example
  #
  # Example
  # =======
  #
  #     app = Sym::Application.new( encrypt: true, file: '/tmp/secrets.yml', output: '/tmp/secrets.yml.enc')
  #     result = app.execute
  #
  #
  class Application

    attr_accessor :opts,
                  :opts_slop,
                  :args,
                  :action,
                  :key,
                  :key_source,
                  :input_handler,
                  :key_handler,
                  :output,
                  :result,
                  :password_cache,
                  :stdin, :stdout, :stderr, :kernel

    def initialize(opts, stdin = $stdin, stdout = $stdout, stderr = $stderr, kernel = nil)
      self.stdin  = stdin
      self.stdout = stdout
      self.stderr = stderr
      self.kernel = kernel

      self.opts_slop = opts.clone
      self.opts      = opts.is_a?(Hash) ? opts : opts.to_hash

      process_negated_option(opts[:negate]) if opts[:negate]
      process_edit_option

      self.args = ::Sym::App::Args.new(self.provided_options)

      initialize_output_stream
      initialize_action
      initialize_data_source
      initialize_password_cache
      initialize_input_handler
    end

    # Main action method — it looksup the command, and executes it, translating
    # various exception conditions into meaningful error messages.
    def execute
      process_output(execute!)

    rescue ::OpenSSL::Cipher::CipherError => e
      { reason:    'Invalid key provided',
        exception: e }

    rescue Sym::Errors::Error => e
      { reason:    e.class.name.gsub(/.*::/, '').underscore.humanize.downcase,
        exception: e }

    rescue TypeError => e
      if e.message =~ /marshal/m
        { reason:    'Corrupt source data or invalid/corrupt key provided',
          exception: e }
      else
        { exception: e }
      end

    rescue StandardError => e
      { exception: e }
    end

    def command
      @command_class ||= Sym::App::Commands.find_command_class(opts)
      @command       ||= @command_class.new(self) if @command_class
      @command
    end

    def provided_flags
      provided_flags = provided_options
      provided_flags.delete_if { |k, v| ![false, true].include?(v) }
      provided_flags.keys
    end

    def provided_value_options
      provided = provided_options(safe: true)
      provided.delete_if { |k, v| [false, true].include?(v) }
      provided
    end

    def provided_options(**opts)
      provided_opts = self.opts.clone
      provided_opts.delete_if { |k, v| !v }
      if opts[:safe]
        provided_options.map do |k, v|
          k == :key && [44, 45].include?(v.size) ?
            [k, '[reducted]'] :
            [k, v]
        end.to_h
      else
        provided_opts
      end
    end

    def editor
      editors_to_try.compact.find { |editor| File.exist?(editor) }
    end

    def process_output(result)
      self.output.call(result) unless result.is_a?(Hash)
      result
    end

    private

    def execute!
      initialize_key_source
      unless command
        raise Sym::Errors::InsufficientOptionsError,
              " Can not determine what to do
        from the options: \ n " +
                " #{self.provided_options.inspect.green.bold}\n" +
                "and flags #{self.provided_flags.to_s.green.bold}"
      end
      log :info, "command located is #{command.class.name.blue.bold}"
      self.result = command.execute.tap do |result|
        log :info, "result is  #{result.nil? ? 'nil' : result[0..10].to_s.blue.bold }..." if opts[:trace]
      end
    end

    def log(*args)
      Sym::App.log(*args, **opts)
    end

    def editors_to_try
      [
        ENV.fetch('EDITOR', nil),
        '/usr/bin/vim',
        '/usr/local/bin/vim',
        '/bin/vim',
        '/sbin/vim',
        '/usr/sbin/vim',
        '/usr/bin/vi',
        '/usr/local/bin/vi',
        '/bin/vi',
        '/sbin/vi'
      ]
    end

    def initialize_output_stream
      output_klass = args.output_class
      unless output_klass && output_klass.is_a?(Class)
        raise "Can not determine output type from arguments #{provided_options}"
      end
      self.output = output_klass.new(opts, stdin, stdout, stderr, kernel).output_proc
    end

    def initialize_input_handler(handler = ::Sym::App::Input::Handler.new(stdin, stdout, stderr, kernel))
      self.input_handler = handler
    end

    def initialize_key_handler
      self.key_handler = ::Sym::App::PrivateKey::Handler.new(opts, input_handler, password_cache)
    end

    def initialize_password_cache
      args            = {}
      args[:timeout]  = (opts[:cache_timeout] || ENV['SYM_CACHE_TTL'] || Sym::Configuration.config.password_cache_timeout).to_i
      args[:enabled]  = opts[:cache_passwords]
      args[:verbose]  = opts[:verbose]
      args[:provider] = opts[:cache_provider] if opts[:cache_provider]

      self.password_cache = Sym::App::Password::Cache.instance.configure(**args)
    end

    def process_edit_option
      if opts[:edit] && opts[:edit].is_a?(String) && opts[:file].nil?
        opts[:file] = opts[:edit]
        opts[:edit] = true
      end
    end

    def process_negated_option(file)
      opts.delete(:negate)
      opts[:file] = file
      extension   = Sym.config.encrypted_file_extension
      if file.end_with?('.enc')
        opts[:decrypt] = true
        opts[:output]  = file.gsub(/\.#{extension}/, '')
        opts.delete(:output) if opts[:output] == ''
      else
        opts[:encrypt] = true
        opts[:output]  = "#{file}.#{extension}"
      end
    end

    def initialize_action
      self.action = if opts[:encrypt]
                      :encr
                    elsif opts[:decrypt]
                      :decr
                    end
    end

    # If we are encrypting or decrypting, and no data has been provided, check if we
    # should read from STDIN
    def initialize_data_source
      if self.action && opts[:string].nil? && opts[:file].nil? && !self.stdin.tty?
        opts[:file] = '-'
      end
    end

    # If no key is provided with command line options, check the default
    # key location (which can be changed via Configuration class).
    # In any case, attempt to initialize the key one way or another.
    def initialize_key_source
      detect_key_source
      if args.require_key? && !self.key
        log :error, 'Unable to determine the key, which appears to be required with current args'
        raise Sym::Errors::NoPrivateKeyFound, "Private key is required when #{self.action ? "#{self.action.to_s}ypting" : provided_flags.join(', ')}"
      end
      log :debug, "initialize_key_source: detected key ends with [...#{(key ? key[-5..] : 'nil').bold.magenta}]"
      log :debug, "opts: #{self.provided_value_options.to_s.green.bold}"
      log :debug, "flags: #{self.provided_flags.to_s.green.bold}"
    end

    def detect_key_source
      initialize_key_handler
      self.key = self.key_handler.key
      if self.key
        self.key_source = key_handler.key_source
        if key_source =~ /^default_file/
          opts[:key] = self.key
        end
        log :info, "key was detected from source #{key_source.to_s.bold.green}"
      end
    end
  end
end