From 5a4acf7ac42c44a17a645e8d3682fd0cc145428d Mon Sep 17 00:00:00 2001
From: Edouard CHIN <edouard.chin@shopify.com>
Date: Fri, 26 Jul 2019 15:56:49 +0200
Subject: 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>
---
 .../rails/command/helpers/pretty_credentials.rb    |  55 +++++++++++
 .../commands/credentials/credentials_command.rb    |  34 +++++--
 railties/test/commands/credentials_test.rb         | 101 ++++++++++++++++++++-
 railties/test/isolation/abstract_unit.rb           |   4 +-
 4 files changed, 185 insertions(+), 9 deletions(-)
 create mode 100644 railties/lib/rails/command/helpers/pretty_credentials.rb

(limited to 'railties')

diff --git a/railties/lib/rails/command/helpers/pretty_credentials.rb b/railties/lib/rails/command/helpers/pretty_credentials.rb
new file mode 100644
index 0000000000..873ed0e825
--- /dev/null
+++ b/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
diff --git a/railties/lib/rails/commands/credentials/credentials_command.rb b/railties/lib/rails/commands/credentials/credentials_command.rb
index e23a1b3008..772e105007 100644
--- a/railties/lib/rails/commands/credentials/credentials_command.rb
+++ b/railties/lib/rails/commands/credentials/credentials_command.rb
@@ -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"
diff --git a/railties/test/commands/credentials_test.rb b/railties/test/commands/credentials_test.rb
index 0ee36081c0..562e2ec382 100644
--- a/railties/test/commands/credentials_test.rb
+++ b/railties/test/commands/credentials_test.rb
@@ -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
diff --git a/railties/test/isolation/abstract_unit.rb b/railties/test/isolation/abstract_unit.rb
index 0fe62df8ba..6077ba3ee7 100644
--- a/railties/test/isolation/abstract_unit.rb
+++ b/railties/test/isolation/abstract_unit.rb
@@ -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)
 
-- 
cgit v1.2.3