| // 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.errorprone.annotations.CanIgnoreReturnValue; |
| 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.Patch; |
| 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.metrics.Counter2; |
| import com.google.gerrit.metrics.Description; |
| import com.google.gerrit.metrics.Field; |
| import com.google.gerrit.metrics.MetricMaker; |
| 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.DiffNotAvailableException; |
| import com.google.gerrit.server.patch.gitdiff.ModifiedFile; |
| 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.query.approval.ApprovalQueryBuilder; |
| 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.Map; |
| import java.util.Optional; |
| import java.util.regex.Pattern; |
| import java.util.stream.Collectors; |
| 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; |
| |
| /** |
| * 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 Config config; |
| private final ChangeUtil changeUtil; |
| private final MetricMaker metricMaker; |
| private final ApprovalQueryBuilder approvalQueryBuilder; |
| |
| @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, |
| ChangeUtil changeUtil, |
| MetricMaker metricMaker, |
| ApprovalQueryBuilder approvalQueryBuilder) { |
| 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.changeUtil = changeUtil; |
| this.metricMaker = metricMaker; |
| this.approvalQueryBuilder = approvalQueryBuilder; |
| } |
| |
| 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(config, urlFormatter.get(), metricMaker)) |
| .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(approvalQueryBuilder)); |
| 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(config, urlFormatter.get(), metricMaker)) |
| .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(approvalQueryBuilder)); |
| 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; |
| } |
| |
| @CanIgnoreReturnValue |
| 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 int maxFileCount; |
| private final UrlFormatter urlFormatter; |
| private final Counter2<Integer, String> metricCountManyFilesPerChange; |
| |
| FileCountValidator(Config config, UrlFormatter urlFormatter, MetricMaker metricMaker) { |
| this.urlFormatter = urlFormatter; |
| this.metricCountManyFilesPerChange = |
| metricMaker.newCounter( |
| "validation/file_count", |
| new Description("Count commits with many files per change."), |
| Field.ofInteger("file_count", (meta, value) -> {}) |
| .description("number of files in the patchset") |
| .build(), |
| Field.ofString("host_repo", (meta, value) -> {}) |
| .description("host and repository of the change in the format 'host/repo'") |
| .build()); |
| 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); |
| |
| this.metricCountManyFilesPerChange.increment( |
| Math.toIntExact(changedFiles), String.format("%s/%s", host, project)); |
| } |
| } catch (DiffNotAvailableException 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 int countChangedFiles(CommitReceivedEvent receiveEvent) |
| throws DiffNotAvailableException { |
| // For merge commits this will compare against auto-merge. |
| Map<String, ModifiedFile> modifiedFiles = |
| receiveEvent.diffOperations.loadModifiedFilesAgainstParentIfNecessary( |
| receiveEvent.getProjectNameKey(), |
| receiveEvent.commit, |
| 0, |
| /* enableRenameDetection= */ true); |
| // We don't want to count the COMMIT_MSG and MERGE_LIST files. |
| List<ModifiedFile> modifiedFilesList = |
| modifiedFiles.values().stream() |
| .filter(p -> !Patch.isMagic(p.newPath().orElse(""))) |
| .collect(Collectors.toList()); |
| return modifiedFilesList.size(); |
| } |
| } |
| |
| /** 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"); |
| } |
| } |
| |
| public 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)); |
| } |
| } |