// 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.gerrit.reviewdb.client.Change.CHANGE_ID_PATTERN; import static com.google.gerrit.reviewdb.client.RefNames.REFS_CONFIG; import static com.google.gerrit.server.git.ReceiveCommits.NEW_PATCHSET; import static java.util.stream.Collectors.toList; import com.google.common.base.CharMatcher; import com.google.common.collect.ImmutableList; import com.google.gerrit.common.FooterConstants; import com.google.gerrit.common.Nullable; import com.google.gerrit.common.PageLinks; import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo; import com.google.gerrit.extensions.registration.DynamicSet; import com.google.gerrit.reviewdb.client.Account; import com.google.gerrit.reviewdb.client.RefNames; import com.google.gerrit.server.GerritPersonIdent; import com.google.gerrit.server.IdentifiedUser; import com.google.gerrit.server.account.WatchConfig; import com.google.gerrit.server.account.externalids.ExternalIdsConsistencyChecker; import com.google.gerrit.server.config.AllUsersName; import com.google.gerrit.server.config.CanonicalWebUrl; import com.google.gerrit.server.config.GerritServerConfig; import com.google.gerrit.server.events.CommitReceivedEvent; import com.google.gerrit.server.git.BanCommit; import com.google.gerrit.server.git.ProjectConfig; import com.google.gerrit.server.git.ValidationError; import com.google.gerrit.server.project.ProjectControl; import com.google.gerrit.server.project.RefControl; 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 com.jcraft.jsch.HostKey; 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.regex.Pattern; 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.slf4j.Logger; import org.slf4j.LoggerFactory; public class CommitValidators { private static final Logger log = LoggerFactory.getLogger(CommitValidators.class); @Singleton public static class Factory { private final PersonIdent gerritIdent; private final String canonicalWebUrl; private final DynamicSet<CommitValidationListener> pluginValidators; private final AllUsersName allUsers; private final ExternalIdsConsistencyChecker externalIdsConsistencyChecker; private final String installCommitMsgHookCommand; @Inject Factory( @GerritPersonIdent PersonIdent gerritIdent, @CanonicalWebUrl @Nullable String canonicalWebUrl, @GerritServerConfig Config cfg, DynamicSet<CommitValidationListener> pluginValidators, AllUsersName allUsers, ExternalIdsConsistencyChecker externalIdsConsistencyChecker) { this.gerritIdent = gerritIdent; this.canonicalWebUrl = canonicalWebUrl; this.pluginValidators = pluginValidators; this.allUsers = allUsers; this.externalIdsConsistencyChecker = externalIdsConsistencyChecker; this.installCommitMsgHookCommand = cfg != null ? cfg.getString("gerrit", null, "installCommitMsgHookCommand") : null; } public CommitValidators forReceiveCommits( RefControl refControl, SshInfo sshInfo, Repository repo, RevWalk rw) throws IOException { NoteMap rejectCommits = BanCommit.loadRejectCommitsMap(repo, rw); return new CommitValidators( ImmutableList.of( new UploadMergesPermissionValidator(refControl), new AmendedGerritMergeCommitValidationListener(refControl, gerritIdent), new AuthorUploaderValidator(refControl, canonicalWebUrl), new CommitterUploaderValidator(refControl, canonicalWebUrl), new SignedOffByValidator(refControl), new ChangeIdValidator( refControl, canonicalWebUrl, installCommitMsgHookCommand, sshInfo), new ConfigValidator(refControl, rw, allUsers), new BannedCommitsValidator(rejectCommits), new PluginCommitValidationListener(pluginValidators), new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker))); } public CommitValidators forGerritCommits(RefControl refControl, SshInfo sshInfo, RevWalk rw) { return new CommitValidators( ImmutableList.of( new UploadMergesPermissionValidator(refControl), new AmendedGerritMergeCommitValidationListener(refControl, gerritIdent), new AuthorUploaderValidator(refControl, canonicalWebUrl), new SignedOffByValidator(refControl), new ChangeIdValidator( refControl, canonicalWebUrl, installCommitMsgHookCommand, sshInfo), new ConfigValidator(refControl, rw, allUsers), new PluginCommitValidationListener(pluginValidators), new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker))); } public CommitValidators forMergedCommits(RefControl refControl) { // 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. return new CommitValidators( ImmutableList.of( new UploadMergesPermissionValidator(refControl), new AuthorUploaderValidator(refControl, canonicalWebUrl), new CommitterUploaderValidator(refControl, canonicalWebUrl))); } } 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) { messages.addAll(commitValidator.onCommitReceived(receiveEvent)); } } catch (CommitValidationException e) { // 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 int SHA1_LENGTH = 7; private static final String CHANGE_ID_PREFIX = FooterConstants.CHANGE_ID.getName() + ":"; private static final String MISSING_CHANGE_ID_MSG = "[%s] missing " + FooterConstants.CHANGE_ID.getName() + " in commit message footer"; private static final String MISSING_SUBJECT_MSG = "[%s] missing subject; " + FooterConstants.CHANGE_ID.getName() + " must be in commit message footer"; private static final String MULTIPLE_CHANGE_ID_MSG = "[%s] multiple " + FooterConstants.CHANGE_ID.getName() + " lines in commit message footer"; private static final String INVALID_CHANGE_ID_MSG = "[%s] invalid " + FooterConstants.CHANGE_ID.getName() + " line format in commit message footer"; private static final Pattern CHANGE_ID = Pattern.compile(CHANGE_ID_PATTERN); private final ProjectControl projectControl; private final String canonicalWebUrl; private final String installCommitMsgHookCommand; private final SshInfo sshInfo; private final IdentifiedUser user; public ChangeIdValidator( RefControl refControl, String canonicalWebUrl, String installCommitMsgHookCommand, SshInfo sshInfo) { this.projectControl = refControl.getProjectControl(); this.canonicalWebUrl = canonicalWebUrl; this.installCommitMsgHookCommand = installCommitMsgHookCommand; this.sshInfo = sshInfo; this.user = projectControl.getUser().asIdentifiedUser(); } @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 = commit.getFooterLines(FooterConstants.CHANGE_ID); String sha1 = commit.abbreviate(SHA1_LENGTH).name(); if (idList.isEmpty()) { if (projectControl.getProjectState().isRequireChangeID()) { String shortMsg = commit.getShortMessage(); if (shortMsg.startsWith(CHANGE_ID_PREFIX) && CHANGE_ID .matcher(shortMsg.substring(CHANGE_ID_PREFIX.length()).trim()) .matches()) { String errMsg = String.format(MISSING_SUBJECT_MSG, sha1); throw new CommitValidationException(errMsg); } String errMsg = String.format(MISSING_CHANGE_ID_MSG, sha1); messages.add(getMissingChangeIdErrorMsg(errMsg, commit)); throw new CommitValidationException(errMsg, messages); } } else if (idList.size() > 1) { String errMsg = String.format(MULTIPLE_CHANGE_ID_MSG, sha1); throw new CommitValidationException(errMsg, messages); } else { String v = idList.get(idList.size() - 1).trim(); // Reject Change-Ids with wrong format and invalid placeholder ID from // Egit (I0000000000000000000000000000000000000000). if (!CHANGE_ID.matcher(v).matches() || v.matches("^I00*$")) { String errMsg = String.format(INVALID_CHANGE_ID_MSG, sha1); messages.add(getMissingChangeIdErrorMsg(errMsg, receiveEvent.commit)); throw new CommitValidationException(errMsg, messages); } } return Collections.emptyList(); } private static boolean shouldValidateChangeId(CommitReceivedEvent event) { return MagicBranch.isMagicBranch(event.command.getRefName()) || NEW_PATCHSET.matcher(event.command.getRefName()).matches(); } private CommitValidationMessage getMissingChangeIdErrorMsg( final String errMsg, final RevCommit c) { StringBuilder sb = new StringBuilder(); sb.append("ERROR: ").append(errMsg); if (c.getFullMessage().indexOf(CHANGE_ID_PREFIX) >= 0) { String[] lines = c.getFullMessage().trim().split("\n"); String lastLine = lines.length > 0 ? lines[lines.length - 1] : ""; if (lastLine.indexOf(CHANGE_ID_PREFIX) == -1) { sb.append('\n'); sb.append('\n'); sb.append("Hint: A potential "); sb.append(FooterConstants.CHANGE_ID.getName()); sb.append("Change-Id was found, but it was not in the "); sb.append("footer (last paragraph) of the commit message."); } } sb.append('\n'); sb.append('\n'); sb.append("Hint: To automatically insert "); sb.append(FooterConstants.CHANGE_ID.getName()); sb.append(", install the hook:\n"); sb.append(getCommitMessageHookInstallationHint()); sb.append('\n'); sb.append("And then amend the commit:\n"); sb.append(" git commit --amend\n"); return new CommitValidationMessage(sb.toString(), false); } 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) if (hostKeys.isEmpty()) { String p = "${gitdir}/hooks/commit-msg"; return String.format( " gitdir=$(git rev-parse --git-dir); curl -o %s %s/tools/hooks/commit-msg ; chmod +x %s", p, getGerritUrl(canonicalWebUrl), p); } // SSH keys exist, so the hook can be installed with scp. String sshHost; int sshPort; String host = hostKeys.get(0).getHost(); int c = host.lastIndexOf(':'); if (0 <= c) { if (host.startsWith("*:")) { sshHost = getGerritHost(canonicalWebUrl); } else { sshHost = host.substring(0, c); } sshPort = Integer.parseInt(host.substring(c + 1)); } else { sshHost = host; sshPort = 22; } return String.format( " gitdir=$(git rev-parse --git-dir); scp -p -P %d %s@%s:hooks/commit-msg ${gitdir}/hooks/", sshPort, user.getUserName(), sshHost); } } /** If this is the special project configuration branch, validate the config. */ public static class ConfigValidator implements CommitValidationListener { private final RefControl refControl; private final RevWalk rw; private final AllUsersName allUsers; public ConfigValidator(RefControl refControl, RevWalk rw, AllUsersName allUsers) { this.refControl = refControl; this.rw = rw; this.allUsers = allUsers; } @Override public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) throws CommitValidationException { IdentifiedUser currentUser = refControl.getUser().asIdentifiedUser(); if (REFS_CONFIG.equals(refControl.getRefName())) { List<CommitValidationMessage> messages = new ArrayList<>(); try { ProjectConfig cfg = new ProjectConfig(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 ConfigInvalidException("invalid project configuration"); } } catch (ConfigInvalidException | IOException e) { log.error( "User " + currentUser.getUserName() + " tried to push an invalid project configuration " + receiveEvent.command.getNewId().name() + " for project " + receiveEvent.project.getName(), e); throw new CommitValidationException("invalid project configuration", messages); } } if (allUsers.equals(refControl.getProjectControl().getProject().getNameKey()) && RefNames.isRefsUsers(refControl.getRefName())) { List<CommitValidationMessage> messages = new ArrayList<>(); Account.Id accountId = Account.Id.fromRef(refControl.getRefName()); if (accountId != null) { try { WatchConfig wc = new WatchConfig(accountId); wc.load(rw, receiveEvent.command.getNewId()); if (!wc.getValidationErrors().isEmpty()) { addError("Invalid project configuration:", messages); for (ValidationError err : wc.getValidationErrors()) { addError(" " + err.getMessage(), messages); } throw new ConfigInvalidException("invalid watch configuration"); } } catch (IOException | ConfigInvalidException e) { log.error( "User " + currentUser.getUserName() + " tried to push an invalid watch configuration " + receiveEvent.command.getNewId().name() + " for account " + accountId.get(), e); throw new CommitValidationException("invalid watch configuration", messages); } } } return Collections.emptyList(); } } /** Require permission to upload merges. */ public static class UploadMergesPermissionValidator implements CommitValidationListener { private final RefControl refControl; public UploadMergesPermissionValidator(RefControl refControl) { this.refControl = refControl; } @Override public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) throws CommitValidationException { if (receiveEvent.commit.getParentCount() > 1 && !refControl.canUploadMerges()) { throw new CommitValidationException("you are not allowed to upload merges"); } return Collections.emptyList(); } } /** Execute commit validation plug-ins */ public static class PluginCommitValidationListener implements CommitValidationListener { private final DynamicSet<CommitValidationListener> commitValidationListeners; public PluginCommitValidationListener( final DynamicSet<CommitValidationListener> commitValidationListeners) { this.commitValidationListeners = commitValidationListeners; } @Override public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) throws CommitValidationException { List<CommitValidationMessage> messages = new ArrayList<>(); for (CommitValidationListener validator : commitValidationListeners) { try { messages.addAll(validator.onCommitReceived(receiveEvent)); } catch (CommitValidationException e) { messages.addAll(e.getMessages()); throw new CommitValidationException(e.getMessage(), messages); } } return messages; } } public static class SignedOffByValidator implements CommitValidationListener { private final RefControl refControl; public SignedOffByValidator(RefControl refControl) { this.refControl = refControl; } @Override public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) throws CommitValidationException { IdentifiedUser currentUser = refControl.getUser().asIdentifiedUser(); final PersonIdent committer = receiveEvent.commit.getCommitterIdent(); final PersonIdent author = receiveEvent.commit.getAuthorIdent(); final ProjectControl projectControl = refControl.getProjectControl(); if (projectControl.getProjectState().isUseSignedOffBy()) { boolean sboAuthor = false; boolean sboCommitter = false; boolean sboMe = false; for (final FooterLine footer : receiveEvent.commit.getFooterLines()) { if (footer.matches(FooterKey.SIGNED_OFF_BY)) { final String e = footer.getEmailAddress(); if (e != null) { sboAuthor |= author.getEmailAddress().equals(e); sboCommitter |= committer.getEmailAddress().equals(e); sboMe |= currentUser.hasEmailAddress(e); } } } if (!sboAuthor && !sboCommitter && !sboMe && !refControl.canForgeCommitter()) { throw new CommitValidationException( "not Signed-off-by author/committer/uploader in commit message footer"); } } return Collections.emptyList(); } } /** Require that author matches the uploader. */ public static class AuthorUploaderValidator implements CommitValidationListener { private final RefControl refControl; private final String canonicalWebUrl; public AuthorUploaderValidator(RefControl refControl, String canonicalWebUrl) { this.refControl = refControl; this.canonicalWebUrl = canonicalWebUrl; } @Override public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) throws CommitValidationException { IdentifiedUser currentUser = refControl.getUser().asIdentifiedUser(); final PersonIdent author = receiveEvent.commit.getAuthorIdent(); if (!currentUser.hasEmailAddress(author.getEmailAddress()) && !refControl.canForgeAuthor()) { List<CommitValidationMessage> messages = new ArrayList<>(); messages.add( getInvalidEmailError( receiveEvent.commit, "author", author, currentUser, canonicalWebUrl)); throw new CommitValidationException("invalid author", messages); } return Collections.emptyList(); } } /** Require that committer matches the uploader. */ public static class CommitterUploaderValidator implements CommitValidationListener { private final RefControl refControl; private final String canonicalWebUrl; public CommitterUploaderValidator(RefControl refControl, String canonicalWebUrl) { this.refControl = refControl; this.canonicalWebUrl = canonicalWebUrl; } @Override public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) throws CommitValidationException { IdentifiedUser currentUser = refControl.getUser().asIdentifiedUser(); final PersonIdent committer = receiveEvent.commit.getCommitterIdent(); if (!currentUser.hasEmailAddress(committer.getEmailAddress()) && !refControl.canForgeCommitter()) { List<CommitValidationMessage> messages = new ArrayList<>(); messages.add( getInvalidEmailError( receiveEvent.commit, "committer", committer, currentUser, canonicalWebUrl)); throw new CommitValidationException("invalid committer", messages); } return Collections.emptyList(); } } /** * 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 PersonIdent gerritIdent; private final RefControl refControl; public AmendedGerritMergeCommitValidationListener( final RefControl refControl, final PersonIdent gerritIdent) { this.refControl = refControl; this.gerritIdent = gerritIdent; } @Override public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) throws CommitValidationException { final PersonIdent author = receiveEvent.commit.getAuthorIdent(); if (receiveEvent.commit.getParentCount() > 1 && author.getName().equals(gerritIdent.getName()) && author.getEmailAddress().equals(gerritIdent.getEmailAddress()) && !refControl.canForgeGerritServerIdentity()) { throw new CommitValidationException("do not amend merges not made by you"); } 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) { String m = "error checking banned commits"; log.warn(m, e); throw new CommitValidationException(m, e); } } } /** Validates updates to refs/meta/external-ids. */ public static class ExternalIdUpdateListener implements CommitValidationListener { private final AllUsersName allUsers; private final ExternalIdsConsistencyChecker externalIdsConsistencyChecker; public ExternalIdUpdateListener( AllUsersName allUsers, ExternalIdsConsistencyChecker externalIdsConsistencyChecker) { this.externalIdsConsistencyChecker = externalIdsConsistencyChecker; this.allUsers = allUsers; } @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(receiveEvent.commit); List<CommitValidationMessage> msgs = problems .stream() .map( p -> new CommitValidationMessage( p.message, p.status == ConsistencyProblemInfo.Status.ERROR)) .collect(toList()); if (msgs.stream().anyMatch(m -> m.isError())) { throw new CommitValidationException("invalid external IDs", msgs); } return msgs; } catch (IOException e) { String m = "error validating external IDs"; log.warn(m, e); throw new CommitValidationException(m, e); } } return Collections.emptyList(); } } private static CommitValidationMessage getInvalidEmailError( RevCommit c, String type, PersonIdent who, IdentifiedUser currentUser, String canonicalWebUrl) { StringBuilder sb = new StringBuilder(); sb.append("\n"); sb.append("ERROR: In commit ").append(c.name()).append("\n"); sb.append("ERROR: ") .append(type) .append(" email address ") .append(who.getEmailAddress()) .append("\n"); sb.append("ERROR: does not match your user account.\n"); sb.append("ERROR:\n"); if (currentUser.getEmailAddresses().isEmpty()) { sb.append("ERROR: You have not registered any email addresses.\n"); } else { sb.append("ERROR: The following addresses are currently registered:\n"); for (String address : currentUser.getEmailAddresses()) { sb.append("ERROR: ").append(address).append("\n"); } } sb.append("ERROR:\n"); if (canonicalWebUrl != null) { sb.append("ERROR: To register an email address, please visit:\n"); sb.append("ERROR: ") .append(canonicalWebUrl) .append("#") .append(PageLinks.SETTINGS_CONTACT) .append("\n"); } sb.append("\n"); return new CommitValidationMessage(sb.toString(), false); } /** * Get the Gerrit URL. * * @return the canonical URL (with any trailing slash removed) if it is configured, otherwise fall * back to "http://hostname" where hostname is the value returned by {@link * #getGerritHost(String)}. */ private static String getGerritUrl(String canonicalWebUrl) { if (canonicalWebUrl != null) { return CharMatcher.is('/').trimTrailingFrom(canonicalWebUrl); } return "http://" + getGerritHost(canonicalWebUrl); } /** * 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) { String host; if (canonicalWebUrl != null) { try { host = new URL(canonicalWebUrl).getHost(); } catch (MalformedURLException e) { host = SystemReader.getInstance().getHostname(); } } else { host = SystemReader.getInstance().getHostname(); } return host; } private static void addError(String error, List<CommitValidationMessage> messages) { messages.add(new CommitValidationMessage(error, true)); } }