| // Copyright (C) 2012 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 static com.google.common.base.Preconditions.checkState; | |
| import static com.google.gerrit.entities.Change.CHANGE_ID_PATTERN; | |
| import static com.google.gerrit.entities.RefNames.REFS_CHANGES; | |
| import static com.google.gerrit.entities.RefNames.REFS_CONFIG; | |
| import static com.google.gerrit.server.project.ProjectCache.illegalState; | |
| import static java.util.stream.Collectors.toList; | |
| import com.google.common.annotations.VisibleForTesting; | |
| import com.google.common.base.Strings; | |
| import com.google.common.collect.ImmutableList; | |
| import com.google.common.flogger.FluentLogger; | |
| import com.google.gerrit.common.FooterConstants; | |
| import com.google.gerrit.common.Nullable; | |
| import com.google.gerrit.entities.Account; | |
| import com.google.gerrit.entities.BooleanProjectConfig; | |
| import com.google.gerrit.entities.BranchNameKey; | |
| import com.google.gerrit.entities.Change; | |
| import com.google.gerrit.entities.RefNames; | |
| import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo; | |
| import com.google.gerrit.extensions.registration.DynamicItem; | |
| import com.google.gerrit.extensions.restapi.AuthException; | |
| import com.google.gerrit.server.ChangeUtil; | |
| import com.google.gerrit.server.GerritPersonIdent; | |
| import com.google.gerrit.server.IdentifiedUser; | |
| import com.google.gerrit.server.account.AccountCache; | |
| import com.google.gerrit.server.account.externalids.ExternalIdsConsistencyChecker; | |
| 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.UrlFormatter; | |
| import com.google.gerrit.server.events.CommitReceivedEvent; | |
| import com.google.gerrit.server.git.GitRepositoryManager; | |
| import com.google.gerrit.server.git.ValidationError; | |
| import com.google.gerrit.server.logging.Metadata; | |
| import com.google.gerrit.server.logging.TraceContext; | |
| import com.google.gerrit.server.logging.TraceContext.TraceTimer; | |
| import com.google.gerrit.server.patch.DiffOperations; | |
| import com.google.gerrit.server.permissions.PermissionBackend; | |
| import com.google.gerrit.server.permissions.PermissionBackendException; | |
| import com.google.gerrit.server.permissions.RefPermission; | |
| import com.google.gerrit.server.plugincontext.PluginSetContext; | |
| import com.google.gerrit.server.project.LabelConfigValidator; | |
| 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.ssh.HostKey; | |
| import com.google.gerrit.server.ssh.SshInfo; | |
| import com.google.gerrit.server.util.MagicBranch; | |
| import com.google.inject.Inject; | |
| import com.google.inject.Singleton; | |
| import java.io.IOException; | |
| import java.net.MalformedURLException; | |
| import java.net.URL; | |
| import java.util.ArrayList; | |
| import java.util.Collections; | |
| import java.util.List; | |
| import java.util.Optional; | |
| import java.util.regex.Pattern; | |
| import org.eclipse.jgit.diff.DiffEntry; | |
| import org.eclipse.jgit.diff.DiffFormatter; | |
| import org.eclipse.jgit.errors.ConfigInvalidException; | |
| import org.eclipse.jgit.lib.Config; | |
| import org.eclipse.jgit.lib.PersonIdent; | |
| import org.eclipse.jgit.lib.Repository; | |
| import org.eclipse.jgit.notes.NoteMap; | |
| import org.eclipse.jgit.revwalk.FooterKey; | |
| import org.eclipse.jgit.revwalk.FooterLine; | |
| import org.eclipse.jgit.revwalk.RevCommit; | |
| import org.eclipse.jgit.revwalk.RevWalk; | |
| import org.eclipse.jgit.util.SystemReader; | |
| import org.eclipse.jgit.util.io.DisabledOutputStream; | |
| /** | |
| * Represents a list of {@link CommitValidationListener}s to run for a push to one branch of one | |
| * project. | |
| */ | |
| public class CommitValidators { | |
| private static final FluentLogger logger = FluentLogger.forEnclosingClass(); | |
| public static final Pattern NEW_PATCHSET_PATTERN = | |
| Pattern.compile("^" + REFS_CHANGES + "(?:[0-9][0-9]/)?([1-9][0-9]*)(?:/[1-9][0-9]*)?$"); | |
| @Singleton | |
| public static class Factory { | |
| private final PersonIdent gerritIdent; | |
| private final DynamicItem<UrlFormatter> urlFormatter; | |
| private final PluginSetContext<CommitValidationListener> pluginValidators; | |
| private final GitRepositoryManager repoManager; | |
| private final AllUsersName allUsers; | |
| private final AllProjectsName allProjects; | |
| private final ExternalIdsConsistencyChecker externalIdsConsistencyChecker; | |
| private final AccountValidator accountValidator; | |
| private final AccountCache accountCache; | |
| private final ProjectCache projectCache; | |
| private final ProjectConfig.Factory projectConfigFactory; | |
| private final DiffOperations diffOperations; | |
| private final Config config; | |
| private final ChangeUtil changeUtil; | |
| @Inject | |
| Factory( | |
| @GerritPersonIdent PersonIdent gerritIdent, | |
| DynamicItem<UrlFormatter> urlFormatter, | |
| @GerritServerConfig Config config, | |
| PluginSetContext<CommitValidationListener> pluginValidators, | |
| GitRepositoryManager repoManager, | |
| AllUsersName allUsers, | |
| AllProjectsName allProjects, | |
| ExternalIdsConsistencyChecker externalIdsConsistencyChecker, | |
| AccountValidator accountValidator, | |
| AccountCache accountCache, | |
| ProjectCache projectCache, | |
| ProjectConfig.Factory projectConfigFactory, | |
| DiffOperations diffOperations, | |
| ChangeUtil changeUtil) { | |
| this.gerritIdent = gerritIdent; | |
| this.urlFormatter = urlFormatter; | |
| this.config = config; | |
| this.pluginValidators = pluginValidators; | |
| this.repoManager = repoManager; | |
| this.allUsers = allUsers; | |
| this.allProjects = allProjects; | |
| this.externalIdsConsistencyChecker = externalIdsConsistencyChecker; | |
| this.accountValidator = accountValidator; | |
| this.accountCache = accountCache; | |
| this.projectCache = projectCache; | |
| this.projectConfigFactory = projectConfigFactory; | |
| this.diffOperations = diffOperations; | |
| this.changeUtil = changeUtil; | |
| } | |
| public CommitValidators forReceiveCommits( | |
| PermissionBackend.ForProject forProject, | |
| BranchNameKey branch, | |
| IdentifiedUser user, | |
| SshInfo sshInfo, | |
| NoteMap rejectCommits, | |
| RevWalk rw, | |
| @Nullable Change change, | |
| boolean skipValidation) { | |
| PermissionBackend.ForRef perm = forProject.ref(branch.branch()); | |
| ProjectState projectState = | |
| projectCache.get(branch.project()).orElseThrow(illegalState(branch.project())); | |
| ImmutableList.Builder<CommitValidationListener> validators = ImmutableList.builder(); | |
| validators | |
| .add(new UploadMergesPermissionValidator(perm)) | |
| .add(new ProjectStateValidationListener(projectState)) | |
| .add(new AmendedGerritMergeCommitValidationListener(perm, gerritIdent)) | |
| .add(new AuthorUploaderValidator(user, perm, urlFormatter.get())) | |
| .add(new FileCountValidator(repoManager, config, urlFormatter.get())) | |
| .add(new CommitterUploaderValidator(user, perm, urlFormatter.get())) | |
| .add(new SignedOffByValidator(user, perm, projectState)) | |
| .add( | |
| new ChangeIdValidator( | |
| changeUtil, projectState, user, urlFormatter.get(), config, sshInfo, change)) | |
| .add(new ConfigValidator(projectConfigFactory, branch, user, rw, allUsers, allProjects)) | |
| .add(new BannedCommitsValidator(rejectCommits)) | |
| .add(new PluginCommitValidationListener(pluginValidators, skipValidation)) | |
| .add(new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker, accountCache)) | |
| .add(new AccountCommitValidator(repoManager, allUsers, accountValidator)) | |
| .add(new GroupCommitValidator(allUsers)) | |
| .add(new LabelConfigValidator(diffOperations)); | |
| return new CommitValidators(validators.build()); | |
| } | |
| public CommitValidators forGerritCommits( | |
| PermissionBackend.ForProject forProject, | |
| BranchNameKey branch, | |
| IdentifiedUser user, | |
| SshInfo sshInfo, | |
| RevWalk rw, | |
| @Nullable Change change) { | |
| PermissionBackend.ForRef perm = forProject.ref(branch.branch()); | |
| ProjectState projectState = | |
| projectCache.get(branch.project()).orElseThrow(illegalState(branch.project())); | |
| ImmutableList.Builder<CommitValidationListener> validators = ImmutableList.builder(); | |
| validators | |
| .add(new UploadMergesPermissionValidator(perm)) | |
| .add(new ProjectStateValidationListener(projectState)) | |
| .add(new AmendedGerritMergeCommitValidationListener(perm, gerritIdent)) | |
| .add(new AuthorUploaderValidator(user, perm, urlFormatter.get())) | |
| .add(new FileCountValidator(repoManager, config, urlFormatter.get())) | |
| .add(new SignedOffByValidator(user, perm, projectState)) | |
| .add( | |
| new ChangeIdValidator( | |
| changeUtil, projectState, user, urlFormatter.get(), config, sshInfo, change)) | |
| .add(new ConfigValidator(projectConfigFactory, branch, user, rw, allUsers, allProjects)) | |
| .add(new PluginCommitValidationListener(pluginValidators)) | |
| .add(new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker, accountCache)) | |
| .add(new AccountCommitValidator(repoManager, allUsers, accountValidator)) | |
| .add(new GroupCommitValidator(allUsers)) | |
| .add(new LabelConfigValidator(diffOperations)); | |
| return new CommitValidators(validators.build()); | |
| } | |
| public CommitValidators forMergedCommits( | |
| PermissionBackend.ForProject forProject, BranchNameKey branch, IdentifiedUser user) { | |
| // Generally only include validators that are based on permissions of the | |
| // user creating a change for a merged commit; generally exclude | |
| // validators that would require amending the change in order to correct. | |
| // | |
| // Examples: | |
| // - Change-Id and Signed-off-by can't be added to an already-merged | |
| // commit. | |
| // - If the commit is banned, we can't ban it here. In fact, creating a | |
| // review of a previously merged and recently-banned commit is a use | |
| // case for post-commit code review: so reviewers have a place to | |
| // discuss what to do about it. | |
| // - Plugin validators may do things like require certain commit message | |
| // formats, so we play it safe and exclude them. | |
| PermissionBackend.ForRef perm = forProject.ref(branch.branch()); | |
| ProjectState projectState = | |
| projectCache.get(branch.project()).orElseThrow(illegalState(branch.project())); | |
| ImmutableList.Builder<CommitValidationListener> validators = ImmutableList.builder(); | |
| validators | |
| .add(new UploadMergesPermissionValidator(perm)) | |
| .add(new ProjectStateValidationListener(projectState)) | |
| .add(new AuthorUploaderValidator(user, perm, urlFormatter.get())) | |
| .add(new CommitterUploaderValidator(user, perm, urlFormatter.get())); | |
| return new CommitValidators(validators.build()); | |
| } | |
| } | |
| private final List<CommitValidationListener> validators; | |
| CommitValidators(List<CommitValidationListener> validators) { | |
| this.validators = validators; | |
| } | |
| public List<CommitValidationMessage> validate(CommitReceivedEvent receiveEvent) | |
| throws CommitValidationException { | |
| List<CommitValidationMessage> messages = new ArrayList<>(); | |
| try { | |
| for (CommitValidationListener commitValidator : validators) { | |
| try (TraceTimer ignored = | |
| TraceContext.newTimer( | |
| "Running CommitValidationListener", | |
| Metadata.builder() | |
| .className(commitValidator.getClass().getSimpleName()) | |
| .projectName(receiveEvent.getProjectNameKey().get()) | |
| .branchName(receiveEvent.getBranchNameKey().branch()) | |
| .commit(receiveEvent.commit.name()) | |
| .build())) { | |
| messages.addAll(commitValidator.onCommitReceived(receiveEvent)); | |
| } | |
| } | |
| } catch (CommitValidationException e) { | |
| logger.atFine().withCause(e).log( | |
| "CommitValidationException occurred: %s", e.getFullMessage()); | |
| // Keep the old messages (and their order) in case of an exception | |
| messages.addAll(e.getMessages()); | |
| throw new CommitValidationException(e.getMessage(), messages); | |
| } | |
| return messages; | |
| } | |
| public static class ChangeIdValidator implements CommitValidationListener { | |
| private static final String CHANGE_ID_PREFIX = FooterConstants.CHANGE_ID.getName() + ":"; | |
| private static final String MISSING_CHANGE_ID_MSG = "missing Change-Id in message footer"; | |
| private static final String MISSING_SUBJECT_MSG = | |
| "missing subject; Change-Id must be in message footer"; | |
| private static final String CHANGE_ID_ABOVE_FOOTER_MSG = "Change-Id must be in message footer"; | |
| private static final String MULTIPLE_CHANGE_ID_MSG = | |
| "multiple Change-Id lines in message footer"; | |
| private static final String INVALID_CHANGE_ID_MSG = | |
| "invalid Change-Id line format in message footer"; | |
| @VisibleForTesting | |
| public static final String CHANGE_ID_MISMATCH_MSG = | |
| "Change-Id in message footer does not match Change-Id of target change"; | |
| private static final Pattern CHANGE_ID = Pattern.compile(CHANGE_ID_PATTERN); | |
| private final ChangeUtil changeUtil; | |
| private final ProjectState projectState; | |
| private final UrlFormatter urlFormatter; | |
| private final String installCommitMsgHookCommand; | |
| private final SshInfo sshInfo; | |
| private final IdentifiedUser user; | |
| private final Change change; | |
| public ChangeIdValidator( | |
| ChangeUtil changeUtil, | |
| ProjectState projectState, | |
| IdentifiedUser user, | |
| UrlFormatter urlFormatter, | |
| Config config, | |
| SshInfo sshInfo, | |
| Change change) { | |
| this.changeUtil = changeUtil; | |
| this.projectState = projectState; | |
| this.user = user; | |
| this.urlFormatter = urlFormatter; | |
| installCommitMsgHookCommand = config.getString("gerrit", null, "installCommitMsgHookCommand"); | |
| this.sshInfo = sshInfo; | |
| this.change = change; | |
| } | |
| @Override | |
| public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) | |
| throws CommitValidationException { | |
| if (!shouldValidateChangeId(receiveEvent)) { | |
| return Collections.emptyList(); | |
| } | |
| RevCommit commit = receiveEvent.commit; | |
| List<CommitValidationMessage> messages = new ArrayList<>(); | |
| List<String> idList = changeUtil.getChangeIdsFromFooter(commit); | |
| if (idList.isEmpty()) { | |
| String shortMsg = commit.getShortMessage(); | |
| if (shortMsg.startsWith(CHANGE_ID_PREFIX) | |
| && CHANGE_ID.matcher(shortMsg.substring(CHANGE_ID_PREFIX.length()).trim()).matches()) { | |
| throw new CommitValidationException(MISSING_SUBJECT_MSG); | |
| } | |
| if (commit.getFullMessage().contains("\n" + CHANGE_ID_PREFIX)) { | |
| messages.add( | |
| new CommitValidationMessage( | |
| CHANGE_ID_ABOVE_FOOTER_MSG | |
| + "\n" | |
| + "\n" | |
| + "Hint: run\n" | |
| + " git commit --amend\n" | |
| + "and move 'Change-Id: Ixxx..' to the bottom on a separate line\n", | |
| ValidationMessage.Type.ERROR)); | |
| throw new CommitValidationException(CHANGE_ID_ABOVE_FOOTER_MSG, messages); | |
| } | |
| if (projectState.is(BooleanProjectConfig.REQUIRE_CHANGE_ID)) { | |
| messages.add(getMissingChangeIdErrorMsg(MISSING_CHANGE_ID_MSG)); | |
| throw new CommitValidationException(MISSING_CHANGE_ID_MSG, messages); | |
| } | |
| } else if (idList.size() > 1) { | |
| throw new CommitValidationException(MULTIPLE_CHANGE_ID_MSG, messages); | |
| } else { | |
| String v = idList.get(0).trim(); | |
| // Reject Change-Ids with wrong format and invalid placeholder ID from | |
| // Egit (I0000000000000000000000000000000000000000). | |
| if (!CHANGE_ID.matcher(v).matches() || v.matches("^I00*$")) { | |
| messages.add(getMissingChangeIdErrorMsg(INVALID_CHANGE_ID_MSG)); | |
| throw new CommitValidationException(INVALID_CHANGE_ID_MSG, messages); | |
| } | |
| if (change != null && !v.equals(change.getKey().get())) { | |
| throw new CommitValidationException(CHANGE_ID_MISMATCH_MSG); | |
| } | |
| } | |
| return Collections.emptyList(); | |
| } | |
| private static boolean shouldValidateChangeId(CommitReceivedEvent event) { | |
| return MagicBranch.isMagicBranch(event.command.getRefName()) | |
| || NEW_PATCHSET_PATTERN.matcher(event.command.getRefName()).matches(); | |
| } | |
| private CommitValidationMessage getMissingChangeIdErrorMsg(String errMsg) { | |
| return new CommitValidationMessage( | |
| errMsg | |
| + "\n" | |
| + "\nHint: to automatically insert a Change-Id, install the hook:\n" | |
| + getCommitMessageHookInstallationHint() | |
| + "\n" | |
| + "and then amend the commit:\n" | |
| + " git commit --amend --no-edit\n" | |
| + "Finally, push your changes again\n", | |
| ValidationMessage.Type.ERROR); | |
| } | |
| private String getCommitMessageHookInstallationHint() { | |
| if (installCommitMsgHookCommand != null) { | |
| return installCommitMsgHookCommand; | |
| } | |
| final List<HostKey> hostKeys = sshInfo.getHostKeys(); | |
| // If there are no SSH keys, the commit-msg hook must be installed via | |
| // HTTP(S) | |
| Optional<String> webUrl = urlFormatter.getWebUrl(); | |
| String httpHook = | |
| String.format( | |
| "f=\"$(git rev-parse --git-dir)/hooks/commit-msg\"; curl -o \"$f\" %stools/hooks/commit-msg ; chmod +x \"$f\"", | |
| webUrl.get()); | |
| if (hostKeys.isEmpty()) { | |
| checkState(webUrl.isPresent()); | |
| return httpHook; | |
| } | |
| // SSH keys exist, so the hook might be able to be installed with scp. | |
| String sshHost; | |
| int sshPort; | |
| String host = hostKeys.get(0).getHost(); | |
| int c = host.lastIndexOf(':'); | |
| if (0 <= c) { | |
| if (host.startsWith("*:")) { | |
| checkState(webUrl.isPresent()); | |
| sshHost = getGerritHost(webUrl.get()); | |
| } else { | |
| sshHost = host.substring(0, c); | |
| } | |
| sshPort = Integer.parseInt(host.substring(c + 1)); | |
| } else { | |
| sshHost = host; | |
| sshPort = 22; | |
| } | |
| // TODO(15944): Remove once both SFTP/SCP protocol are supported. | |
| // | |
| // In newer versions of OpenSSH, the default hook installation command will fail with a | |
| // cryptic error because the scp binary defaults to a different protocol. | |
| String scpFlagHint = "(for OpenSSH >= 9.0 you need to add the flag '-O' to the scp command)"; | |
| String sshHook = | |
| String.format( | |
| "gitdir=$(git rev-parse --git-dir); scp -p -P %d %s@%s:hooks/commit-msg ${gitdir}/hooks/", | |
| sshPort, user.getUserName().orElse("<USERNAME>"), sshHost); | |
| return String.format(" %s\n%s\nor, for http(s):\n %s", sshHook, scpFlagHint, httpHook); | |
| } | |
| } | |
| /** Limits the number of files per change. */ | |
| private static class FileCountValidator implements CommitValidationListener { | |
| private static final int FILE_COUNT_WARNING_THRESHOLD = 10_000; | |
| private final GitRepositoryManager repoManager; | |
| private final int maxFileCount; | |
| private final UrlFormatter urlFormatter; | |
| FileCountValidator(GitRepositoryManager repoManager, Config config, UrlFormatter urlFormatter) { | |
| this.repoManager = repoManager; | |
| this.urlFormatter = urlFormatter; | |
| maxFileCount = config.getInt("change", null, "maxFiles", 100_000); | |
| } | |
| @Override | |
| public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) | |
| throws CommitValidationException { | |
| // TODO(zieren): Refactor interface to signal the intent of the event instead of hard-coding | |
| // it here. Due to interface limitations, this method is called from both receive commits | |
| // and from main Gerrit (e.g. when publishing a change edit). This is why we need to gate the | |
| // early return on REFS_CHANGES (though pushes to refs/changes are not possible). | |
| String refName = receiveEvent.command.getRefName(); | |
| if (!refName.startsWith("refs/for/") && !refName.startsWith(RefNames.REFS_CHANGES)) { | |
| // This is a direct push bypassing review. We don't need to enforce any file-count limits | |
| // here. | |
| return Collections.emptyList(); | |
| } | |
| // Use DiffFormatter to compute the number of files in the change. This should be faster than | |
| // the previous approach of using the PatchListCache. | |
| try { | |
| long changedFiles = countChangedFiles(receiveEvent); | |
| if (changedFiles > maxFileCount) { | |
| throw new CommitValidationException( | |
| String.format( | |
| "Exceeding maximum number of files per change (%d > %d)", | |
| changedFiles, maxFileCount)); | |
| } | |
| if (changedFiles > FILE_COUNT_WARNING_THRESHOLD) { | |
| String host = getGerritHost(urlFormatter.getWebUrl().orElse(null)); | |
| String project = receiveEvent.project.getNameKey().get(); | |
| logger.atWarning().log( | |
| "Warning: Change with %d files on host %s, project %s, ref %s", | |
| changedFiles, host, project, refName); | |
| } | |
| } catch (IOException e) { | |
| // This happens e.g. for cherrypicks. | |
| if (!receiveEvent.command.getRefName().startsWith(REFS_CHANGES)) { | |
| logger.atWarning().withCause(e).log( | |
| "Failed to validate file count for commit: %s", receiveEvent.commit); | |
| } | |
| } | |
| return Collections.emptyList(); | |
| } | |
| private long countChangedFiles(CommitReceivedEvent receiveEvent) throws IOException { | |
| try (Repository repository = repoManager.openRepository(receiveEvent.project.getNameKey()); | |
| DiffFormatter diffFormatter = new DiffFormatter(DisabledOutputStream.INSTANCE)) { | |
| diffFormatter.setRepository(repository); | |
| // Do not detect renames; that would require reading file contents, which is slow for large | |
| // files. | |
| diffFormatter.setDetectRenames(false); | |
| // For merge commits, i.e. >1 parents, we use parent #0 by convention. | |
| List<DiffEntry> diffEntries = | |
| diffFormatter.scan( | |
| receiveEvent.commit.getParentCount() > 0 ? receiveEvent.commit.getParent(0) : null, | |
| receiveEvent.commit); | |
| return diffEntries.stream().map(DiffEntry::getNewPath).distinct().count(); | |
| } | |
| } | |
| } | |
| /** If this is the special project configuration branch, validate the config. */ | |
| public static class ConfigValidator implements CommitValidationListener { | |
| private final ProjectConfig.Factory projectConfigFactory; | |
| private final BranchNameKey branch; | |
| private final IdentifiedUser user; | |
| private final RevWalk rw; | |
| private final AllUsersName allUsers; | |
| private final AllProjectsName allProjects; | |
| public ConfigValidator( | |
| ProjectConfig.Factory projectConfigFactory, | |
| BranchNameKey branch, | |
| IdentifiedUser user, | |
| RevWalk rw, | |
| AllUsersName allUsers, | |
| AllProjectsName allProjects) { | |
| this.projectConfigFactory = projectConfigFactory; | |
| this.branch = branch; | |
| this.user = user; | |
| this.rw = rw; | |
| this.allProjects = allProjects; | |
| this.allUsers = allUsers; | |
| } | |
| @Override | |
| public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) | |
| throws CommitValidationException { | |
| if (REFS_CONFIG.equals(branch.branch())) { | |
| List<CommitValidationMessage> messages = new ArrayList<>(); | |
| try { | |
| ProjectConfig cfg = projectConfigFactory.create(receiveEvent.project.getNameKey()); | |
| cfg.load(rw, receiveEvent.command.getNewId()); | |
| if (!cfg.getValidationErrors().isEmpty()) { | |
| addError("Invalid project configuration:", messages); | |
| for (ValidationError err : cfg.getValidationErrors()) { | |
| addError(" " + err.getMessage(), messages); | |
| } | |
| throw new CommitValidationException("invalid project configuration", messages); | |
| } | |
| if (allUsers.equals(receiveEvent.project.getNameKey()) | |
| && !allProjects.equals(cfg.getProject().getParent(allProjects))) { | |
| addError("Invalid project configuration:", messages); | |
| addError( | |
| String.format(" %s must inherit from %s", allUsers.get(), allProjects.get()), | |
| messages); | |
| throw new CommitValidationException("invalid project configuration", messages); | |
| } | |
| } catch (ConfigInvalidException | IOException e) { | |
| if (e instanceof ConfigInvalidException && !Strings.isNullOrEmpty(e.getMessage())) { | |
| addError(e.getMessage(), messages); | |
| } | |
| logger.atSevere().withCause(e).log( | |
| "User %s tried to push an invalid project configuration %s for project %s", | |
| user.getLoggableName(), | |
| receiveEvent.command.getNewId().name(), | |
| receiveEvent.project.getName()); | |
| throw new CommitValidationException("invalid project configuration", messages); | |
| } | |
| } | |
| return Collections.emptyList(); | |
| } | |
| } | |
| /** Require permission to upload merge commits. */ | |
| public static class UploadMergesPermissionValidator implements CommitValidationListener { | |
| private final PermissionBackend.ForRef perm; | |
| public UploadMergesPermissionValidator(PermissionBackend.ForRef perm) { | |
| this.perm = perm; | |
| } | |
| @Override | |
| public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) | |
| throws CommitValidationException { | |
| if (receiveEvent.commit.getParentCount() <= 1) { | |
| return Collections.emptyList(); | |
| } | |
| try { | |
| if (perm.test(RefPermission.MERGE)) { | |
| return Collections.emptyList(); | |
| } | |
| throw new CommitValidationException("you are not allowed to upload merges"); | |
| } catch (PermissionBackendException e) { | |
| logger.atSevere().withCause(e).log("cannot check MERGE"); | |
| throw new CommitValidationException("internal auth error"); | |
| } | |
| } | |
| } | |
| /** Execute commit validation plug-ins */ | |
| public static class PluginCommitValidationListener implements CommitValidationListener { | |
| private final boolean skipValidation; | |
| private final PluginSetContext<CommitValidationListener> commitValidationListeners; | |
| public PluginCommitValidationListener( | |
| final PluginSetContext<CommitValidationListener> commitValidationListeners) { | |
| this(commitValidationListeners, false); | |
| } | |
| public PluginCommitValidationListener( | |
| final PluginSetContext<CommitValidationListener> commitValidationListeners, | |
| boolean skipValidation) { | |
| this.skipValidation = skipValidation; | |
| this.commitValidationListeners = commitValidationListeners; | |
| } | |
| private void runValidator( | |
| CommitValidationListener validator, | |
| List<CommitValidationMessage> messages, | |
| CommitReceivedEvent receiveEvent) | |
| throws CommitValidationException { | |
| if (skipValidation && !validator.shouldValidateAllCommits()) { | |
| return; | |
| } | |
| messages.addAll(validator.onCommitReceived(receiveEvent)); | |
| } | |
| @Override | |
| public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) | |
| throws CommitValidationException { | |
| List<CommitValidationMessage> messages = new ArrayList<>(); | |
| try { | |
| commitValidationListeners.runEach( | |
| l -> runValidator(l, messages, receiveEvent), CommitValidationException.class); | |
| } catch (CommitValidationException e) { | |
| messages.addAll(e.getMessages()); | |
| throw new CommitValidationException(e.getMessage(), messages); | |
| } | |
| return messages; | |
| } | |
| @Override | |
| public boolean shouldValidateAllCommits() { | |
| return commitValidationListeners.stream() | |
| .anyMatch(CommitValidationListener::shouldValidateAllCommits); | |
| } | |
| } | |
| public static class SignedOffByValidator implements CommitValidationListener { | |
| private final IdentifiedUser user; | |
| private final PermissionBackend.ForRef perm; | |
| private final ProjectState state; | |
| public SignedOffByValidator( | |
| IdentifiedUser user, PermissionBackend.ForRef perm, ProjectState state) { | |
| this.user = user; | |
| this.perm = perm; | |
| this.state = state; | |
| } | |
| @Override | |
| public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) | |
| throws CommitValidationException { | |
| if (!state.is(BooleanProjectConfig.USE_SIGNED_OFF_BY)) { | |
| return Collections.emptyList(); | |
| } | |
| RevCommit commit = receiveEvent.commit; | |
| PersonIdent committer = commit.getCommitterIdent(); | |
| PersonIdent author = commit.getAuthorIdent(); | |
| boolean sboAuthor = false; | |
| boolean sboCommitter = false; | |
| boolean sboMe = false; | |
| for (FooterLine footer : commit.getFooterLines()) { | |
| if (footer.matches(FooterKey.SIGNED_OFF_BY)) { | |
| String e = footer.getEmailAddress(); | |
| if (e != null) { | |
| sboAuthor |= author.getEmailAddress().equals(e); | |
| sboCommitter |= committer.getEmailAddress().equals(e); | |
| sboMe |= user.hasEmailAddress(e); | |
| } | |
| } | |
| } | |
| if (!sboAuthor && !sboCommitter && !sboMe) { | |
| try { | |
| if (!perm.test(RefPermission.FORGE_COMMITTER)) { | |
| throw new CommitValidationException( | |
| "not Signed-off-by author/committer/uploader in message footer"); | |
| } | |
| } catch (PermissionBackendException e) { | |
| logger.atSevere().withCause(e).log("cannot check FORGE_COMMITTER"); | |
| throw new CommitValidationException("internal auth error"); | |
| } | |
| } | |
| return Collections.emptyList(); | |
| } | |
| } | |
| /** Require that author matches the uploader. */ | |
| public static class AuthorUploaderValidator implements CommitValidationListener { | |
| private final IdentifiedUser user; | |
| private final PermissionBackend.ForRef perm; | |
| private final UrlFormatter urlFormatter; | |
| public AuthorUploaderValidator( | |
| IdentifiedUser user, PermissionBackend.ForRef perm, UrlFormatter urlFormatter) { | |
| this.user = user; | |
| this.perm = perm; | |
| this.urlFormatter = urlFormatter; | |
| } | |
| @Override | |
| public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) | |
| throws CommitValidationException { | |
| PersonIdent author = receiveEvent.commit.getAuthorIdent(); | |
| if (user.hasEmailAddress(author.getEmailAddress())) { | |
| return Collections.emptyList(); | |
| } | |
| try { | |
| if (!perm.test(RefPermission.FORGE_AUTHOR)) { | |
| throw new CommitValidationException( | |
| "invalid author", invalidEmail("author", author, user, urlFormatter)); | |
| } | |
| return Collections.emptyList(); | |
| } catch (PermissionBackendException e) { | |
| logger.atSevere().withCause(e).log("cannot check FORGE_AUTHOR"); | |
| throw new CommitValidationException("internal auth error"); | |
| } | |
| } | |
| } | |
| /** Require that committer matches the uploader. */ | |
| public static class CommitterUploaderValidator implements CommitValidationListener { | |
| private final IdentifiedUser user; | |
| private final PermissionBackend.ForRef perm; | |
| private final UrlFormatter urlFormatter; | |
| public CommitterUploaderValidator( | |
| IdentifiedUser user, PermissionBackend.ForRef perm, UrlFormatter urlFormatter) { | |
| this.user = user; | |
| this.perm = perm; | |
| this.urlFormatter = urlFormatter; | |
| } | |
| @Override | |
| public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) | |
| throws CommitValidationException { | |
| PersonIdent committer = receiveEvent.commit.getCommitterIdent(); | |
| if (user.hasEmailAddress(committer.getEmailAddress())) { | |
| return Collections.emptyList(); | |
| } | |
| try { | |
| if (!perm.test(RefPermission.FORGE_COMMITTER)) { | |
| throw new CommitValidationException( | |
| "invalid committer", invalidEmail("committer", committer, user, urlFormatter)); | |
| } | |
| return Collections.emptyList(); | |
| } catch (PermissionBackendException e) { | |
| logger.atSevere().withCause(e).log("cannot check FORGE_COMMITTER"); | |
| throw new CommitValidationException("internal auth error"); | |
| } | |
| } | |
| } | |
| /** | |
| * Don't allow the user to amend a merge created by Gerrit Code Review. This seems to happen all | |
| * too often, due to users not paying any attention to what they are doing. | |
| */ | |
| public static class AmendedGerritMergeCommitValidationListener | |
| implements CommitValidationListener { | |
| private final PermissionBackend.ForRef perm; | |
| private final PersonIdent gerritIdent; | |
| public AmendedGerritMergeCommitValidationListener( | |
| PermissionBackend.ForRef perm, PersonIdent gerritIdent) { | |
| this.perm = perm; | |
| this.gerritIdent = gerritIdent; | |
| } | |
| @Override | |
| public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) | |
| throws CommitValidationException { | |
| PersonIdent author = receiveEvent.commit.getAuthorIdent(); | |
| if (receiveEvent.commit.getParentCount() > 1 | |
| && author.getName().equals(gerritIdent.getName()) | |
| && author.getEmailAddress().equals(gerritIdent.getEmailAddress())) { | |
| try { | |
| // Stop authors from amending the merge commits that Gerrit itself creates. | |
| perm.check(RefPermission.FORGE_SERVER); | |
| } catch (AuthException denied) { | |
| throw new CommitValidationException( | |
| String.format( | |
| "pushing merge commit %s by %s requires '%s' permission", | |
| receiveEvent.commit.getId(), | |
| gerritIdent.getEmailAddress(), | |
| RefPermission.FORGE_SERVER.name()), | |
| denied); | |
| } catch (PermissionBackendException e) { | |
| logger.atSevere().withCause(e).log("cannot check FORGE_SERVER"); | |
| throw new CommitValidationException("internal auth error"); | |
| } | |
| } | |
| return Collections.emptyList(); | |
| } | |
| } | |
| /** Reject banned commits. */ | |
| public static class BannedCommitsValidator implements CommitValidationListener { | |
| private final NoteMap rejectCommits; | |
| public BannedCommitsValidator(NoteMap rejectCommits) { | |
| this.rejectCommits = rejectCommits; | |
| } | |
| @Override | |
| public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) | |
| throws CommitValidationException { | |
| try { | |
| if (rejectCommits.contains(receiveEvent.commit)) { | |
| throw new CommitValidationException( | |
| "contains banned commit " + receiveEvent.commit.getName()); | |
| } | |
| return Collections.emptyList(); | |
| } catch (IOException e) { | |
| throw new CommitValidationException("error checking banned commits", e); | |
| } | |
| } | |
| } | |
| /** Validates updates to refs/meta/external-ids. */ | |
| public static class ExternalIdUpdateListener implements CommitValidationListener { | |
| private final AllUsersName allUsers; | |
| private final AccountCache accountCache; | |
| private final ExternalIdsConsistencyChecker externalIdsConsistencyChecker; | |
| public ExternalIdUpdateListener( | |
| AllUsersName allUsers, | |
| ExternalIdsConsistencyChecker externalIdsConsistencyChecker, | |
| AccountCache accountCache) { | |
| this.externalIdsConsistencyChecker = externalIdsConsistencyChecker; | |
| this.allUsers = allUsers; | |
| this.accountCache = accountCache; | |
| } | |
| @Override | |
| public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) | |
| throws CommitValidationException { | |
| if (allUsers.equals(receiveEvent.project.getNameKey()) | |
| && RefNames.REFS_EXTERNAL_IDS.equals(receiveEvent.refName)) { | |
| try { | |
| List<ConsistencyProblemInfo> problems = | |
| externalIdsConsistencyChecker.check(accountCache, receiveEvent.commit); | |
| List<CommitValidationMessage> msgs = | |
| problems.stream() | |
| .map( | |
| p -> | |
| new CommitValidationMessage( | |
| p.message, | |
| p.status == ConsistencyProblemInfo.Status.ERROR | |
| ? ValidationMessage.Type.ERROR | |
| : ValidationMessage.Type.OTHER)) | |
| .collect(toList()); | |
| if (msgs.stream().anyMatch(ValidationMessage::isError)) { | |
| throw new CommitValidationException("invalid external IDs", msgs); | |
| } | |
| return msgs; | |
| } catch (IOException | ConfigInvalidException e) { | |
| throw new CommitValidationException("error validating external IDs", e); | |
| } | |
| } | |
| return Collections.emptyList(); | |
| } | |
| } | |
| public static class AccountCommitValidator implements CommitValidationListener { | |
| private final GitRepositoryManager repoManager; | |
| private final AllUsersName allUsers; | |
| private final AccountValidator accountValidator; | |
| public AccountCommitValidator( | |
| GitRepositoryManager repoManager, | |
| AllUsersName allUsers, | |
| AccountValidator accountValidator) { | |
| this.repoManager = repoManager; | |
| this.allUsers = allUsers; | |
| this.accountValidator = accountValidator; | |
| } | |
| @Override | |
| public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) | |
| throws CommitValidationException { | |
| if (!allUsers.equals(receiveEvent.project.getNameKey())) { | |
| return Collections.emptyList(); | |
| } | |
| if (receiveEvent.command.getRefName().startsWith(MagicBranch.NEW_CHANGE)) { | |
| // no validation on push for review, will be checked on submit by | |
| // MergeValidators.AccountMergeValidator | |
| return Collections.emptyList(); | |
| } | |
| Account.Id accountId = Account.Id.fromRef(receiveEvent.refName); | |
| if (accountId == null) { | |
| return Collections.emptyList(); | |
| } | |
| try (Repository repo = repoManager.openRepository(allUsers)) { | |
| List<String> errorMessages = | |
| accountValidator.validate( | |
| accountId, | |
| repo, | |
| receiveEvent.revWalk, | |
| receiveEvent.command.getOldId(), | |
| receiveEvent.commit); | |
| if (!errorMessages.isEmpty()) { | |
| throw new CommitValidationException( | |
| "invalid account configuration", | |
| errorMessages.stream() | |
| .map(m -> new CommitValidationMessage(m, ValidationMessage.Type.ERROR)) | |
| .collect(toList())); | |
| } | |
| } catch (IOException e) { | |
| throw new CommitValidationException( | |
| String.format("Validating update for account %s failed", accountId.get()), e); | |
| } | |
| return Collections.emptyList(); | |
| } | |
| } | |
| /** Rejects updates to group branches. */ | |
| public static class GroupCommitValidator implements CommitValidationListener { | |
| private final AllUsersName allUsers; | |
| public GroupCommitValidator(AllUsersName allUsers) { | |
| this.allUsers = allUsers; | |
| } | |
| @Override | |
| public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) | |
| throws CommitValidationException { | |
| // Groups are stored inside the 'All-Users' repository. | |
| if (!allUsers.equals(receiveEvent.project.getNameKey())) { | |
| return Collections.emptyList(); | |
| } | |
| if (receiveEvent.command.getRefName().startsWith(MagicBranch.NEW_CHANGE)) { | |
| // no validation on push for review, will be checked on submit by | |
| // MergeValidators.GroupMergeValidator | |
| return Collections.emptyList(); | |
| } | |
| if (RefNames.isGroupRef(receiveEvent.command.getRefName())) { | |
| throw new CommitValidationException("group update not allowed"); | |
| } | |
| return Collections.emptyList(); | |
| } | |
| } | |
| /** Rejects updates to projects that don't allow writes. */ | |
| public static class ProjectStateValidationListener implements CommitValidationListener { | |
| private final ProjectState projectState; | |
| public ProjectStateValidationListener(ProjectState projectState) { | |
| this.projectState = projectState; | |
| } | |
| @Override | |
| public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) | |
| throws CommitValidationException { | |
| if (projectState.statePermitsWrite()) { | |
| return Collections.emptyList(); | |
| } | |
| throw new CommitValidationException("project state does not permit write"); | |
| } | |
| } | |
| private static CommitValidationMessage invalidEmail( | |
| String type, PersonIdent who, IdentifiedUser currentUser, UrlFormatter urlFormatter) { | |
| StringBuilder sb = new StringBuilder(); | |
| sb.append("email address ") | |
| .append(who.getEmailAddress()) | |
| .append(" is not registered in your account, and you lack 'forge ") | |
| .append(type) | |
| .append("' permission.\n"); | |
| if (currentUser.getEmailAddresses().isEmpty()) { | |
| sb.append("You have not registered any email addresses.\n"); | |
| } else { | |
| sb.append("The following addresses are currently registered:\n"); | |
| for (String address : currentUser.getEmailAddresses()) { | |
| sb.append(" ").append(address).append("\n"); | |
| } | |
| } | |
| if (urlFormatter.getSettingsUrl("").isPresent()) { | |
| sb.append("To register an email address, visit:\n") | |
| .append(urlFormatter.getSettingsUrl("EmailAddresses").get()) | |
| .append("\n\n"); | |
| } | |
| return new CommitValidationMessage(sb.toString(), ValidationMessage.Type.ERROR); | |
| } | |
| /** | |
| * Get the Gerrit hostname. | |
| * | |
| * @return the hostname from the canonical URL if it is configured, otherwise whatever the OS says | |
| * the hostname is. | |
| */ | |
| private static String getGerritHost(String canonicalWebUrl) { | |
| if (canonicalWebUrl != null) { | |
| try { | |
| return new URL(canonicalWebUrl).getHost(); | |
| } catch (MalformedURLException ignored) { | |
| logger.atWarning().log( | |
| "configured canonical web URL is invalid, using system default: %s", | |
| ignored.getMessage()); | |
| } | |
| } | |
| return SystemReader.getInstance().getHostname(); | |
| } | |
| private static void addError(String error, List<CommitValidationMessage> messages) { | |
| messages.add(new CommitValidationMessage(error, ValidationMessage.Type.ERROR)); | |
| } | |
| } |