diff --git a/.gitignore b/.gitignore index 2bb7f841..0437b62f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ coverage/ pkg/ .bundle .idea +/spec_example_status diff --git a/lib/overcommit.rb b/lib/overcommit.rb index 15fef94f..0dda5dab 100644 --- a/lib/overcommit.rb +++ b/lib/overcommit.rb @@ -24,3 +24,4 @@ require 'overcommit/installer' require 'overcommit/logger' require 'overcommit/version' +require 'overcommit/signature' diff --git a/lib/overcommit/configuration.rb b/lib/overcommit/configuration.rb index ae3efa50..a90d9231 100644 --- a/lib/overcommit/configuration.rb +++ b/lib/overcommit/configuration.rb @@ -190,12 +190,12 @@ def plugin_hook?(hook_context_or_type, hook_name) File.exist?(File.join(plugin_directory, hook_type_name, "#{hook_name}.rb")) end - # Return whether the signature for this configuration has changed since it - # was last calculated. + # Return whether the signature for this configuration has been verified. # # @return [true,false] - def signature_changed? - signature != stored_signature + def signature_verified? + signature_unchanged = signature == stored_signature + signature_unchanged || Overcommit::Signature.verified?(object_signature) end # Return whether a previous signature has been recorded for this @@ -214,37 +214,22 @@ def verify_signatures? return false if ENV['OVERCOMMIT_NO_VERIFY'] return true if @hash['verify_signatures'] != false - result = Overcommit::Utils.execute( - %W[git config --local --get #{verify_signature_config_key}] - ) - - if result.status == 1 # Key doesn't exist - return true - elsif result.status != 0 - raise Overcommit::Exceptions::GitConfigError, - "Unable to read from local repo git config: #{result.stderr}" + verify = Overcommit::GitRepo.get_local_config(verify_signature_config_key) + if verify.nil? + true + else + # We don't cast since we want to allow anything to count as "true" except + # a literal zero + verify.strip != '0' end - - # We don't cast since we want to allow anything to count as "true" except - # a literal zero - result.stdout.strip != '0' end # Update the currently stored signature for this hook. def update_signature! - result = Overcommit::Utils.execute( - %w[git config --local] + [signature_config_key, signature] - ) - + Overcommit::Signature.verify(object_signature) + Overcommit::GitRepo.set_local_config(signature_config_key, signature) verify_signature_value = @hash['verify_signatures'] ? 1 : 0 - result &&= Overcommit::Utils.execute( - %W[git config --local #{verify_signature_config_key} #{verify_signature_value}] - ) - - unless result.success? - raise Overcommit::Exceptions::GitConfigError, - "Unable to write to local repo git config: #{result.stderr}" - end + Overcommit::GitRepo.set_local_config(verify_signature_config_key, verify_signature_value) end protected @@ -315,28 +300,32 @@ def smart_merge(parent, child) # # @return [String] def signature - Digest::SHA256.hexdigest(@hash.to_json) + Overcommit::Signature.sign(@hash.to_json) end - # Returns the stored signature of this repo's Overcommit configuration. + # Returns the git object ID of this configuration. + # + # @return [String] + def git_object_id + Overcommit::GitRepo.blob_id(@hash.to_json) + end + + # Returns the object signature of this configuration. + # + # @return [Hash] + def object_signature + Overcommit::Signature.object_signature(@hash.to_json) + end + + # Returns the last stored signature of this repo's Overcommit configuration. # # This is intended to be compared against the current signature of this # configuration object. # # @return [String] def stored_signature - result = Overcommit::Utils.execute( - %w[git config --local --get] + [signature_config_key] - ) - - if result.status == 1 # Key doesn't exist - return '' - elsif result.status != 0 - raise Overcommit::Exceptions::GitConfigError, - "Unable to read from local repo git config: #{result.stderr}" - end - - result.stdout.chomp + signature = Overcommit::GitRepo.get_local_config(signature_config_key) + signature || '' end def signature_config_key diff --git a/lib/overcommit/configuration_loader.rb b/lib/overcommit/configuration_loader.rb index f884c41d..7659259b 100644 --- a/lib/overcommit/configuration_loader.rb +++ b/lib/overcommit/configuration_loader.rb @@ -86,7 +86,7 @@ def verify_signatures(config) "No previously recorded signature for configuration file.\n" \ 'Run `overcommit --sign` if you trust the hooks in this repository.' - elsif config.signature_changed? + elsif !config.signature_verified? raise Overcommit::Exceptions::ConfigurationSignatureChanged, "Signature of configuration file has changed!\n" \ "Run `overcommit --sign` once you've verified the configuration changes." diff --git a/lib/overcommit/exceptions.rb b/lib/overcommit/exceptions.rb index 3246c567..8a9dbd32 100644 --- a/lib/overcommit/exceptions.rb +++ b/lib/overcommit/exceptions.rb @@ -16,6 +16,12 @@ class GitSubmoduleError < StandardError; end # Raised when there was a problem reading git revision information with `rev-list`. class GitRevListError < StandardError; end + # Raised when there was a problem computing the ID of git repository objects. + class GitHashObjectError < StandardError; end + + # Raised when there was a problem retrieving information from git repository objects. + class GitCatFileError < StandardError; end + # Raised when a {HookContext} is unable to setup the environment before a run. class HookSetupFailed < StandardError; end diff --git a/lib/overcommit/git_repo.rb b/lib/overcommit/git_repo.rb index bd659eb7..0f4300de 100644 --- a/lib/overcommit/git_repo.rb +++ b/lib/overcommit/git_repo.rb @@ -282,5 +282,75 @@ def branches_containing_commit(commit_ref) def current_branch `git symbolic-ref --short -q HEAD`.chomp end + + # Get the value of a local configuration option. + # + # @param name [String] the name of the option + # @return [String, nil] the value of the option or _nil_ if the option does not exist + def get_local_config(name) + result = Overcommit::Utils.execute( + %w[git config --local --get] << name + ) + if result.status == 1 # Key doesn't exist + return nil + elsif result.status != 0 + raise Overcommit::Exceptions::GitConfigError, + "Unable to read from local repo git config: #{result.stderr}" + end + + result.stdout.chomp + end + + # Sets the value of a local configuration option. + # + # @param name [String] the name of the option + # @param value [String] the value to set + def set_local_config(name, value) + result = Overcommit::Utils.execute( + %W[git config --local #{name} #{value}] + ) + unless result.success? + raise Overcommit::Exceptions::GitConfigError, + "Unable to write to local repo git config: #{result.stderr}" + end + end + + # Computes the git object ID for the given blob contents and optionally writes that blob to the + # git repository. + # + # @param blob [String] the blob contents + # @param write [Boolean] true if this blob should be written to the git repository + # @return [String] the git object ID + def blob_id(blob, write = false) + write_args = write ? %w[-w] : [] + result = Overcommit::Utils.execute( + %w[git hash-object] + write_args + %w[--stdin], + input: blob + ) + if result.success? + result.stdout.chomp + else + raise Overcommit::Exceptions::GitHashObjectError, + "Failed to compute blob ID: #{result.stderr}" + end + end + + # Retrieves the contents of a blob from the git repository. + # + # @param blob_id [String] the blob ID + # @return [String, nil] the contents of the blob or _nil_ if it does not exist + def blob_contents(blob_id) + result = Overcommit::Utils.execute( + %W[git cat-file -p #{blob_id}^{blob}] + ) + if result.success? + result.stdout.chomp + elsif result.status == 128 # 128 is returned when the blob does not exist. + nil + else + raise Overcommit::Exceptions::GitCatFileError, + "Failed to get blob contents: #{result.stderr}" + end + end end end diff --git a/lib/overcommit/hook_loader/plugin_hook_loader.rb b/lib/overcommit/hook_loader/plugin_hook_loader.rb index b57d7095..154cb1ff 100644 --- a/lib/overcommit/hook_loader/plugin_hook_loader.rb +++ b/lib/overcommit/hook_loader/plugin_hook_loader.rb @@ -7,7 +7,7 @@ module Overcommit::HookLoader # is running in. class PluginHookLoader < Base def load_hooks - check_for_modified_plugins if @config.verify_signatures? + check_for_unverified_plugins if @config.verify_signatures? hooks = plugin_paths.map do |plugin_path| require plugin_path @@ -22,9 +22,9 @@ def load_hooks end def update_signatures - log.success('No plugin signatures have changed') if modified_plugins.empty? + log.success('All plugin signatures have been verified') if unverified_plugins.empty? - modified_plugins.each do |plugin| + unverified_plugins.each do |plugin| plugin.update_signature! log.warning "Updated signature of plugin #{plugin.hook_name}" end @@ -47,20 +47,20 @@ def ad_hoc_hook_names @config.enabled_ad_hoc_hooks(@context) end - def modified_plugins + def unverified_plugins (plugin_hook_names + ad_hoc_hook_names). map { |hook_name| Overcommit::HookSigner.new(hook_name, @config, @context) }. - select(&:signature_changed?) + reject(&:signature_verified?) end - def check_for_modified_plugins - return if modified_plugins.empty? + def check_for_unverified_plugins + return if unverified_plugins.empty? log.bold_warning "The following #{@context.hook_script_name} plugins " \ 'have been added, changed, or had their configuration modified:' log.newline - modified_plugins.each do |signer| + unverified_plugins.each do |signer| log.warning " * #{signer.hook_name} in #{signer.hook_path}" end diff --git a/lib/overcommit/hook_signer.rb b/lib/overcommit/hook_signer.rb index f432ca48..ebf408d0 100644 --- a/lib/overcommit/hook_signer.rb +++ b/lib/overcommit/hook_signer.rb @@ -57,24 +57,18 @@ def signable_file?(file) file.start_with?(Overcommit::Utils.repo_root) end - # Return whether the signature for this hook has changed since it was last - # calculated. + # Return whether the signature for this hook has been verified. # # @return [true,false] - def signature_changed? - signature != stored_signature + def signature_verified? + signature_unchanged = signature == stored_signature + signature_unchanged || Overcommit::Signature.verified?(object_signatures) end # Update the current stored signature for this hook. def update_signature! - result = Overcommit::Utils.execute( - %w[git config --local] + [signature_config_key, signature] - ) - - unless result.success? - raise Overcommit::Exceptions::GitConfigError, - "Unable to write to local repo git config: #{result.stderr}" - end + Overcommit::Signature.verify(object_signatures) + Overcommit::GitRepo.set_local_config(signature_config_key, signature) end private @@ -85,16 +79,32 @@ def update_signature! # This way, if either the plugin code changes or its configuration changes, # the hash will change and we can alert the user to this change. def signature - hook_config = @config.for_hook(@hook_name, @context.hook_class_name). - dup. - tap { |config| IGNORED_CONFIG_KEYS.each { |k| config.delete(k) } } + Overcommit::Signature.sign_signatures(object_signatures) + end - content_to_sign = - if signable_file?(hook_path) && Overcommit::GitRepo.tracked?(hook_path) - hook_contents - end + # Returns the object signatures of this configuration. + # + # @return [Hash] + def object_signatures + sig = Overcommit::Signature.object_signature(hook_config.to_json) + content = content_to_sign + if content.nil? + sig + else + sig.merge(Overcommit::Signature.object_signature(content)) + end + end + + def hook_config + @config.for_hook(@hook_name, @context.hook_class_name). + dup. + tap { |config| IGNORED_CONFIG_KEYS.each { |k| config.delete(k) } } + end - Digest::SHA256.hexdigest(content_to_sign.to_s + hook_config.to_s) + def content_to_sign + if signable_file?(hook_path) && Overcommit::GitRepo.tracked?(hook_path) + hook_contents + end end def hook_contents @@ -102,18 +112,8 @@ def hook_contents end def stored_signature - result = Overcommit::Utils.execute( - %w[git config --local --get] + [signature_config_key] - ) - - if result.status == 1 # Key doesn't exist - return '' - elsif result.status != 0 - raise Overcommit::Exceptions::GitConfigError, - "Unable to read from local repo git config: #{result.stderr}" - end - - result.stdout.chomp + signature = Overcommit::GitRepo.get_local_config(signature_config_key) + signature || '' end def signature_config_key diff --git a/lib/overcommit/signature.rb b/lib/overcommit/signature.rb new file mode 100644 index 00000000..a3e40dd8 --- /dev/null +++ b/lib/overcommit/signature.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +require 'digest' + +module Overcommit + # Calculates, stores, and retrieves stored signatures of git objects. + module Signature + class << self + # Calculates the signature of some _String_. + # + # @param arg [String] + # @return [String] + def sign(arg) + Digest::SHA256.hexdigest(arg) + end + + # Calculates the signature of some _Hash_. + # + # @param arg [Hash] + # @return [String] + def sign_signatures(arg) + sign(to_blob(arg)) + end + + # Returns the object signature for some blob. + # + # @return [Hash] + def object_signature(blob) + { Overcommit::GitRepo.blob_id(blob) => sign(blob) } + end + + # Stores the object signatures as verified. + # + # @param object_signatures [Hash] a hash of git object hashes to their signatures + def verify(object_signatures) + blob = to_blob(object_signatures) + Overcommit::GitRepo.blob_id(blob, write: true) + end + + # Have the given object signatures been verified? + # + # @param object_signatures [Hash] a hash of git object hashes to their signatures + # @return [true,false] + def verified?(object_signatures) + blob = to_blob(object_signatures) + object_signatures_id = Overcommit::GitRepo.blob_id(blob) + existing_blob = Overcommit::GitRepo.blob_contents(object_signatures_id) + # Compare the contents in case of a SHA1 collision. + existing_blob == blob + end + + private + + # Converts a Hash of git object hashes and their signatures to a signature blob. + # + # @example + # rubocop:disable Metrics/LineLength + # Converts from: + # { + # "333EC19DD1707BA9D10EB18913C7214D701691BB" => "2938858697BE5C7552CD9463DA010E01F01AC3B8AD65FE584975CE4F2FFC1E7E", + # "426637B5CCDA600060C504CCB133D1976576E594" => "9526B9B463BDC1E856C12518998C704D070C0C217F2896959DB22DBEE7345684" + # } + # to: + # <<~SIGNATURES.chomp + # 333EC19DD1707BA9D10EB18913C7214D701691BB 2938858697BE5C7552CD9463DA010E01F01AC3B8AD65FE584975CE4F2FFC1E7E + # 426637B5CCDA600060C504CCB133D1976576E594 9526B9B463BDC1E856C12518998C704D070C0C217F2896959DB22DBEE7345684 + # SIGNATURES + # rubocop:enable Metrics/LineLength + # + # @param object_signatures [Hash] + # @return [String] + def to_blob(object_signatures) + lines = object_signatures.map do |git_object_hash, signature| + "#{git_object_hash} #{signature}" + end + lines.join("\n") + end + + # Converts a signature blob to a Hash of git object hashes and their signatures. + # + # @example + # rubocop:disable Metrics/LineLength + # Converts from: + # <<~SIGNATURES.chomp + # 333EC19DD1707BA9D10EB18913C7214D701691BB 2938858697BE5C7552CD9463DA010E01F01AC3B8AD65FE584975CE4F2FFC1E7E + # 426637B5CCDA600060C504CCB133D1976576E594 9526B9B463BDC1E856C12518998C704D070C0C217F2896959DB22DBEE7345684 + # SIGNATURES + # to: + # { + # "333EC19DD1707BA9D10EB18913C7214D701691BB" => "2938858697BE5C7552CD9463DA010E01F01AC3B8AD65FE584975CE4F2FFC1E7E", + # "426637B5CCDA600060C504CCB133D1976576E594" => "9526B9B463BDC1E856C12518998C704D070C0C217F2896959DB22DBEE7345684" + # } + # rubocop:enable Metrics/LineLength + # + # @param blob [String] + # @return [Hash] + def to_hash(blob) + Hash[blob.split('\n').each_slice.map { |line| line.split(' ') }] + end + end + end +end diff --git a/spec/overcommit/hook_signer_spec.rb b/spec/overcommit/hook_signer_spec.rb index 98862fd0..8bef1090 100644 --- a/spec/overcommit/hook_signer_spec.rb +++ b/spec/overcommit/hook_signer_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' describe Overcommit::HookSigner do - describe '#signature_changed?' do + describe '#signature_verified?' do let(:config) { double('config') } let(:context) { double('context') } @@ -11,10 +11,11 @@ described_class.new('.git-hooks/pre_commit/some_path.rb', config, context) end - let(:hook_config) { { 'enabled' => false } } + let(:original_hook_config) { { 'enabled' => false } } + let(:hook_config) { original_hook_config } let(:modified_hook_config) { hook_config } - let(:hook_contents) { <<-RUBY } + let(:original_hook_contents) { <<-RUBY } module Overcommit::Hook::PreCommit class SomeHook def run @@ -23,10 +24,10 @@ def run end end RUBY - + let(:hook_contents) { original_hook_contents } let(:modified_hook_contents) { hook_contents } - subject { signer.signature_changed? } + subject { signer.signature_verified? } around do |example| repo do @@ -49,17 +50,7 @@ def run signer.stub(:hook_contents).and_return(modified_hook_contents) end - context 'when the hook code and config are the same' do - it { should == false } - - context 'and the user has specified they wish to skip the hook' do - let(:modified_hook_config) { hook_config.merge('skip' => true) } - - it { should == false } - end - end - - context 'when the hook code has changed' do + shared_context 'hook code has changed' do let(:modified_hook_contents) { <<-RUBY } module Overcommit::Hook::PreCommit class SomeHook @@ -69,14 +60,70 @@ def run end end RUBY + end - it { should == true } + shared_context 'hook code has changed back' do + include_context 'hook code has changed' + before do + signer.update_signature! + config.stub(:for_hook).and_return(modified_hook_config) + signer.stub(:hook_contents).and_return(original_hook_contents) + end end - context 'when the hook config has changed' do + shared_context 'hook config has changed' do let(:modified_hook_config) { { 'enabled' => true } } + end + shared_context 'hook config has changed back' do + include_context 'hook config has changed' + before do + signer.update_signature! + config.stub(:for_hook).and_return(original_hook_config) + signer.stub(:hook_contents).and_return(modified_hook_contents) + end + end + + shared_examples 'signature has been verified' do it { should == true } end + + shared_examples 'signature has not been verified' do + it { should == false } + end + + context 'when the hook code and config are the same' do + include_examples 'signature has been verified' + + context 'and the user has specified they wish to skip the hook' do + let(:modified_hook_config) { hook_config.merge('skip' => true) } + + include_examples 'signature has been verified' + end + end + + context 'when the hook code has changed' do + include_context 'hook code has changed' + + include_examples 'signature has not been verified' + end + + context 'when the hook code has changed back to a previously verified signature' do + include_context 'hook code has changed back' + + include_examples 'signature has been verified' + end + + context 'when the hook config has changed' do + include_context 'hook config has changed' + + include_examples 'signature has not been verified' + end + + context 'when the hook config has changed back' do + include_context 'hook config has changed back' + + include_examples 'signature has been verified' + end end end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index f0d93a7f..5560fe79 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -62,4 +62,6 @@ Overcommit::Utils.instance_variable_set(:"@#{var}", nil) end end + + config.example_status_persistence_file_path = 'spec_example_status' end