gitlab-ce/lib/gitlab/ci/build/rules/rule/clause/exists.rb

191 lines
6.0 KiB
Ruby

# frozen_string_literal: true
module Gitlab
module Ci
module Build
class Rules::Rule::Clause::Exists < Rules::Rule::Clause
include Gitlab::Utils::StrongMemoize
# The maximum number of patterned glob comparisons that will be
# performed before the rule assumes that it has a match
MAX_PATTERN_COMPARISONS = 10_000
WILDCARD_NESTED_PATTERN = "**/*"
def initialize(clause)
@globs = Array(clause[:paths])
@project_path = clause[:project]
@ref = clause[:ref]
end
def satisfied_by?(_pipeline, context)
# Return early to avoid redundant Gitaly calls
return false unless @globs.any?
context = change_context(context) if @project_path
expanded_globs = expand_globs(context)
top_level_only = expanded_globs.all?(&method(:top_level_glob?))
paths = worktree_paths(context, top_level_only)
exact_globs, extension_globs, pattern_globs = separate_globs(expanded_globs)
exact_matches?(paths, exact_globs) ||
matches_extension?(paths, extension_globs) ||
pattern_matches?(paths, pattern_globs, context)
end
private
def separate_globs(expanded_globs)
grouped = expanded_globs.group_by { |glob| glob_type(glob) }
grouped.values_at(:exact, :extension, :pattern).map { |globs| Array(globs) }
end
def expand_globs(context)
@globs.map do |glob|
expand_value_nested(glob, context)
end
end
def worktree_paths(context, top_level_only)
return [] unless context.project
if top_level_only
context.top_level_worktree_paths
else
context.all_worktree_paths
end
end
def glob_type(glob)
if exact_glob?(glob)
:exact
elsif extension_glob?(glob)
:extension
else
:pattern
end
end
def exact_matches?(paths, exact_globs)
exact_globs.any? do |glob|
paths.bsearch { |path| glob <=> path }
end
end
def matches_extension?(paths, extension_globs)
return false if extension_globs.empty?
extensions = extension_globs.map { |glob| without_wildcard_nested_pattern(glob) }
paths.any? do |path|
path.end_with?(*extensions)
end
end
def pattern_matches?(paths, pattern_globs, context)
return true if (paths.size * pattern_globs.size) > MAX_PATTERN_COMPARISONS
pattern_globs.any? do |glob|
Gitlab::SafeRequestStore.fetch("ci_rules_exists_pattern_matches_#{context.project&.id}_#{glob}") do
paths.any? do |path|
pattern_match?(glob, path)
end
end
end
end
def pattern_match?(glob, path)
File.fnmatch?(glob, path, File::FNM_PATHNAME | File::FNM_DOTMATCH | File::FNM_EXTGLOB)
end
# matches glob patterns that only match files in the top level directory
def top_level_glob?(glob)
glob.exclude?('/') && glob.exclude?('**')
end
# matches glob patterns that have no metacharacters for File#fnmatch?
def exact_glob?(glob)
glob.exclude?('*') && glob.exclude?('?') && glob.exclude?('[') && glob.exclude?('{')
end
# matches glob patterns like **/*.js or **/*.so.1 to optimize with path.end_with?('.js')
def extension_glob?(glob)
without_nested = without_wildcard_nested_pattern(glob)
without_nested.start_with?('.') && without_nested.exclude?('/') && exact_glob?(without_nested)
end
def without_wildcard_nested_pattern(glob)
glob.delete_prefix(WILDCARD_NESTED_PATTERN)
end
def change_context(old_context)
user = find_context_user(old_context)
new_project = find_context_project(user, old_context)
new_sha = find_context_sha(new_project, old_context)
Gitlab::Ci::Config::External::Context.new(
project: new_project,
user: user,
sha: new_sha,
variables: old_context.variables
)
end
def find_context_user(context)
context.is_a?(Gitlab::Ci::Config::External::Context) ? context.user : context.pipeline.user
end
def find_context_project(user, context)
full_path = expand_value_nested(@project_path, context)
project = Project.find_by_full_path(full_path)
unless project
raise Rules::Rule::Clause::ParseError,
"rules:exists:project `#{mask_context_variables_from(context, full_path)}` is not a valid project path"
end
unless Ability.allowed?(user, :read_code, project)
raise Rules::Rule::Clause::ParseError,
"rules:exists:project access denied to project " \
"`#{mask_context_variables_from(context, project.full_path)}`"
end
project
end
def find_context_sha(project, context)
return project.commit&.sha unless @ref
ref = expand_value_nested(@ref, context)
commit = project.commit(ref)
unless commit
raise Rules::Rule::Clause::ParseError,
"rules:exists:ref `#{mask_context_variables_from(context, ref)}` is not a valid ref " \
"in project `#{mask_context_variables_from(context, project.full_path)}`"
end
commit.sha
end
def mask_context_variables_from(context, string)
context.variables.reduce(string.dup) do |str, variable|
if variable[:masked]
Gitlab::Ci::MaskSecret.mask!(str, variable[:value])
else
str
end
end
end
def expand_value_nested(value, context)
ExpandVariables.expand_existing(value, -> { context.variables_hash_expanded })
end
end
end
end
end