Prettify diff generated by git for encripted file:

- @sinsoku had the idea and started implementing it few months ago
  but sadly didn't finish it.
  This PR is taking over his work.

  The credentials feature has changed a lot since @sinsoku opened hi
  PR, it was easier to just restart from scratch instead of checking
  out his branch.
  Sinsoku will get all the credit he deserves for this idea :)

  TL;DR on that that feature is to make the `git diff` or `git log`
  of encrypted files to be readable.

  The previous implementation was only setting up the git required
  configuration for the first time Rails was bootstraped, so I decided
  to instead provide the user a choice to opt-in for readable diff
  credential whenever a user types the `bin/rails credentials:edit`
  command.
  The question won't be asked in the future the user has already
  answered or if the user already opted in.

  Co-authored-by: Takumi Shotoku <insoku.listy@gmail.com>
This commit is contained in:
Edouard CHIN 2019-07-26 15:56:49 +02:00
parent ec7aa03c98
commit 5a4acf7ac4
4 changed files with 185 additions and 9 deletions

View File

@ -0,0 +1,55 @@
# frozen_string_literal: true
require "fileutils"
module Rails
module Command
module Helpers
module PrettyCredentials
Error = Class.new(StandardError)
def opt_in_pretty_credentials
unless already_answered? || already_opted_in?
answer = yes?("Would you like to make the credentials diff from git more readable in the future? [Y/n]")
end
opt_in! if answer
FileUtils.touch(tracker) unless answer.nil?
rescue Error
say("Couldn't setup git to prettify the credentials diff")
end
private
def already_answered?
tracker.exist?
end
def already_opted_in?
system_call("git config --get 'diff.rails_credentials.textconv'", accepted_codes: [0, 1])
end
def opt_in!
system_call("git config diff.rails_credentials.textconv 'bin/rails credentials:show'", accepted_codes: [0])
git_attributes = Rails.root.join(".gitattributes")
File.open(git_attributes, "a+") do |file|
file.write(<<~EOM)
config/credentials/*.yml.enc diff=rails_credentials
config/credentials.yml.enc diff=rails_credentials
EOM
end
end
def tracker
Rails.root.join("tmp", "rails_pretty_credentials")
end
def system_call(command_line, accepted_codes:)
result = system(command_line)
raise(Error) if accepted_codes.exclude?($?.exitstatus)
result
end
end
end
end
end

View File

@ -2,12 +2,15 @@
require "active_support"
require "rails/command/helpers/editor"
require "rails/command/helpers/pretty_credentials"
require "rails/command/environment_argument"
require "pathname"
module Rails
module Command
class CredentialsCommand < Rails::Command::Base # :nodoc:
include Helpers::Editor
include Helpers::PrettyCredentials
include EnvironmentArgument
self.environment_desc = "Uses credentials from config/credentials/:environment.yml.enc encrypted by config/credentials/:environment.key key"
@ -34,20 +37,29 @@ module Rails
end
say "File encrypted and saved."
opt_in_pretty_credentials
rescue ActiveSupport::MessageEncryptor::InvalidMessage
say "Couldn't decrypt #{content_path}. Perhaps you passed the wrong key?"
end
def show
extract_environment_option_from_argument(default_environment: nil)
def show(git_textconv_path = nil)
if git_textconv_path
default_environment = extract_environment_from_path(git_textconv_path)
fallback_message = File.read(git_textconv_path)
end
extract_environment_option_from_argument(default_environment: default_environment)
require_application!
say credentials.read.presence || missing_credentials_message
say credentials(git_textconv_path).read.presence || fallback_message || missing_credentials_message
rescue => e
raise(e) unless git_textconv_path
fallback_message
end
private
def credentials
Rails.application.encrypted(content_path, key_path: key_path)
def credentials(content = nil)
Rails.application.encrypted(content || content_path, key_path: key_path)
end
def ensure_encryption_key_has_been_added
@ -77,7 +89,6 @@ module Rails
end
end
def content_path
options[:environment] ? "config/credentials/#{options[:environment]}.yml.enc" : "config/credentials.yml.enc"
end
@ -86,6 +97,17 @@ module Rails
options[:environment] ? "config/credentials/#{options[:environment]}.key" : "config/master.key"
end
def extract_environment_from_path(path)
regex = %r{
([A-Za-z0-9]+) # match the environment
(?<!credentials) # don't match if file contains the word "credentials"
# in such case, the environment should be the default one
\.yml\.enc # look for `.yml.enc` file extension
}x
path.match(regex)
Regexp.last_match(1)
end
def encryption_key_file_generator
require "rails/generators"

View File

@ -4,6 +4,7 @@ require "isolation/abstract_unit"
require "env_helpers"
require "rails/command"
require "rails/commands/credentials/credentials_command"
require "fileutils"
class Rails::Command::CredentialsCommandTest < ActiveSupport::TestCase
include ActiveSupport::Testing::Isolation, EnvHelpers
@ -88,10 +89,107 @@ class Rails::Command::CredentialsCommandTest < ActiveSupport::TestCase
assert_match(/secret_key_base/, output)
end
test "edit ask the user to opt in to pretty credentials" do
assert_match(/Would you like to make the credentials diff from git/, run_edit_command)
end
test "edit doesn't ask the user to opt in to pretty credentials when alreasy asked" do
app_file("tmp/rails_pretty_credentials", "")
assert_no_match(/Would you like to make the credentials diff from git/, run_edit_command)
end
test "edit doesn't ask the user to opt in when user already opted in" do
content = <<~EOM
[diff "rails_credentials"]
textconv = bin/rails credentials:show
EOM
app_file(".git/config", content)
assert_no_match(/Would you like to make the credentials diff from git/, run_edit_command)
end
test "edit ask the user to opt in to pretty credentials, user accepts" do
file = File.open("foo", "w")
file.write("y")
file.rewind
run_edit_command(stdin: file.path)
git_attributes = app_path(".gitattributes")
expected = <<~EOM
config/credentials/*.yml.enc diff=rails_credentials
config/credentials.yml.enc diff=rails_credentials
EOM
assert(File.exist?(git_attributes))
assert_equal(expected, File.read(git_attributes))
Dir.chdir(app_path) do
assert_equal("bin/rails credentials:show\n", `git config --get 'diff.rails_credentials.textconv'`)
end
ensure
File.delete(file)
end
test "edit ask the user to opt in to pretty credentials, user refuses" do
file = File.open("foo", "w")
file.write("n")
file.rewind
run_edit_command(stdin: file.path)
git_attributes = app_path(".gitattributes")
assert_not(File.exist?(git_attributes))
ensure
File.delete(file)
end
test "show credentials" do
assert_match(/access_key_id: 123/, run_show_command)
end
test "show command when argument is provided (from git diff left file)" do
run_edit_command(environment: "development")
assert_match(/access_key_id: 123/, run_show_command("config/credentials/development.yml.enc"))
end
test "show command when argument is provided (from git diff right file)" do
run_edit_command(environment: "development")
dir = Dir.mktmpdir
file_path = File.join(dir, "KnAM4a_development.yml.enc")
file_content = File.read(app_path("config", "credentials", "development.yml.enc"))
File.write(file_path, file_content)
assert_match(/access_key_id: 123/, run_show_command(file_path))
ensure
FileUtils.rm_rf(dir)
end
test "show command when argument is provided (git diff) and filename is the master credentials" do
assert_match(/access_key_id: 123/, run_show_command("config/credentials.yml.enc"))
end
test "show command when argument is provided (git diff) and master key is not available" do
remove_file "config/master.key"
raw_content = File.read(app_path("config", "credentials.yml.enc"))
assert_match(raw_content, run_show_command("config/credentials.yml.enc"))
end
test "show command when argument is provided (git diff) return the raw encrypted content in an error occurs" do
run_edit_command(environment: "development")
dir = Dir.mktmpdir
file_path = File.join(dir, "20190807development.yml.enc")
file_content = File.read(app_path("config", "credentials", "development.yml.enc"))
File.write(file_path, file_content)
assert_match(file_content, run_show_command(file_path))
ensure
FileUtils.rm_rf(dir)
end
test "show command raises error when require_master_key is specified and key does not exist" do
remove_file "config/master.key"
add_to_config "config.require_master_key = true"
@ -128,8 +226,9 @@ class Rails::Command::CredentialsCommandTest < ActiveSupport::TestCase
end
end
def run_show_command(environment: nil, **options)
def run_show_command(path = nil, environment: nil, **options)
args = environment ? ["--environment", environment] : []
args.unshift(path)
rails "credentials:show", args, **options
end
end

View File

@ -301,7 +301,7 @@ module TestHelpers
# stderr:: true to pass STDERR output straight to the "real" STDERR.
# By default, the STDERR and STDOUT of the process will be
# combined in the returned string.
def rails(*args, allow_failure: false, stderr: false)
def rails(*args, allow_failure: false, stderr: false, stdin: File::NULL)
args = args.flatten
fork = true
@ -328,7 +328,7 @@ module TestHelpers
out_read.close
err_read.close if err_read
$stdin.reopen(File::NULL, "r")
$stdin.reopen(stdin, "r")
$stdout.reopen(out_write)
$stderr.reopen(err_write)