| // 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); | |
| } | |
| } |