class Cipher

Constants

DEFAULT_GPG_USER
EXECUTABLE_EXTENSIONS
EXTENSION
STATUS
VALID_OPTIONS
VALID_SUBCOMMANDS

Public Class Methods

new() click to toggle source
# File bin/git-cipher, line 26
def initialize
  @subcommand, @options, @files = process_args

  if @options.include?('help') || @subcommand == 'help'
    usage(@subcommand)
  end

  @force = @options.include?('force')
end

Public Instance Methods

run() click to toggle source
# File bin/git-cipher, line 20
def run
  send @subcommand
end

Private Instance Methods

blue(string) click to toggle source
# File bin/git-cipher, line 36
def blue(string)
  colorize(string, 34)
end
check_ignored(path) click to toggle source
# File bin/git-cipher, line 40
def check_ignored(path)
  `#{escape command_path('git')} check-ignore -q #{escape path}`
  puts "[warning: #{path} is not ignored]" unless $?.exitstatus.zero?
end
colorize(string, color) click to toggle source
# File bin/git-cipher, line 45
def colorize(string, color)
  "\e[#{color}m#{string}\e[0m"
end
command_name() click to toggle source
# File bin/git-cipher, line 49
def command_name
  @command_name ||= begin
    if `ps -p #{Process.ppid.to_i}` =~ /\bgit cipher\b/
      'git cipher'
    else
      File.basename(__FILE__)
    end
  rescue
    File.basename(__FILE__)
  end
end
command_path(command) click to toggle source
# File bin/git-cipher, line 61
def command_path(command)
  path = `command -v #{escape command}`.chomp
  die "required dependency #{command} not found" if path.empty?
  path
end
decrypt() click to toggle source
# File bin/git-cipher, line 67
def decrypt
  if @files.empty?
    puts 'No explicit paths supplied: decrypting all matching files'
    matching.each { |file| decrypt!(file) }
  else
    @files.each { |file| decrypt!(file) }
  end
end
decrypt!(file) click to toggle source
# File bin/git-cipher, line 76
def decrypt!(file)
  pathname = Pathname.new(file)
  basename = pathname.basename.to_s
  unless basename.start_with?('.')
    die "#{file} does not begin with a period"
  end

  unless basename.end_with?(".#{EXTENSION}")
    die "#{file} does not have an .#{EXTENSION} extension"
  end

  unless pathname.exist?
    die "#{file} does not exist"
  end

  outfile = pathname.dirname + basename.gsub(
    /\A\.|\.#{EXTENSION}\z/, ''
  )

  print "#{file} -> #{outfile} "

  if FileUtils.uptodate?(outfile, [file]) && !@force
    # decrypted is newer than encrypted; it might have local changes which we
    # could blow away, so warn
    puts red('[warning: plain-text newer than ciphertext; skipping]')
  else
    print green('[decrypting ...')
    gpg_decrypt(file, outfile)
    if $?.success?
      puts green(' done]')

      File.chmod(mode(outfile), outfile)

      # Mark plain-text as older than ciphertext, this will prevent a
      # bin/encrypt run from needlessly changing the contents of the ciphertext
      # (as the encryption is non-deterministic).
      time = File.mtime(file) - 1
      File.utime(time, time, outfile)
    else
      print kill_line
      puts red('[decrypting ... failed; bailing]')
      exit $?.exitstatus
    end

    check_ignored(outfile)
  end
end
die(msg) click to toggle source
# File bin/git-cipher, line 124
def die(msg)
  STDERR.puts red('error:'), strip_heredoc(msg)
  exit 1
