blob: 514dee1255afc73330474ade18e9fe9d6469bc8c [file] [log] [blame]
// Copyright (C) 2013 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.git.validators;
import com.google.common.base.Joiner;
import com.google.common.collect.ImmutableList;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.entities.Account;
import com.google.gerrit.entities.BranchNameKey;
import com.google.gerrit.entities.PatchSet;
import com.google.gerrit.entities.Project;
import com.google.gerrit.entities.RefNames;
import com.google.gerrit.exceptions.StorageException;
import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType;
import com.google.gerrit.extensions.registration.DynamicMap;
import com.google.gerrit.extensions.registration.Extension;
import com.google.gerrit.extensions.restapi.AuthException;
import com.google.gerrit.server.IdentifiedUser;
import com.google.gerrit.server.account.AccountProperties;
import com.google.gerrit.server.config.AllProjectsName;
import com.google.gerrit.server.config.AllUsersName;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.config.PluginConfig;
import com.google.gerrit.server.config.ProjectConfigEntry;
import com.google.gerrit.server.git.CodeReviewCommit;
import com.google.gerrit.server.git.CodeReviewCommit.CodeReviewRevWalk;
import com.google.gerrit.server.group.db.GroupConfig;
import com.google.gerrit.server.permissions.GlobalPermission;
import com.google.gerrit.server.permissions.PermissionBackend;
import com.google.gerrit.server.permissions.PermissionBackendException;
import com.google.gerrit.server.permissions.ProjectPermission;
import com.google.gerrit.server.plugincontext.PluginSetContext;
import com.google.gerrit.server.project.ProjectCache;
import com.google.gerrit.server.project.ProjectConfig;
import com.google.gerrit.server.project.ProjectState;
import com.google.gerrit.server.query.change.ChangeData;
import com.google.inject.Inject;
import java.io.IOException;
import java.util.List;
import java.util.Objects;
import org.eclipse.jgit.errors.ConfigInvalidException;
import org.eclipse.jgit.lib.Config;
import org.eclipse.jgit.lib.Ref;
import org.eclipse.jgit.lib.Repository;
/**
* Collection of validators that run inside Gerrit before a change is submitted. The main purpose is
* to ensure that NoteDb data is mutated in a controlled way.
*
* <p>The difference between this and {@link OnSubmitValidators} is that this validates the original
* commit. Depending on the {@link com.google.gerrit.server.submit.SubmitStrategy} that the project
* chooses, the resulting commit in the repo might differ from this original commit. In case you
* want to validate the resulting commit, use {@link OnSubmitValidators}
*/
public class MergeValidators {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
private final PluginSetContext<MergeValidationListener> mergeValidationListeners;
private final ProjectConfigValidator.Factory projectConfigValidatorFactory;
private final AccountMergeValidator.Factory accountValidatorFactory;
private final GroupMergeValidator.Factory groupValidatorFactory;
public interface Factory {
MergeValidators create();
}
@Inject
MergeValidators(
PluginSetContext<MergeValidationListener> mergeValidationListeners,
ProjectConfigValidator.Factory projectConfigValidatorFactory,
AccountMergeValidator.Factory accountValidatorFactory,
GroupMergeValidator.Factory groupValidatorFactory) {
this.mergeValidationListeners = mergeValidationListeners;
this.projectConfigValidatorFactory = projectConfigValidatorFactory;
this.accountValidatorFactory = accountValidatorFactory;
this.groupValidatorFactory = groupValidatorFactory;
}
/**
* Runs all validators and throws a {@link MergeValidationException} for the first validator that
* failed. Only the first violation is propagated and processing is stopped thereafter.
*/
public void validatePreMerge(
Repository repo,
CodeReviewRevWalk revWalk,
CodeReviewCommit commit,
ProjectState destProject,
BranchNameKey destBranch,
PatchSet.Id patchSetId,
IdentifiedUser caller)
throws MergeValidationException {
List<MergeValidationListener> validators =
ImmutableList.of(
new PluginMergeValidationListener(mergeValidationListeners),
projectConfigValidatorFactory.create(),
accountValidatorFactory.create(),
groupValidatorFactory.create(),
new DestBranchRefValidator());
for (MergeValidationListener validator : validators) {
validator.onPreMerge(repo, revWalk, commit, destProject, destBranch, patchSetId, caller);
}
}
/** Validator for any commits to {@code refs/meta/config}. */
public static class ProjectConfigValidator implements MergeValidationListener {
private static final String INVALID_CONFIG =
"Change contains an invalid project configuration.";
private static final String PARENT_NOT_FOUND =
"Change contains an invalid project configuration:\nParent project does not exist.";
private static final String PLUGIN_VALUE_NOT_EDITABLE =
"Change contains an invalid project configuration:\n"
+ "One of the plugin configuration parameters is not editable.";
private static final String PLUGIN_VALUE_NOT_PERMITTED =
"Change contains an invalid project configuration:\n"
+ "One of the plugin configuration parameters has a value that is not"
+ " permitted.";
private static final String ROOT_NO_PARENT =
"Change contains an invalid project configuration:\n"
+ "The root project cannot have a parent.";
private static final String SET_BY_ADMIN =
"Change contains a project configuration that changes the parent"
+ " project.\n"
+ "The change must be submitted by a Gerrit administrator.";
private static final String SET_BY_OWNER =
"Change contains a project configuration that changes the parent"
+ " project.\n"
+ "The change must be submitted by a Gerrit administrator or the project owner.";
private final AllProjectsName allProjectsName;
private final AllUsersName allUsersName;
private final ProjectCache projectCache;
private final PermissionBackend permissionBackend;
private final DynamicMap<ProjectConfigEntry> pluginConfigEntries;
private final ProjectConfig.Factory projectConfigFactory;
private final boolean allowProjectOwnersToChangeParent;
public interface Factory {
ProjectConfigValidator create();
}
@Inject
public ProjectConfigValidator(
AllProjectsName allProjectsName,
AllUsersName allUsersName,
ProjectCache projectCache,
PermissionBackend permissionBackend,
DynamicMap<ProjectConfigEntry> pluginConfigEntries,
ProjectConfig.Factory projectConfigFactory,
@GerritServerConfig Config config) {
this.allProjectsName = allProjectsName;
this.allUsersName = allUsersName;
this.projectCache = projectCache;
this.permissionBackend = permissionBackend;
this.pluginConfigEntries = pluginConfigEntries;
this.projectConfigFactory = projectConfigFactory;
this.allowProjectOwnersToChangeParent =
config.getBoolean("receive", "allowProjectOwnersToChangeParent", false);
}
@Override
public void onPreMerge(
Repository repo,
CodeReviewRevWalk revWalk,
CodeReviewCommit commit,
ProjectState destProject,
BranchNameKey destBranch,
PatchSet.Id patchSetId,
IdentifiedUser caller)
throws MergeValidationException {
if (RefNames.REFS_CONFIG.equals(destBranch.branch())) {
final Project.NameKey newParent;
try {
ProjectConfig cfg = projectConfigFactory.create(destProject.getNameKey());
cfg.load(destProject.getNameKey(), repo, commit);
newParent = cfg.getProject().getParent(allProjectsName);
final Project.NameKey oldParent = destProject.getProject().getParent(allProjectsName);
if (oldParent == null) {
// update of the 'All-Projects' project
if (newParent != null) {
throw new MergeValidationException(ROOT_NO_PARENT);
}
} else {
if (!oldParent.equals(newParent)) {
if (!allowProjectOwnersToChangeParent) {
try {
if (!permissionBackend.user(caller).test(GlobalPermission.ADMINISTRATE_SERVER)) {
throw new MergeValidationException(SET_BY_ADMIN);
}
} catch (PermissionBackendException e) {
logger.atWarning().withCause(e).log("Cannot check ADMINISTRATE_SERVER");
throw new MergeValidationException("validation unavailable", e);
}
} else {
try {
permissionBackend
.user(caller)
.project(destProject.getNameKey())
.check(ProjectPermission.WRITE_CONFIG);
} catch (AuthException e) {
throw new MergeValidationException(SET_BY_OWNER, e);
} catch (PermissionBackendException e) {
logger.atWarning().withCause(e).log("Cannot check WRITE_CONFIG");
throw new MergeValidationException("validation unavailable", e);
}
}
if (allUsersName.equals(destProject.getNameKey())
&& !allProjectsName.equals(newParent)) {
throw new MergeValidationException(
String.format(
" %s must inherit from %s", allUsersName.get(), allProjectsName.get()));
}
if (!projectCache.get(newParent).isPresent()) {
throw new MergeValidationException(PARENT_NOT_FOUND);
}
}
}
for (Extension<ProjectConfigEntry> e : pluginConfigEntries) {
PluginConfig pluginCfg = cfg.getPluginConfig(e.getPluginName());
ProjectConfigEntry configEntry = e.getProvider().get();
String value = pluginCfg.getString(e.getExportName());
String oldValue =
destProject.getPluginConfig(e.getPluginName()).getString(e.getExportName());
if (!Objects.equals(value, oldValue) && !configEntry.isEditable(destProject)) {
throw new MergeValidationException(PLUGIN_VALUE_NOT_EDITABLE);
}
if (ProjectConfigEntryType.LIST.equals(configEntry.getType())
&& value != null
&& !configEntry.getPermittedValues().contains(value)) {
throw new MergeValidationException(PLUGIN_VALUE_NOT_PERMITTED);
}
}
} catch (ConfigInvalidException | IOException e) {
throw new MergeValidationException(INVALID_CONFIG, e);
}
}
}
}
/** Validator that calls to plugins that provide additional validators. */
public static class PluginMergeValidationListener implements MergeValidationListener {
private final PluginSetContext<MergeValidationListener> mergeValidationListeners;
public PluginMergeValidationListener(
PluginSetContext<MergeValidationListener> mergeValidationListeners) {
this.mergeValidationListeners = mergeValidationListeners;
}
@Override
public void onPreMerge(
Repository repo,
CodeReviewRevWalk revWalk,
CodeReviewCommit commit,
ProjectState destProject,
BranchNameKey destBranch,
PatchSet.Id patchSetId,
IdentifiedUser caller)
throws MergeValidationException {
mergeValidationListeners.runEach(
l -> l.onPreMerge(repo, revWalk, commit, destProject, destBranch, patchSetId, caller),
MergeValidationException.class);
}
}
public static class AccountMergeValidator implements MergeValidationListener {
public interface Factory {
AccountMergeValidator create();
}
private final AllUsersName allUsersName;
private final ChangeData.Factory changeDataFactory;
private final AccountValidator accountValidator;
@Inject
public AccountMergeValidator(
AllUsersName allUsersName,
ChangeData.Factory changeDataFactory,
AccountValidator accountValidator) {
this.allUsersName = allUsersName;
this.changeDataFactory = changeDataFactory;
this.accountValidator = accountValidator;
}
@Override
public void onPreMerge(
Repository repo,
CodeReviewRevWalk revWalk,
CodeReviewCommit commit,
ProjectState destProject,
BranchNameKey destBranch,
PatchSet.Id patchSetId,
IdentifiedUser caller)
throws MergeValidationException {
Account.Id accountId = Account.Id.fromRef(destBranch.branch());
if (!allUsersName.equals(destProject.getNameKey()) || accountId == null) {
return;
}
ChangeData cd =
changeDataFactory.create(destProject.getProject().getNameKey(), patchSetId.changeId());
try {
if (!cd.currentFilePaths().contains(AccountProperties.ACCOUNT_CONFIG)) {
return;
}
} catch (StorageException e) {
logger.atSevere().withCause(e).log("Cannot validate account update");
throw new MergeValidationException("account validation unavailable", e);
}
try {
List<String> errorMessages =
accountValidator.validate(accountId, repo, revWalk, null, commit);
if (!errorMessages.isEmpty()) {
throw new MergeValidationException(
"invalid account configuration: " + Joiner.on("; ").join(errorMessages));
}
} catch (IOException e) {
logger.atSevere().withCause(e).log("Cannot validate account update");
throw new MergeValidationException("account validation unavailable", e);
}
}
}
/** Validator to ensure that group refs are not mutated. */
public static class GroupMergeValidator implements MergeValidationListener {
public interface Factory {
GroupMergeValidator create();
}
private final AllUsersName allUsersName;
private final ChangeData.Factory changeDataFactory;
@Inject
public GroupMergeValidator(AllUsersName allUsersName, ChangeData.Factory changeDataFactory) {
this.allUsersName = allUsersName;
this.changeDataFactory = changeDataFactory;
}
@Override
public void onPreMerge(
Repository repo,
CodeReviewRevWalk revWalk,
CodeReviewCommit commit,
ProjectState destProject,
BranchNameKey destBranch,
PatchSet.Id patchSetId,
IdentifiedUser caller)
throws MergeValidationException {
// Groups are stored inside the 'All-Users' repository.
if (!allUsersName.equals(destProject.getNameKey())
|| !RefNames.isGroupRef(destBranch.branch())) {
return;
}
// Update to group files is not supported because there are no validations
// on the changes being done to these files, without which the group data
// might get corrupted. Thus don't allow merges into All-Users group refs
// which updates group files (i.e., group.config, members and subgroups).
// But it is still useful to allow users to update files apart from group
// files. For example, users can upload named destinations into group refs.
ChangeData cd =
changeDataFactory.create(destProject.getProject().getNameKey(), patchSetId.changeId());
try {
if (cd.currentFilePaths().contains(GroupConfig.GROUP_CONFIG_FILE)
|| cd.currentFilePaths().contains(GroupConfig.MEMBERS_FILE)
|| cd.currentFilePaths().contains(GroupConfig.SUBGROUPS_FILE)) {
throw new MergeValidationException(
String.format(
"update to group files (%s, %s, %s) not allowed",
GroupConfig.GROUP_CONFIG_FILE,
GroupConfig.MEMBERS_FILE,
GroupConfig.SUBGROUPS_FILE));
}
} catch (StorageException e) {
logger.atSevere().withCause(e).log("Cannot validate group update");
throw new MergeValidationException("group validation unavailable", e);
}
}
}
/**
* Validator to ensure that destBranch is not a symbolic reference (an attempt to merge into a
* symbolic ref branch leads to LOCK_FAILURE exception).
*/
private static class DestBranchRefValidator implements MergeValidationListener {
@Override
public void onPreMerge(
Repository repo,
CodeReviewRevWalk revWalk,
CodeReviewCommit commit,
ProjectState destProject,
BranchNameKey destBranch,
PatchSet.Id patchSetId,
IdentifiedUser caller)
throws MergeValidationException {
try {
Ref ref = repo.exactRef(destBranch.branch());
// Usually the target branch exists, but there is an exception for some branches (see
// {@link com.google.gerrit.server.git.receive.ReceiveCommits} for details).
// Such non-existing branches should be ignored.
if (ref != null && ref.isSymbolic()) {
throw new MergeValidationException("the target branch is a symbolic ref");
}
} catch (IOException e) {
logger.atSevere().withCause(e).log("Cannot validate destination branch");
throw new MergeValidationException("symref validation unavailable", e);
}
}
}
}