schmich/tome

View on GitHub
lib/tome/command.rb

Summary

Maintainability
D
1 day
Test Coverage
require 'io/console'
require 'passgen'
require 'clipboard'

module Tome
  class CommandError < RuntimeError
  end

  class Command
    private_class_method :new 

    def self.run(tome_filename, args, stdout = $stdout, stderr = $stderr, stdin = $stdin)
      command = new()
      return command.send(:run, tome_filename, args, stdout, stderr, stdin)
    end

  private
    def run(tome_filename, args, stdout, stderr, stdin)
      @out = stdout
      @err = stderr
      @in = stdin
      @tome_filename = tome_filename
      
      if args.length < 1
        usage()
        return 1
      end

      begin
        handle_command(args)
      rescue CommandError => error
        @err.puts "Error: #{error.message}"
        return 1
      rescue FileFormatError => error
        # Fix file separators for Windows.
        filename = @tome_filename.gsub(File::SEPARATOR, File::ALT_SEPARATOR || File::SEPARATOR)
        @err.puts "Error: Cannot read #{filename}: #{error.message}"
        return 1
      end

      return 0
    end

    def handle_command(args)
      # TODO: Handle 'command --help', e.g. 'tome set --help'.

      command = command_from_arg(args[0])

      if command.nil?
        raise CommandError, "Unrecognized command: #{args[0]}.\n\n#{$usage}"
      end

      args.shift
      send(command, args) 
    end

    def command_from_arg(arg)
      commands = {
        /\A(help|-h|--help)\z/i => :help,
        /\A(version|ver|-v|--version)\z/i => :version,
        /\A(set|s|add)\z/i => :set,
        /\A(get|g|show)\z/i => :get,
        /\A(delete|del|rm|remove)\z/i => :delete,
        /\A(generate|gen)\z/i => :generate,
        /\A(copy|cp)\z/i => :copy,
        /\A(rename|ren|rn)\z/i => :rename,
        /\A(master)\z/i => :master,
        /\A(list|ls)\z/i => :list
      }

      commands.each { |pattern, command|
        return command if arg =~ pattern
      }

      return nil
    end

    def help(args)
      if args.length > 1
        raise CommandError, "Invalid arguments.\n\n#{$usage}"
      end

      if args.empty?
        usage()
        return
      end

      command = command_from_arg(args[0])

      if command.nil?
        raise CommandError, "No help for unrecognized command: #{args[0]}.\n\n#{$usage}"
      end

      help = {
        :help => $help_usage,
        :set => $set_usage,
        :get => $get_usage,
        :delete => $delete_usage,
        :generate => $generate_usage,
        :copy => $copy_usage,
        :rename => $rename_usage,
        :master => $master_usage,
        :list => $list_usage
      }

      usage = help[command]
      if usage.nil?
        raise CommandError, "No help available for command: #{args[0]}."
      end

      @out.puts usage
    end

    def version(args)
      @out.puts "tome version #{VERSION}"
    end

    def set(args)
      if args.length < 1 || args.length > 2
        raise CommandError, "Invalid arguments.\n\n#{$set_usage}"
      end
      
      created, tome = tome_create_connect()

      case args.length
        # TODO: Validate that first argument is in [username@]domain form.

        # tome set bar.com
        # tome set foo@bar.com
        when 1
          id = args[0]
          password = prompt_password()

        # tome set bar.com p4ssw0rd
        # tome set foo@bar.com p4ssw0rd
        when 2
          id = args[0]
          password = args[1]
      end

      exists = !tome.get(id).nil?
      if exists
        confirm = prompt_confirm("A password already exists for #{id}. Overwrite (y/n)? ")
        if !confirm
          raise CommandError, 'Aborted.'
        end
      end

      created = tome.set(id, password)
      if created
        @out.print 'Created '
      else
        @out.print 'Updated '
      end

      @out.puts "password for #{id}."
    end

    def get(args)
      if args.length != 1
        raise CommandError, "Invalid arguments.\n\n#{$get_usage}"
      end
      
      # tome get bar.com
      # tome get foo@bar.com
      pattern = args[0]

      tome = tome_connect()
      matches = tome.find(pattern)

      if matches.empty?
        raise CommandError, "No password found for #{pattern}."
      elsif matches.count == 1
        match = matches.first
        @out.puts "Password for #{match.first}:"
        @out.puts match.last
      else
        @out.puts "Multiple matches for #{pattern}:"
        matches.each { |key, password|
          @out.puts "#{key}: #{password}"
        }
      end
    end

    def delete(args)
      if args.length != 1
        raise CommandError, "Invalid arguments.\n\n#{$delete_usage}"
      end

      tome = tome_connect()

      # tome del bar.com
      # tome del foo@bar.com
      id = args[0]

      exists = !tome.get(id).nil?
      if exists
        confirmed = prompt_confirm("Are you sure you want to delete the password for #{id} (y/n)? ")
        if !confirmed
          raise CommandError, 'Aborted.'
        end
      end

      deleted = tome.delete(id)

      if deleted
        @out.puts "Deleted password for #{id}."
      else
        @out.puts "No password found for #{id}."
      end
    end

    def generate(args)
      if args.length != 1
        raise CommandError, "Invalid arguments.\n\n#{$generate_usage}"
      end
      
      created, tome = tome_create_connect()

      # tome gen bar.com
      # tome gen foo@bar.com
      id = args[0]
      password = generate_password()

      exists = !tome.get(id).nil?
      if exists
        confirm = prompt_confirm("A password already exists for #{id}. Overwrite (y/n)? ")
        if !confirm
          raise CommandError, 'Aborted.'
        end
      end

      created = tome.set(id, password)
      Clipboard.copy(password)

      if created
        @out.puts "Generated and copied password for #{id}."
      else
        @out.puts "Updated and copied password for #{id}."
      end
    end

    def copy(args)
      if args.length != 1
        raise CommandError, "Invalid arguments.\n\n#{$copy_usage}"
      end

      # tome cp bar.com
      # tome cp foo@bar.com
      pattern = args[0]

      tome = tome_connect()
      matches = tome.find(pattern)

      if matches.empty?
        raise CommandError, "No password found for #{pattern}."
      elsif matches.count > 1
        match = pick_match(pattern, matches)
      else
        match = matches.first
      end

      name = match.first
      password = match.last

      Clipboard.copy(password)
      if Clipboard.paste == password
        @out.puts "Password for #{name} copied to clipboard."
      else
        @err.puts "Failed to copy password for #{name} to clipboard."
      end
    end

    def pick_match(pattern, matches)
      matches = matches.to_a

      @out.puts "Found multiple matches for \"#{pattern}\":\n\n"
      matches.each_with_index { |match, i|
        @out.puts "\t#{i + 1}: #{match[0]}"
      }

      begin
        @out.print "\n> "

        index = @in.gets.to_i
        if index <= 0 || index > matches.length
          @out.puts 'Invalid option.'
          raise
        end

        return matches[index - 1]
      rescue
        retry
      end
    end

    def list(args)
      if !args.empty?
        raise CommandError, "Invalid arguments.\n\n#{$list_usage}"
      end

      tome = tome_connect()

      count = 0
      tome.each_password { |id, password|
        @out.puts "#{id}: #{password}"
        count += 1
      }

      if count == 0
        @out.puts 'No passwords stored.'
      end
    end

    def rename(args)
      if args.count != 2
        raise CommandError, "Invalid arguments.\n\n#{$rename_usage}"
      end

      tome = tome_connect()

      old_id = args[0]
      new_id = args[1]

      overwriting = !tome.get(new_id).nil?
      if overwriting
        confirm = prompt_confirm("A password already exists for #{new_id}. Overwrite (y/n)? ")
        if !confirm
          raise CommandError, 'Aborted.'
        end
      end

      renamed = tome.rename(old_id, new_id)
      
      if !renamed
        raise CommandError, "#{old_id} does not exist."
      else
        @out.puts "#{old_id} renamed to #{new_id}."
      end
    end

    def master(args)
      if args.count > 0
        raise CommandError, "Invalid arguments.\n\n#{$master_usage}"
      end

      created, tome = tome_create_connect()

      if !created
        master_password = prompt_password('New master password')
        tome.master_password = master_password
        @out.puts 'Master password updated.'
      end
    end

    def generate_password
      Passgen.generate(:length => 30, :symbols => true)
    end

    def prompt_password(prompt = 'Password')
      begin
        @err.print "#{prompt}: "
        password = input_password()

        if password.empty?
          @err.puts 'Password cannot be blank.'
          raise
        end

        @err.print "#{prompt} (verify): "
        verify = input_password()

        if verify != password
          @err.puts 'Passwords do not match.'
          raise
        end
      rescue
        retry
      end

      return password
    end

    def input_password
      input = proc { |stdin|
        raw = stdin.gets
        return nil if raw.nil?

        password = raw.strip
        @out.puts

        return password
      }

      begin
        @in.noecho { |stdin|
          input.call stdin
        }
      rescue Errno::EBADF
        # This can happen when stdin refers to a file or pipe.
        # In this case, we ignore 'no echo' and do normal input.
        input.call @in
      end
    end

    def prompt_confirm(prompt)
      begin
        @out.print prompt

        confirm = @in.gets.strip

        if confirm =~ /\Ay/i
          return true
        elsif confirm =~ /\An/i
          return false
        end
      rescue
        retry
      end
    end

    def usage
      @err.puts "tome version #{VERSION}"
      @err.puts
      @err.puts $usage
    end

    def tome_connect
      if !Tome.exists?(@tome_filename)
        raise CommandError, "Tome database does not exist. Use 'tome set' or 'tome generate' to create a password first."
      end

      begin
        @err.print 'Master password: '
        master_password = input_password()
        tome = Tome.new(@tome_filename, master_password)
      rescue MasterPasswordError
        @err.puts 'Incorrect master password.'

        if master_password.nil?
          raise CommandError, 'Authentication failed.'
        else
          retry
        end
      end

      return tome
    end

    def tome_create_connect
      if !Tome.exists?(@tome_filename)
        @out.puts 'Creating tome database.'
        master_password = prompt_password('Master password')
        return true, Tome.create!(@tome_filename, master_password)
      else
        return false, tome_connect()
      end
    end
  end
end