com.google.gerrit.server.project.LabelConfigValidator Maven / Gradle / Ivy
// 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.index.query.QueryParseException;
import com.google.gerrit.server.events.CommitReceivedEvent;
import com.google.gerrit.server.git.meta.VersionedConfigFile;
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.gitdiff.ModifiedFile;
import com.google.gerrit.server.query.approval.ApprovalQueryBuilder;
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}.
 *
 * 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}).
 *
 * 
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 DEPRECATED_FLAGS =
      ImmutableMap.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 ApprovalQueryBuilder approvalQueryBuilder;
  public LabelConfigValidator(ApprovalQueryBuilder approvalQueryBuilder) {
    this.approvalQueryBuilder = approvalQueryBuilder;
  }
  @Override
  public List 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 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
      @Nullable Config oldConfig = loadOldConfig(receiveEvent).orElse(null);
      for (String labelName : newConfig.getSubsections(ProjectConfig.LABEL)) {
        rejectSettingDeprecatedFlags(newConfig, oldConfig, labelName, validationMessageBuilder);
        rejectSettingCopyValues(newConfig, oldConfig, labelName, validationMessageBuilder);
        rejectSettingBlockingFunction(newConfig, oldConfig, labelName, validationMessageBuilder);
        rejectDeletingFunction(newConfig, oldConfig, labelName, validationMessageBuilder);
        rejectNonParseableCopyCondition(newConfig, oldConfig, labelName, validationMessageBuilder);
      }
      ImmutableList validationMessages = validationMessageBuilder.build();
      if (validationMessages.stream().anyMatch(CommitValidationMessage::isError)) {
        throw new CommitValidationException(
            String.format(
                "invalid %s file in revision %s",
                ProjectConfig.PROJECT_CONFIG, receiveEvent.commit.getName()),
            validationMessages);
      }
      return validationMessages;
    } 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);
    }
  }
  private void rejectSettingDeprecatedFlags(
      Config newConfig,
      @Nullable Config oldConfig,
      String labelName,
      ImmutableList.Builder validationMessageBuilder) {
    for (String deprecatedFlag : DEPRECATED_FLAGS.keySet()) {
      if (flagChangedOrNewlySet(newConfig, oldConfig, 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));
      }
    }
  }
  private void rejectSettingCopyValues(
      Config newConfig,
      @Nullable Config oldConfig,
      String labelName,
      ImmutableList.Builder validationMessageBuilder) {
    if (copyValuesChangedOrNewlySet(newConfig, oldConfig, labelName)) {
      validationMessageBuilder.add(
          new CommitValidationMessage(
              String.format(
                  "Parameter '%s.%s.%s' is deprecated and cannot be set,"
                      + " use 'is:' in '%s.%s.%s' instead.",
                  ProjectConfig.LABEL,
                  labelName,
                  KEY_COPY_VALUE,
                  ProjectConfig.LABEL,
                  labelName,
                  ProjectConfig.KEY_COPY_CONDITION),
              ValidationMessage.Type.ERROR));
    }
  }
  private void rejectSettingBlockingFunction(
      Config newConfig,
      @Nullable Config oldConfig,
      String labelName,
      ImmutableList.Builder validationMessageBuilder) {
    if (flagChangedOrNewlySet(newConfig, oldConfig, labelName, ProjectConfig.KEY_FUNCTION)) {
      String fnName =
          newConfig.getString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_FUNCTION);
      Optional 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));
      }
    }
  }
  private void rejectDeletingFunction(
      Config newConfig,
      @Nullable Config oldConfig,
      String labelName,
      ImmutableList.Builder validationMessageBuilder) {
    // Ban deletion of label function since the default is MaxWithBlock which is deprecated
    if (flagDeleted(newConfig, oldConfig, 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));
    }
  }
  private void rejectNonParseableCopyCondition(
      Config newConfig,
      @Nullable Config oldConfig,
      String labelName,
      ImmutableList.Builder validationMessageBuilder) {
    if (flagChangedOrNewlySet(newConfig, oldConfig, labelName, ProjectConfig.KEY_COPY_CONDITION)) {
      String copyCondition =
          newConfig.getString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_CONDITION);
      try {
        @SuppressWarnings("unused")
        var unused = approvalQueryBuilder.parse(copyCondition);
      } catch (QueryParseException e) {
        validationMessageBuilder.add(
            new CommitValidationMessage(
                String.format(
                    "Cannot parse copy condition '%s' of label %s (parameter '%s.%s.%s'): %s",
                    copyCondition,
                    labelName,
                    ProjectConfig.LABEL,
                    labelName,
                    ProjectConfig.KEY_COPY_CONDITION,
                    e.getMessage()),
                // if the old copy condition is not parseable allow updating it even if the new copy
                // condition is also not parseable, only emit a warning in this case
                hasUnparseableOldCopyCondition(oldConfig, labelName)
                    ? ValidationMessage.Type.WARNING
                    : ValidationMessage.Type.ERROR));
      }
    }
  }
  private boolean hasUnparseableOldCopyCondition(@Nullable Config oldConfig, String labelName) {
    if (oldConfig == null) {
      return false;
    }
    String oldCopyCondition =
        oldConfig.getString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_COPY_CONDITION);
    try {
      @SuppressWarnings("unused")
      var unused = approvalQueryBuilder.parse(oldCopyCondition);
      return false;
    } catch (QueryParseException e) {
      return true;
    }
  }
  /**
   * 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 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 =
          receiveEvent.diffOperations.loadModifiedFilesAgainstParentIfNecessary(
              receiveEvent.getProjectNameKey(),
              receiveEvent.commit,
              1,
              /* enableRenameDetection= */ true);
    } else {
      // initial commit: must use listModifiedFilesAgainstParent with parentNum = 0
      fileDiffOutputs =
          receiveEvent.diffOperations.loadModifiedFilesAgainstParentIfNecessary(
              receiveEvent.getProjectNameKey(),
              receiveEvent.commit,
              /* parentNum= */ 0,
              /* enableRenameDetection= */ true);
    }
    return fileDiffOutputs.keySet().contains(fileName);
  }
  private Config loadNewConfig(CommitReceivedEvent receiveEvent)
      throws IOException, ConfigInvalidException {
    VersionedConfigFile bareConfig = new VersionedConfigFile(ProjectConfig.PROJECT_CONFIG);
    bareConfig.load(receiveEvent.project.getNameKey(), receiveEvent.revWalk, receiveEvent.commit);
    return bareConfig.getConfig();
  }
  private Optional loadOldConfig(CommitReceivedEvent receiveEvent) throws IOException {
    if (receiveEvent.commit.getParentCount() == 0) {
      // initial commit, an old config doesn't exist
      return Optional.empty();
    }
    try {
      VersionedConfigFile bareConfig = new VersionedConfigFile(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 oldValues =
        ImmutableSet.copyOf(
            oldConfig.getStringList(ProjectConfig.LABEL, labelName, KEY_COPY_VALUE));
    ImmutableSet 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);
  }
}