| // Copyright (C) 2022 The Android Open Source Project |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| package com.google.gerrit.server.project; |
| |
| import com.google.common.annotations.VisibleForTesting; |
| import com.google.common.collect.ImmutableList; |
| import com.google.common.collect.ImmutableMap; |
| import com.google.common.collect.ImmutableSet; |
| import com.google.common.collect.Sets; |
| import com.google.common.flogger.FluentLogger; |
| import com.google.gerrit.common.Nullable; |
| import com.google.gerrit.entities.LabelFunction; |
| import com.google.gerrit.entities.RefNames; |
| import com.google.gerrit.extensions.client.ChangeKind; |
| import com.google.gerrit.server.events.CommitReceivedEvent; |
| import com.google.gerrit.server.git.validators.CommitValidationException; |
| import com.google.gerrit.server.git.validators.CommitValidationListener; |
| import com.google.gerrit.server.git.validators.CommitValidationMessage; |
| import com.google.gerrit.server.git.validators.ValidationMessage; |
| import com.google.gerrit.server.patch.DiffNotAvailableException; |
| import com.google.gerrit.server.patch.DiffOperations; |
| import com.google.gerrit.server.patch.DiffOptions; |
| import com.google.gerrit.server.patch.filediff.FileDiffOutput; |
| import com.google.inject.Inject; |
| import com.google.inject.Singleton; |
| import java.io.IOException; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Optional; |
| import org.eclipse.jgit.errors.ConfigInvalidException; |
| import org.eclipse.jgit.lib.Config; |
| |
| /** |
| * Validates modifications to label configurations in the {@code project.config} file that is stored |
| * in {@code refs/meta/config}. |
| * |
| * <p>Rejects setting/changing deprecated fields that are no longer supported (fields {@code |
| * copyAnyScore}, {@code copyMinScore}, {@code copyMaxScore}, {@code copyAllScoresIfNoChange}, |
| * {@code copyAllScoresIfNoCodeChange}, {@code copyAllScoresOnMergeFirstParentUpdate}, {@code |
| * copyAllScoresOnTrivialRebase}, {@code copyAllScoresIfListOfFilesDidNotChange}, {@code |
| * copyValue}). |
| * |
| * <p>Updates that unset the deprecated fields or that don't touch them are allowed. |
| */ |
| @Singleton |
| public class LabelConfigValidator implements CommitValidationListener { |
| private static final FluentLogger logger = FluentLogger.forEnclosingClass(); |
| |
| @VisibleForTesting public static final String KEY_COPY_ANY_SCORE = "copyAnyScore"; |
| |
| @VisibleForTesting public static final String KEY_COPY_MIN_SCORE = "copyMinScore"; |
| |
| @VisibleForTesting public static final String KEY_COPY_MAX_SCORE = "copyMaxScore"; |
| |
| @VisibleForTesting public static final String KEY_COPY_VALUE = "copyValue"; |
| |
| @VisibleForTesting |
| public static final String KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE = |
| "copyAllScoresOnMergeFirstParentUpdate"; |
| |
| @VisibleForTesting |
| public static final String KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE = "copyAllScoresOnTrivialRebase"; |
| |
| @VisibleForTesting |
| public static final String KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = "copyAllScoresIfNoCodeChange"; |
| |
| @VisibleForTesting |
| public static final String KEY_COPY_ALL_SCORES_IF_NO_CHANGE = "copyAllScoresIfNoChange"; |
| |
| @VisibleForTesting |
| public static final String KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE = |
| "copyAllScoresIfListOfFilesDidNotChange"; |
| |
| // Map of deprecated boolean flags to the predicates that should be used in the copy condition |
| // instead. |
| private static final ImmutableMap<String, String> DEPRECATED_FLAGS = |
| ImmutableMap.<String, String>builder() |
| .put(KEY_COPY_ANY_SCORE, "is:ANY") |
| .put(KEY_COPY_MIN_SCORE, "is:MIN") |
| .put(KEY_COPY_MAX_SCORE, "is:MAX") |
| .put(KEY_COPY_ALL_SCORES_IF_NO_CHANGE, "changekind:" + ChangeKind.NO_CHANGE.name()) |
| .put( |
| KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE, |
| "changekind:" + ChangeKind.NO_CODE_CHANGE.name()) |
| .put( |
| KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE, |
| "changekind:" + ChangeKind.MERGE_FIRST_PARENT_UPDATE.name()) |
| .put( |
| KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE, |
| "changekind:" + ChangeKind.TRIVIAL_REBASE.name()) |
| .put(KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE, "has:unchanged-files") |
| .build(); |
| |
| private final DiffOperations diffOperations; |
| |
| @Inject |
| public LabelConfigValidator(DiffOperations diffOperations) { |
| this.diffOperations = diffOperations; |
| } |
| |
| @Override |
| public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) |
| throws CommitValidationException { |
| try { |
| if (!receiveEvent.refName.equals(RefNames.REFS_CONFIG) |
| || !isFileChanged(receiveEvent, ProjectConfig.PROJECT_CONFIG)) { |
| // The project.config file in refs/meta/config was not modified, hence we do not need to do |
| // any validation and can return early. |
| return ImmutableList.of(); |
| } |
| |
| ImmutableList.Builder<CommitValidationMessage> validationMessageBuilder = |
| ImmutableList.builder(); |
| |
| // Load the new config |
| Config newConfig; |
| try { |
| newConfig = loadNewConfig(receiveEvent); |
| } catch (ConfigInvalidException e) { |
| // The current config is invalid, hence we cannot inspect the delta. |
| // Rejecting invalid configs is not the responsibility of this validator, hence ignore this |
| // exception here. |
| logger.atWarning().log( |
| "cannot inspect the project config, because parsing %s from revision %s" |
| + " in project %s failed: %s", |
| ProjectConfig.PROJECT_CONFIG, |
| receiveEvent.commit.name(), |
| receiveEvent.getProjectNameKey(), |
| e.getMessage()); |
| return ImmutableList.of(); |
| } |
| |
| // Load the old config |
| Optional<Config> oldConfig = loadOldConfig(receiveEvent); |
| |
| for (String labelName : newConfig.getSubsections(ProjectConfig.LABEL)) { |
| for (String deprecatedFlag : DEPRECATED_FLAGS.keySet()) { |
| if (flagChangedOrNewlySet(newConfig, oldConfig.orElse(null), labelName, deprecatedFlag)) { |
| validationMessageBuilder.add( |
| new CommitValidationMessage( |
| String.format( |
| "Parameter '%s.%s.%s' is deprecated and cannot be set," |
| + " use '%s' in '%s.%s.%s' instead.", |
| ProjectConfig.LABEL, |
| labelName, |
| deprecatedFlag, |
| DEPRECATED_FLAGS.get(deprecatedFlag), |
| ProjectConfig.LABEL, |
| labelName, |
| ProjectConfig.KEY_COPY_CONDITION), |
| ValidationMessage.Type.ERROR)); |
| } |
| } |
| |
| if (copyValuesChangedOrNewlySet(newConfig, oldConfig.orElse(null), labelName)) { |
| validationMessageBuilder.add( |
| new CommitValidationMessage( |
| String.format( |
| "Parameter '%s.%s.%s' is deprecated and cannot be set," |
| + " use 'is:<copy-value>' in '%s.%s.%s' instead.", |
| ProjectConfig.LABEL, |
| labelName, |
| KEY_COPY_VALUE, |
| ProjectConfig.LABEL, |
| labelName, |
| ProjectConfig.KEY_COPY_CONDITION), |
| ValidationMessage.Type.ERROR)); |
| } |
| |
| // Ban modifying label functions to any blocking function value |
| if (flagChangedOrNewlySet( |
| newConfig, oldConfig.orElse(null), labelName, ProjectConfig.KEY_FUNCTION)) { |
| String fnName = |
| newConfig.getString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_FUNCTION); |
| Optional<LabelFunction> labelFn = LabelFunction.parse(fnName); |
| if (labelFn.isPresent() && !isLabelFunctionAllowed(labelFn.get())) { |
| validationMessageBuilder.add( |
| new CommitValidationMessage( |
| String.format( |
| "Value '%s' of '%s.%s.%s' is not allowed and cannot be set." |
| + " Label functions can only be set to {%s, %s, %s}." |
| + " Use submit requirements instead of label functions.", |
| fnName, |
| ProjectConfig.LABEL, |
| labelName, |
| ProjectConfig.KEY_FUNCTION, |
| LabelFunction.NO_BLOCK, |
| LabelFunction.NO_OP, |
| LabelFunction.PATCH_SET_LOCK), |
| ValidationMessage.Type.ERROR)); |
| } |
| } |
| |
| // Ban deletions of label functions as well since the default is MaxWithBlock |
| if (flagDeleted(newConfig, oldConfig.orElse(null), labelName, ProjectConfig.KEY_FUNCTION)) { |
| validationMessageBuilder.add( |
| new CommitValidationMessage( |
| String.format( |
| "Cannot delete '%s.%s.%s'." |
| + " Label functions can only be set to {%s, %s, %s}." |
| + " Use submit requirements instead of label functions.", |
| ProjectConfig.LABEL, |
| labelName, |
| ProjectConfig.KEY_FUNCTION, |
| LabelFunction.NO_BLOCK, |
| LabelFunction.NO_OP, |
| LabelFunction.PATCH_SET_LOCK), |
| ValidationMessage.Type.ERROR)); |
| } |
| } |
| |
| ImmutableList<CommitValidationMessage> validationMessages = validationMessageBuilder.build(); |
| if (!validationMessages.isEmpty()) { |
| throw new CommitValidationException( |
| String.format( |
| "invalid %s file in revision %s", |
| ProjectConfig.PROJECT_CONFIG, receiveEvent.commit.getName()), |
| validationMessages); |
| } |
| return ImmutableList.of(); |
| } catch (IOException | DiffNotAvailableException e) { |
| String errorMessage = |
| String.format( |
| "failed to validate file %s for revision %s in ref %s of project %s", |
| ProjectConfig.PROJECT_CONFIG, |
| receiveEvent.commit.getName(), |
| RefNames.REFS_CONFIG, |
| receiveEvent.getProjectNameKey()); |
| logger.atSevere().withCause(e).log("%s", errorMessage); |
| throw new CommitValidationException(errorMessage, e); |
| } |
| } |
| |
| /** |
| * Whether the given file was changed in the given revision. |
| * |
| * @param receiveEvent the receive event |
| * @param fileName the name of the file |
| */ |
| private boolean isFileChanged(CommitReceivedEvent receiveEvent, String fileName) |
| throws DiffNotAvailableException { |
| Map<String, FileDiffOutput> fileDiffOutputs; |
| if (receiveEvent.commit.getParentCount() > 0) { |
| // normal commit with one parent: use listModifiedFilesAgainstParent with parentNum = 1 to |
| // compare against the only parent (using parentNum = 0 to compare against the default parent |
| // would also work) |
| // merge commit with 2 or more parents: must use listModifiedFilesAgainstParent with parentNum |
| // = 1 to compare against the first parent (using parentNum = 0 would compare against the |
| // auto-merge) |
| fileDiffOutputs = |
| diffOperations.listModifiedFilesAgainstParent( |
| receiveEvent.getProjectNameKey(), receiveEvent.commit, 1, DiffOptions.DEFAULTS); |
| } else { |
| // initial commit: must use listModifiedFilesAgainstParent with parentNum = 0 |
| fileDiffOutputs = |
| diffOperations.listModifiedFilesAgainstParent( |
| receiveEvent.getProjectNameKey(), |
| receiveEvent.commit, |
| /* parentNum=*/ 0, |
| DiffOptions.DEFAULTS); |
| } |
| return fileDiffOutputs.keySet().contains(fileName); |
| } |
| |
| private Config loadNewConfig(CommitReceivedEvent receiveEvent) |
| throws IOException, ConfigInvalidException { |
| ProjectLevelConfig.Bare bareConfig = new ProjectLevelConfig.Bare(ProjectConfig.PROJECT_CONFIG); |
| bareConfig.load(receiveEvent.project.getNameKey(), receiveEvent.revWalk, receiveEvent.commit); |
| return bareConfig.getConfig(); |
| } |
| |
| private Optional<Config> loadOldConfig(CommitReceivedEvent receiveEvent) throws IOException { |
| if (receiveEvent.commit.getParentCount() == 0) { |
| // initial commit, an old config doesn't exist |
| return Optional.empty(); |
| } |
| |
| try { |
| ProjectLevelConfig.Bare bareConfig = |
| new ProjectLevelConfig.Bare(ProjectConfig.PROJECT_CONFIG); |
| bareConfig.load( |
| receiveEvent.project.getNameKey(), |
| receiveEvent.revWalk, |
| receiveEvent.commit.getParent(0)); |
| return Optional.of(bareConfig.getConfig()); |
| } catch (ConfigInvalidException e) { |
| // the old config is not parseable, treat this the same way as if an old config didn't exist |
| // so that all parameters in the new config are validated |
| logger.atWarning().log( |
| "cannot inspect the old project config, because parsing %s from parent revision %s" |
| + " in project %s failed: %s", |
| ProjectConfig.PROJECT_CONFIG, |
| receiveEvent.commit.name(), |
| receiveEvent.getProjectNameKey(), |
| e.getMessage()); |
| return Optional.empty(); |
| } |
| } |
| |
| private static boolean flagChangedOrNewlySet( |
| Config newConfig, @Nullable Config oldConfig, String labelName, String key) { |
| if (oldConfig == null) { |
| return newConfig.getNames(ProjectConfig.LABEL, labelName).contains(key); |
| } |
| |
| // Use getString rather than getBoolean so that we do not have to deal with values that cannot |
| // be parsed as a boolean. |
| String oldValue = oldConfig.getString(ProjectConfig.LABEL, labelName, key); |
| String newValue = newConfig.getString(ProjectConfig.LABEL, labelName, key); |
| return newValue != null && !newValue.equals(oldValue); |
| } |
| |
| private static boolean flagDeleted( |
| Config newConfig, @Nullable Config oldConfig, String labelName, String key) { |
| if (oldConfig == null) { |
| return false; |
| } |
| String oldValue = oldConfig.getString(ProjectConfig.LABEL, labelName, key); |
| String newValue = newConfig.getString(ProjectConfig.LABEL, labelName, key); |
| return oldValue != null && newValue == null; |
| } |
| |
| private static boolean copyValuesChangedOrNewlySet( |
| Config newConfig, @Nullable Config oldConfig, String labelName) { |
| if (oldConfig == null) { |
| return newConfig.getNames(ProjectConfig.LABEL, labelName).contains(KEY_COPY_VALUE); |
| } |
| |
| // Ignore the order in which the copy values are defined in the new and old config, since the |
| // order doesn't matter for this parameter. |
| ImmutableSet<String> oldValues = |
| ImmutableSet.copyOf( |
| oldConfig.getStringList(ProjectConfig.LABEL, labelName, KEY_COPY_VALUE)); |
| ImmutableSet<String> newValues = |
| ImmutableSet.copyOf( |
| newConfig.getStringList(ProjectConfig.LABEL, labelName, KEY_COPY_VALUE)); |
| return !newValues.isEmpty() && !Sets.difference(newValues, oldValues).isEmpty(); |
| } |
| |
| private static boolean isLabelFunctionAllowed(LabelFunction labelFunction) { |
| return labelFunction.equals(LabelFunction.NO_BLOCK) |
| || labelFunction.equals(LabelFunction.NO_OP) |
| || labelFunction.equals(LabelFunction.PATCH_SET_LOCK); |
| } |
| } |