Skip to content

Commit

Permalink
Prettify diff generated by git for encripted file:
Browse files Browse the repository at this point in the history
- @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>
  • Loading branch information
Edouard-chin committed Jul 26, 2019
1 parent ec7aa03 commit 5a4acf7
Show file tree
Hide file tree
Showing 4 changed files with 185 additions and 9 deletions.
55 changes: 55 additions & 0 deletions railties/lib/rails/command/helpers/pretty_credentials.rb
@@ -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
34 changes: 28 additions & 6 deletions railties/lib/rails/commands/credentials/credentials_command.rb
Expand Up @@ -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"
Expand All @@ -34,20 +37,29 @@ def edit
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
Expand Down Expand Up @@ -77,7 +89,6 @@ def missing_credentials_message
end
end


def content_path
options[:environment] ? "config/credentials/#{options[:environment]}.yml.enc" : "config/credentials.yml.enc"
end
Expand All @@ -86,6 +97,17 @@ def key_path
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"
Expand Down
101 changes: 100 additions & 1 deletion railties/test/commands/credentials_test.rb
Expand Up @@ -4,6 +4,7 @@
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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -128,8 +226,9 @@ def run_edit_command(editor: "cat", environment: nil, **options)
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
4 changes: 2 additions & 2 deletions railties/test/isolation/abstract_unit.rb
Expand Up @@ -301,7 +301,7 @@ def engine(name)
# 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

Expand All @@ -328,7 +328,7 @@ def rails(*args, allow_failure: false, stderr: false)
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)

Expand Down

0 comments on commit 5a4acf7

Please sign in to comment.