end
encrypt() click to toggle source
# File bin/git-cipher, line 129
def encrypt
  if @files.empty?
    puts 'No explicit paths supplied: encrypting all matching files'
    matching.each do |file|
      file = Pathname.new(file)
      encrypt!(
        file.dirname +
        file.basename.to_s.gsub(/\A\.|\.#{EXTENSION}\z/, '')
      )
    end
  else
    @files.each { |file| encrypt!(Pathname.new(file)) }
  end
end
encrypt!(file) click to toggle source
# File bin/git-cipher, line 144
def encrypt!(file)
  unless file.exist?
    die "#{file} does not exist"
  end

  outfile = file.dirname + ".#{file.basename}.#{EXTENSION}"

  print "#{file} -> #{outfile} "
  if FileUtils.uptodate?(outfile, [file]) && !@force
    puts blue('[up to date]')
  else
    print green('[encrypting ...')
    execute(%{
      #{escape command_path('gpg')}
        -a
        -q
        --batch
        --no-tty
        --yes
        -r #{escape gpg_user}
        -o #{escape outfile}
        -e #{escape file}
    })
    if $?.success?
      puts green(' done]')
    else
      print kill_line
      puts red('[encrypting ... failed; bailing]')
      exit $?.exitstatus
    end
  end

  File.chmod(mode(file), file)
  check_ignored(file)
end
escape(string) click to toggle source
# File bin/git-cipher, line 180
def escape(string)
  Shellwords.escape(string)
end
execute(string) click to toggle source
# File bin/git-cipher, line 184
def execute(string)
  %x{#{string.gsub("\n", ' ')}}
end
get_config(key) click to toggle source
# File bin/git-cipher, line 188
def get_config(key)
  value = `#{escape command_path('git')} config cipher.#{key}`.chomp
  return if value.empty?
  value
end
get_passphrase() click to toggle source
# File bin/git-cipher, line 194
def get_passphrase
  stty = escape(command_path('stty'))
  print 'Passphrase [will not be echoed]: '
  `#{stty} -echo` # quick hack, cheaper than depending on highline gem
  STDIN.gets.chomp
ensure
  `#{stty} echo`
  puts
end
gpg_decrypt(file, outfile) click to toggle source
# File bin/git-cipher, line 204
def gpg_decrypt(file, outfile)
  execute(%{
    #{escape command_path('gpg')}
      -q
      --yes
      --batch
      --no-tty
      --use-agent
      -o #{escape outfile}
      -d #{escape file}
  })
end
gpg_user() click to toggle source
# File bin/git-cipher, line 217
def gpg_user
  ENV['GPG_USER'] || get_config('gpguser') || DEFAULT_GPG_USER
end
green(string) click to toggle source
# File bin/git-cipher, line 221
def green(string)
  colorize(string, 32)
end
kill_line() click to toggle source
# File bin/git-cipher, line 225
def kill_line
  # 2K deletes the line, 0G moves to column 0
  # see: http://en.wikipedia.org/wiki/ANSI_escape_code
  "\e[2K\e[0G"
end
log() click to toggle source
# File bin/git-cipher, line 231
def log
  if @files.empty?
    # TODO: would be nice to interleave these instead of doing them serially.
    puts 'No explicit paths supplied: logging all matching files'
    matching.each { |file| log!(file) }
  else
    @files.each { |file| log!(file) }
  end
end
log!(file) click to toggle source
# File bin/git-cipher, line 241
def log!(file)
  commits = execute(%{
    #{escape command_path('git')} log
    --pretty=format:%H -- #{escape file}
  }).split
  suffix = "-#{File.basename(file)}"

  commits.each do |commit|
    files = []
    begin
      # Get plaintext "post" image.
      files.push(post = temp_write(show(file, commit)))
      files.push(
        post_plaintext = temp_write(gpg_decrypt(post.path, '-'), suffix)
      )

      # Get plaintext "pre" image.
      files.push(pre = temp_write(show(file, "#{commit}~")))
      files.push(pre_plaintext = temp_write(
        pre.size.zero? ? '' : gpg_decrypt(pre.path, '-'),
        suffix
      ))

      # Print commit message.
      puts execute(%{
        #{escape command_path('git')} --no-pager log
        --color=always -1 #{commit}
      })
      puts

      # Print pre-to-post diff.
      puts execute(%{
        #{escape command_path('git')} --no-pager diff
        --color=always
        #{escape pre_plaintext.path}
        #{escape post_plaintext.path}
      })
      puts

    ensure
      files.each do |tempfile|
        tempfile.close
        tempfile.unlink
      end
    end
  end
end
ls() click to toggle source
# File bin/git-cipher, line 289
def ls
  matching.each { |file| puts file }
end
matching() click to toggle source
# File bin/git-cipher, line 318
def matching
  Dir.glob("**/*.#{EXTENSION}", File::FNM_DOTMATCH)
end
mode(file) click to toggle source

Determine the appropriate mode for the given decrypted plaintext `file` based on its file extension.

# File bin/git-cipher, line 324
def mode(file)
  if EXECUTABLE_EXTENSIONS.include?(Pathname.new(file).extname)
    0700
  else
    0600
  end
end
normalize_option(option) click to toggle source
# File bin/git-cipher, line 332
def normalize_option(option)
  normal = option.dup

  if normal.sub!(/\A--/, '') # long option
    found = VALID_OPTIONS.find { |o| o == normal }
  elsif normal.sub!(/\A-/, '') # short option
    found = VALID_OPTIONS.find { |o| o[0] == normal }
  end

  die "unrecognized option: #{option}" if found.nil?

  found
end
process_args() click to toggle source
# File bin/git-cipher, line 346
def process_args
  options, files = ARGV.partition { |arg| arg.start_with?('-') }
  subcommand = files.shift

  options.map! { |option| normalize_option(option) }

  unless VALID_SUBCOMMANDS.include?(subcommand)
    if subcommand.nil?
      message = 'no subcommand'
    else
      message = 'unrecognized subcommand'
    end
    die [message, "expected one of #{VALID_SUBCOMMANDS.inspect}"].join(': ')
  end

  [subcommand, options, files]
end
red(string) click to toggle source
# File bin/git-cipher, line 364
def red(string)
  colorize(string, 31)
end
show(file, commit) click to toggle source
# File bin/git-cipher, line 368
def show(file, commit)
  # Redirect stderr to /dev/null because the file might not have existed prior
  # to this commit.
  execute(%{
    #{escape command_path('git')} show
    #{commit}:#{escape file} 2> /dev/null
  })
end
status() click to toggle source
# File bin/git-cipher, line 293
def status
  exitstatus = 0
  matching.each do |file|
    pathname = Pathname.new(file)
    basename = pathname.basename.to_s
    outfile = pathname.dirname + basename.gsub(
      /\A\.|\.#{EXTENSION}\z/, ''
    )
    if outfile.exist?
      if FileUtils.uptodate?(outfile, [file])
        # Plain-text is newer than ciphertext.
        description = yellow('[MODIFIED]')
        exitstatus |= STATUS['MODIFIED']
      else
        description = green('[OK]')
      end
    else
      description = red('[MISSING]')
      exitstatus |= STATUS['MISSING']
    end
    puts "#{file}: #{description}"
  end
  exit exitstatus
end
strip_heredoc(doc) click to toggle source
# File bin/git-cipher, line 377
def strip_heredoc(doc)
  # based on method of same name from Rails
  indent = doc.scan(/^[ \t]*(?=\S)/).map(&:size).min || 0
  doc.gsub(/^[ \t]{#{indent}}/, '')
end
temp_write(contents, suffix = '') click to toggle source
# File bin/git-cipher, line 383
def temp_write(contents, suffix = '')
  file = Tempfile.new(['git-cipher-', suffix])
  file.write(contents)
  file.flush
  file
end
usage(subcommand) click to toggle source

Print usage information and exit.

# File bin/git-cipher, line 391
  def usage(subcommand)
    case subcommand
    when 'decrypt'
      puts strip_heredoc(<<-USAGE)
        #{command_name} decrypt [-f|--force] [FILES...]

        Decrypts files that have been encrypted for storage in version control

            Decrypt two files, but only if the corresponding plain-text files
            are missing or older:

                #{command_name} decrypt .foo.encrypted .bar.encrypted

            Decrypt all decryptable files:

                #{command_name} decrypt

            (Re-)decrypt all decryptable files, even those whose corresponding
            plain-text files are newer:

                #{command_name} decrypt -f
                #{command_name} decrypt --force # (alternative syntax)
      USAGE
    when 'encrypt'
      puts strip_heredoc(<<-USAGE)
        #{command_name} encrypt [-f|--force] [FILES...]

        Encrypts files for storage in version control

            Encrypt two files, but only if the corresponding ciphertext files
            are missing or older:

                #{command_name} encrypt foo bar

            Encrypt all encryptable files:

                #{command_name} encrypt

            (Re-)encrypt all encryptable files, even those whose corresponding
            ciphertext files are newer:

                #{command_name} encrypt -f
                #{command_name} encrypt --force # (alternative syntax)
      USAGE
    when 'log'
      puts strip_heredoc(<<-USAGE)
        #{command_name} log FILE

          Shows the log message and decrypted diff for FILE
          (analogous to `git log -p -- FILE`).

              #{command_name} log foo
      USAGE
    when 'ls'
      puts strip_heredoc(<<-USAGE)
        #{command_name} ls

          Lists the encrypted files in the current directory and
          its subdirectories.
      USAGE
    when 'status'
      puts strip_heredoc(<<-USAGE)
        #{command_name} status

          Shows the status of encrypted files in the current directory and
          its subdirectories.

          Exits with status #{STATUS['MISSING']} if any decrypted file is missing.
          Exits with status #{STATUS['MODIFIED']} if any decrypted file is modified.
          Exits with status #{STATUS['MISSING'] | STATUS['MODIFIED']} if both of the above apply.
      USAGE
    else
      puts strip_heredoc(<<-USAGE)
        Available commands (invoke any with -h or --help for more info):

            #{command_name} decrypt
            #{command_name} encrypt
            #{command_name} log
            #{command_name} ls
            #{command_name} status
      USAGE
    end

    exit
  end
yellow(string) click to toggle source
# File bin/git-cipher, line 477
def yellow(string)
  colorize(string, 33)
end