diff --git a/config/feature_flags/development/verify_push_rules_for_first_commit.yml b/config/feature_flags/development/verify_push_rules_for_first_commit.yml new file mode 100644 index 0000000000000000000000000000000000000000..a09142b06d3a18bce57f49b58ab09fc8596dc162 --- /dev/null +++ b/config/feature_flags/development/verify_push_rules_for_first_commit.yml @@ -0,0 +1,8 @@ +--- +name: verify_push_rules_for_first_commit +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/123950 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/419128 +milestone: '16.3' +type: development +group: group::source code +default_enabled: false diff --git a/ee/spec/lib/gitlab/checks/diff_check_spec.rb b/ee/spec/lib/gitlab/checks/diff_check_spec.rb index d6c50715a0fc5ce27b9afd1d4c88a6622829abde..9bf9e4ca29541ebcb7ac1284b10f59fc6c5ab672 100644 --- a/ee/spec/lib/gitlab/checks/diff_check_spec.rb +++ b/ee/spec/lib/gitlab/checks/diff_check_spec.rb @@ -239,6 +239,42 @@ expect { subject.validate! }.to raise_error(Gitlab::GitAccess::ForbiddenError, /File name #{file_path} was prohibited by the pattern/) end end + + context 'when the repository was empty' do + let(:project) { create(:project, :empty_repo, push_rule: push_rule) } + let(:new_rev) { project.repository.create_file(user, file_path, "commit #{file_path}", message: "commit #{file_path}", branch_name: "master") } + let(:new_commits) { [project.repository.commit(new_rev)] } + + before do + allow(project.repository).to receive(:empty?).and_return(true) + end + + context 'when file contains secrets' do + let(:file_path) { 'aws/credentials' } + + it "returns an error if a new or renamed filed doesn't match the file name regex" do + expect { subject.validate! }.to raise_error(Gitlab::GitAccess::ForbiddenError, /File name #{file_path} was prohibited by the pattern/) + end + + context 'when feature flag "verify_push_rules_for_first_commit" is disabled' do + before do + stub_feature_flags(verify_push_rules_for_first_commit: false) + end + + it 'does not raise an error' do + expect(subject.validate!).to be_truthy + end + end + end + + context 'when file is permitted' do + let(:file_path) { 'aws/not_credentials' } + + it 'does not raise an error' do + expect(subject.validate!).to be_truthy + end + end + end end end diff --git a/ee/spec/support/shared_contexts/push_rules_checks_shared_context.rb b/ee/spec/support/shared_contexts/push_rules_checks_shared_context.rb index f2f5232c1cb126bbe96d6dbf08750eed08482589..bc0ed863976baf9ee4b5306b4149500e7ca9850b 100644 --- a/ee/spec/support/shared_contexts/push_rules_checks_shared_context.rb +++ b/ee/spec/support/shared_contexts/push_rules_checks_shared_context.rb @@ -5,9 +5,9 @@ let(:project) { create(:project, :public, :repository, push_rule: push_rule) } + let(:new_commits) { project.repository.commits_between('be93687618e4b132087f430a4d8fc3a609c9b77c', '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51') } + before do - allow(project.repository).to receive(:new_commits).and_return( - project.repository.commits_between('be93687618e4b132087f430a4d8fc3a609c9b77c', '54fcc214b94e78d7a41a9a8fe6d87a5e59500e51') - ) + allow(project.repository).to receive(:new_commits).and_return(new_commits) end end diff --git a/lib/gitlab/checks/diff_check.rb b/lib/gitlab/checks/diff_check.rb index bce4f969284a86b517f2b5ea0b1dc4d9150735cd..faf5a764725ae55e4f261831980df5187463938c 100644 --- a/lib/gitlab/checks/diff_check.rb +++ b/lib/gitlab/checks/diff_check.rb @@ -18,10 +18,7 @@ def validate! return unless should_run_validations? return if commits.empty? - paths = project.repository.find_changed_paths( - commits.map(&:sha), merge_commit_diff_mode: :all_parents - ) - + paths = project.repository.find_changed_paths(treeish_objects, merge_commit_diff_mode: :all_parents) paths.each do |path| validate_path(path) end @@ -31,6 +28,29 @@ def validate! private + def treeish_objects + objects = commits + + return objects unless project.repository.empty? && + Feature.enabled?(:verify_push_rules_for_first_commit, project) + + # It's a special case for the push to the empty repository + # + # Git doesn't display a diff of the initial commit of the repository + # if we just provide a commit sha. + # + # To fix that we can use TreeRequest to check the difference + # between empty tree sha and the tree sha of the initial commit + # + # `commits` are sorted in reverse order, the initial commit is the last one. + init_commit = objects.last + + diff_tree = Gitlab::Git::DiffTree.from_commit(init_commit) + objects.prepend(diff_tree) if diff_tree + + objects + end + def validate_lfs_file_locks? strong_memoize(:validate_lfs_file_locks) do project.lfs_enabled? && project.any_lfs_file_locks? diff --git a/lib/gitlab/git/commit.rb b/lib/gitlab/git/commit.rb index c0601c7795c881304f20e2d8ecf7e3b3373beec9..b104610038cabea9c7da28a59d2ca03c5b6526b9 100644 --- a/lib/gitlab/git/commit.rb +++ b/lib/gitlab/git/commit.rb @@ -226,6 +226,12 @@ def short_id(length = 10) id.to_s[0..length] end + def tree_id + return unless raw_commit + + raw_commit.tree_id + end + def safe_message @safe_message ||= message end diff --git a/lib/gitlab/git/diff_tree.rb b/lib/gitlab/git/diff_tree.rb new file mode 100644 index 0000000000000000000000000000000000000000..df48baeb1c3f8d7c124dbdf9a5257f005daed8ba --- /dev/null +++ b/lib/gitlab/git/diff_tree.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Gitlab + module Git + # Represents a tree-ish object for git diff-tree command + # See: https://git-scm.com/docs/git-diff-tree + class DiffTree + attr_reader :left_tree_id, :right_tree_id + + def initialize(left_tree_id, right_tree_id) + @left_tree_id = left_tree_id + @right_tree_id = right_tree_id + end + + def self.from_commit(commit) + return unless commit.tree_id + + parent_tree_id = + if commit.parent_ids.blank? + Gitlab::Git::EMPTY_TREE_ID + else + parent_id = commit.parent_ids.first + commit.repository.commit(parent_id).tree_id + end + + new(parent_tree_id, commit.tree_id) + end + end + end +end diff --git a/lib/gitlab/git/repository.rb b/lib/gitlab/git/repository.rb index 71be986882ce36caadb3a539bfe44b2d22e637bc..7ea0c0ad697344e9ebfc2fac4e58376079330341 100644 --- a/lib/gitlab/git/repository.rb +++ b/lib/gitlab/git/repository.rb @@ -525,15 +525,13 @@ def diff_stats(left_id, right_id) empty_diff_stats end - def find_changed_paths(commits, merge_commit_diff_mode: nil) - processed_commits = commits.reject { |ref| ref.blank? || Gitlab::Git.blank_ref?(ref) } + def find_changed_paths(treeish_objects, merge_commit_diff_mode: nil) + processed_objects = treeish_objects.compact - return [] if processed_commits.empty? + return [] if processed_objects.empty? wrapped_gitaly_errors do - gitaly_commit_client.find_changed_paths( - processed_commits, merge_commit_diff_mode: merge_commit_diff_mode - ) + gitaly_commit_client.find_changed_paths(processed_objects, merge_commit_diff_mode: merge_commit_diff_mode) end rescue CommandError, TypeError, NoRepository [] diff --git a/lib/gitlab/gitaly_client/commit_service.rb b/lib/gitlab/gitaly_client/commit_service.rb index c10f780665cf277949a6d790d0866734643d3a39..0b0cf19752f12509171843970badf645b6e16434 100644 --- a/lib/gitlab/gitaly_client/commit_service.rb +++ b/lib/gitlab/gitaly_client/commit_service.rb @@ -274,8 +274,10 @@ def diff_stats(left_commit_sha, right_commit_sha) # else # defaults to :include_merges behavior # ['foo_bar.rb', 'bar_baz.rb'], # - def find_changed_paths(commits, merge_commit_diff_mode: nil) - request = find_changed_paths_request(commits, merge_commit_diff_mode) + def find_changed_paths(objects, merge_commit_diff_mode: nil) + request = find_changed_paths_request(objects, merge_commit_diff_mode) + + return [] if request.nil? response = gitaly_client_call(@repository.storage, :diff_service, :find_changed_paths, request, timeout: GitalyClient.medium_timeout) response.flat_map do |msg| @@ -646,16 +648,27 @@ def call_find_commit(revision) response.commit end - def find_changed_paths_request(commits, merge_commit_diff_mode) + def find_changed_paths_request(objects, merge_commit_diff_mode) diff_mode = MERGE_COMMIT_DIFF_MODES[merge_commit_diff_mode] if Feature.enabled?(:merge_commit_diff_modes) - commit_requests = commits.map do |commit| - Gitaly::FindChangedPathsRequest::Request.new( - commit_request: Gitaly::FindChangedPathsRequest::Request::CommitRequest.new(commit_revision: commit) - ) + requests = objects.filter_map do |object| + case object + when Gitlab::Git::DiffTree + Gitaly::FindChangedPathsRequest::Request.new( + tree_request: Gitaly::FindChangedPathsRequest::Request::TreeRequest.new(left_tree_revision: object.left_tree_id, right_tree_revision: object.right_tree_id) + ) + when Commit, Gitlab::Git::Commit + next if object.sha.blank? || Gitlab::Git.blank_ref?(object.sha) + + Gitaly::FindChangedPathsRequest::Request.new( + commit_request: Gitaly::FindChangedPathsRequest::Request::CommitRequest.new(commit_revision: object.sha) + ) + end end - Gitaly::FindChangedPathsRequest.new(repository: @gitaly_repo, requests: commit_requests, merge_commit_diff_mode: diff_mode) + return if requests.blank? + + Gitaly::FindChangedPathsRequest.new(repository: @gitaly_repo, requests: requests, merge_commit_diff_mode: diff_mode) end def path_error_message(path_error) diff --git a/spec/lib/gitlab/git/commit_spec.rb b/spec/lib/gitlab/git/commit_spec.rb index dd9f77f0211dcbbf1a8834395a9499887660848a..3b1dbf7d602618e04a669ad540d3c422b223bfb9 100644 --- a/spec/lib/gitlab/git/commit_spec.rb +++ b/spec/lib/gitlab/git/commit_spec.rb @@ -3,15 +3,16 @@ require "spec_helper" RSpec.describe Gitlab::Git::Commit, feature_category: :source_code_management do - let(:repository) { create(:project, :repository).repository.raw } + let_it_be(:repository) { create(:project, :repository).repository.raw } let(:commit) { described_class.find(repository, SeedRepo::Commit::ID) } describe "Commit info from gitaly commit" do let(:subject) { (+"My commit").force_encoding('ASCII-8BIT') } let(:body) { subject + (+"My body").force_encoding('ASCII-8BIT') } let(:body_size) { body.length } - let(:gitaly_commit) { build(:gitaly_commit, subject: subject, body: body, body_size: body_size) } + let(:gitaly_commit) { build(:gitaly_commit, subject: subject, body: body, body_size: body_size, tree_id: tree_id) } let(:id) { gitaly_commit.id } + let(:tree_id) { 'd7f32d821c9cc7b1a9166ca7c4ba95b5c2d0d000' } let(:committer) { gitaly_commit.committer } let(:author) { gitaly_commit.author } let(:commit) { described_class.new(repository, gitaly_commit) } @@ -26,6 +27,7 @@ it { expect(commit.committer_name).to eq(committer.name) } it { expect(commit.committer_email).to eq(committer.email) } it { expect(commit.parent_ids).to eq(gitaly_commit.parent_ids) } + it { expect(commit.tree_id).to eq(tree_id) } context 'non-UTC dates' do let(:seconds) { Time.now.to_i } @@ -577,6 +579,14 @@ def create_commit_with_large_message it { is_expected.to eq(sample_commit_hash[:message]) } end + + describe '#tree_id' do + subject { super().tree_id } + + it "doesn't return tree id for non-Gitaly commits" do + is_expected.to be_nil + end + end end describe '#stats' do diff --git a/spec/lib/gitlab/git/diff_tree_spec.rb b/spec/lib/gitlab/git/diff_tree_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..614a8f03dd8d6e8e5968c6c4daccd37657c95ca1 --- /dev/null +++ b/spec/lib/gitlab/git/diff_tree_spec.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe Gitlab::Git::DiffTree, feature_category: :source_code_management do + let_it_be(:project) { create(:project, :repository) } + let_it_be(:repository) { project.repository } + + describe '.from_commit' do + subject(:diff_tree) { described_class.from_commit(commit) } + + context 'when commit is an initial commit' do + let(:commit) { repository.commit('1a0b36b3cdad1d2ee32457c102a8c0b7056fa863') } + + it 'returns the expected diff tree object' do + expect(diff_tree.left_tree_id).to eq(Gitlab::Git::EMPTY_TREE_ID) + expect(diff_tree.right_tree_id).to eq(commit.tree_id) + end + end + + context 'when commit is a regular commit' do + let(:commit) { repository.commit('60ecb67744cb56576c30214ff52294f8ce2def98') } + + it 'returns the expected diff tree object' do + expect(diff_tree.left_tree_id).to eq(commit.parent.tree_id) + expect(diff_tree.right_tree_id).to eq(commit.tree_id) + end + end + end +end diff --git a/spec/lib/gitlab/git/repository_spec.rb b/spec/lib/gitlab/git/repository_spec.rb index 9ce8a674146738b1d66a7f5c786160cfe96b12db..a97c3c8597140107997053226deae64150103996 100644 --- a/spec/lib/gitlab/git/repository_spec.rb +++ b/spec/lib/gitlab/git/repository_spec.rb @@ -1675,9 +1675,13 @@ def create_commit(blobs) end describe '#find_changed_paths' do - let(:commit_1) { TestEnv::BRANCH_SHA['with-executables'] } - let(:commit_2) { TestEnv::BRANCH_SHA['master'] } - let(:commit_3) { '6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9' } + let_it_be(:commit_1) { repository.commit(TestEnv::BRANCH_SHA['with-executables']) } + let_it_be(:commit_2) { repository.commit(TestEnv::BRANCH_SHA['master']) } + let_it_be(:commit_3) { repository.commit('6f6d7e7ed97bb5f0054f2b1df789b39ca89b6ff9') } + + let_it_be(:initial_commit) { repository.commit('1a0b36b3cdad1d2ee32457c102a8c0b7056fa863') } + let_it_be(:diff_tree) { Gitlab::Git::DiffTree.from_commit(initial_commit) } + let(:commit_1_files) do [Gitlab::Git::ChangedPath.new(status: :ADDED, path: "files/executables/ls")] end @@ -1693,18 +1697,26 @@ def create_commit(blobs) ] end + let(:diff_tree_files) do + [ + Gitlab::Git::ChangedPath.new(status: :ADDED, path: ".gitignore"), + Gitlab::Git::ChangedPath.new(status: :ADDED, path: "LICENSE"), + Gitlab::Git::ChangedPath.new(status: :ADDED, path: "README.md") + ] + end + it 'returns a list of paths' do - collection = repository.find_changed_paths([commit_1, commit_2, commit_3]) + collection = repository.find_changed_paths([commit_1, commit_2, commit_3, diff_tree]) expect(collection).to be_a(Enumerable) - expect(collection.as_json).to eq((commit_1_files + commit_2_files + commit_3_files).as_json) + expect(collection.as_json).to eq((commit_1_files + commit_2_files + commit_3_files + diff_tree_files).as_json) end - it 'returns no paths when SHAs are invalid' do + it 'returns only paths with valid SHAs' do collection = repository.find_changed_paths(['invalid', commit_1]) expect(collection).to be_a(Enumerable) - expect(collection.to_a).to be_empty + expect(collection.as_json).to eq(commit_1_files.as_json) end it 'returns a list of paths even when containing a blank ref' do diff --git a/spec/lib/gitlab/gitaly_client/commit_service_spec.rb b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb index fd66efe12c840224183e2d23e5c4a3918bd8d18c..740073d5e4e26ea8df50e88135710c3e2ee6069a 100644 --- a/spec/lib/gitlab/gitaly_client/commit_service_spec.rb +++ b/spec/lib/gitlab/gitaly_client/commit_service_spec.rb @@ -192,7 +192,9 @@ Gitaly::FindChangedPathsRequest.new(repository: repository_message, requests: requests, merge_commit_diff_mode: merge_commit_diff_mode) end - subject { described_class.new(repository).find_changed_paths(commits, merge_commit_diff_mode: merge_commit_diff_mode).as_json } + let(:treeish_objects) { repository.commits_by(oids: commits) } + + subject { described_class.new(repository).find_changed_paths(treeish_objects, merge_commit_diff_mode: merge_commit_diff_mode).as_json } before do allow(Gitaly::FindChangedPathsRequest).to receive(:new).and_call_original @@ -334,6 +336,40 @@ include_examples 'uses requests format' end end + + context 'when all requested objects are invalid' do + it 'does not send RPC request' do + expect_any_instance_of(Gitaly::DiffService::Stub).not_to receive(:find_changed_paths) + + returned_value = described_class.new(repository).find_changed_paths(%w[wrong values]) + + expect(returned_value).to eq([]) + end + end + + context 'when commit has an empty SHA' do + let(:empty_commit) { build(:commit, project: project, sha: '0000000000000000000000000000000000000000') } + + it 'does not send RPC request' do + expect_any_instance_of(Gitaly::DiffService::Stub).not_to receive(:find_changed_paths) + + returned_value = described_class.new(repository).find_changed_paths([empty_commit]) + + expect(returned_value).to eq([]) + end + end + + context 'when commit sha is not set' do + let(:empty_commit) { build(:commit, project: project, sha: nil) } + + it 'does not send RPC request' do + expect_any_instance_of(Gitaly::DiffService::Stub).not_to receive(:find_changed_paths) + + returned_value = described_class.new(repository).find_changed_paths([empty_commit]) + + expect(returned_value).to eq([]) + end + end end describe '#tree_entries' do