| // Copyright (C) 2017 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.googlesource.gerrit.plugins.findowners; |
| |
| import static com.googlesource.gerrit.plugins.findowners.Config.OWNERS; |
| import static com.googlesource.gerrit.plugins.findowners.Config.OWNERS_FILE_NAME; |
| import static com.googlesource.gerrit.plugins.findowners.Config.REJECT_ERROR_IN_OWNERS; |
| |
| import com.google.common.annotations.VisibleForTesting; |
| import com.google.common.collect.Multimap; |
| import com.google.gerrit.extensions.annotations.Exports; |
| import com.google.gerrit.extensions.annotations.PluginName; |
| import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType; |
| import com.google.gerrit.extensions.registration.DynamicSet; |
| import com.google.gerrit.reviewdb.client.Account; |
| import com.google.gerrit.reviewdb.client.Project; |
| import com.google.gerrit.server.account.Emails; |
| import com.google.gerrit.server.config.PluginConfig; |
| import com.google.gerrit.server.config.PluginConfigFactory; |
| import com.google.gerrit.server.config.ProjectConfigEntry; |
| import com.google.gerrit.server.events.CommitReceivedEvent; |
| import com.google.gerrit.server.git.GitRepositoryManager; |
| import com.google.gerrit.server.git.validators.CommitValidationException; |
| import com.google.gerrit.server.git.validators.CommitValidationListener; |
| import com.google.gerrit.server.git.validators.CommitValidationMessage; |
| import com.google.gerrit.server.project.NoSuchProjectException; |
| import com.google.inject.AbstractModule; |
| import com.google.inject.Inject; |
| import java.io.BufferedReader; |
| import java.io.IOException; |
| import java.io.InputStream; |
| import java.io.InputStreamReader; |
| import java.nio.charset.StandardCharsets; |
| import java.util.ArrayList; |
| import java.util.Collection; |
| import java.util.HashMap; |
| import java.util.HashSet; |
| import java.util.LinkedList; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Set; |
| import java.util.concurrent.ExecutionException; |
| import java.util.regex.Matcher; |
| import java.util.regex.Pattern; |
| import org.eclipse.jgit.diff.RawText; |
| import org.eclipse.jgit.lib.FileMode; |
| import org.eclipse.jgit.lib.ObjectId; |
| import org.eclipse.jgit.lib.ObjectLoader; |
| import org.eclipse.jgit.lib.Repository; |
| import org.eclipse.jgit.revwalk.RevCommit; |
| import org.eclipse.jgit.revwalk.RevWalk; |
| import org.eclipse.jgit.treewalk.TreeWalk; |
| import org.eclipse.jgit.treewalk.filter.TreeFilter; |
| |
| /** Check syntax of changed OWNERS files. */ |
| public class OwnersValidator implements CommitValidationListener { |
| private interface TreeWalkVisitor { |
| void onVisit(TreeWalk tw); |
| } |
| |
| public static AbstractModule module() { |
| return new AbstractModule() { |
| @Override |
| protected void configure() { |
| DynamicSet.bind(binder(), CommitValidationListener.class).to(OwnersValidator.class); |
| bind(ProjectConfigEntry.class) |
| .annotatedWith(Exports.named(REJECT_ERROR_IN_OWNERS)) |
| .toInstance( |
| new ProjectConfigEntry( |
| "Reject OWNERS Files With Errors", |
| null, |
| ProjectConfigEntryType.BOOLEAN, |
| null, |
| false, |
| "Pushes of commits with errors in OWNERS files will be rejected.")); |
| } |
| }; |
| } |
| |
| private final String pluginName; |
| private final PluginConfigFactory cfgFactory; |
| private final GitRepositoryManager repoManager; |
| private final Emails emails; |
| |
| @Inject |
| OwnersValidator( |
| @PluginName String pluginName, |
| PluginConfigFactory cfgFactory, |
| GitRepositoryManager repoManager, |
| Emails emails) { |
| this.pluginName = pluginName; |
| this.cfgFactory = cfgFactory; |
| this.repoManager = repoManager; |
| this.emails = emails; |
| } |
| |
| public static String getOwnersFileName(PluginConfig cfg) { |
| return getOwnersFileName(cfg, OWNERS); |
| } |
| |
| public static String getOwnersFileName(PluginConfig cfg, String defaultName) { |
| return cfg.getString(OWNERS_FILE_NAME, defaultName); |
| } |
| |
| public String getOwnersFileName(Project.NameKey project) { |
| String name = getOwnersFileName(cfgFactory.getFromGerritConfig(pluginName, true)); |
| try { |
| return getOwnersFileName( |
| cfgFactory.getFromProjectConfigWithInheritance(project, pluginName), name); |
| } catch (NoSuchProjectException e) { |
| return name; |
| } |
| } |
| |
| @VisibleForTesting |
| static boolean isActive(PluginConfig cfg) { |
| return cfg.getBoolean(REJECT_ERROR_IN_OWNERS, false); |
| } |
| |
| @Override |
| public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent) |
| throws CommitValidationException { |
| List<CommitValidationMessage> messages = new LinkedList<>(); |
| try { |
| Project.NameKey project = receiveEvent.project.getNameKey(); |
| PluginConfig cfg = cfgFactory.getFromProjectConfigWithInheritance(project, pluginName); |
| if (isActive(cfg)) { |
| try (Repository repo = repoManager.openRepository(project)) { |
| String name = getOwnersFileName(project); |
| messages = performValidation(receiveEvent.commit, receiveEvent.revWalk, name, false); |
| } |
| } |
| } catch (NoSuchProjectException | IOException | ExecutionException e) { |
| throw new CommitValidationException("failed to check owners files", e); |
| } |
| if (hasError(messages)) { |
| throw new CommitValidationException("found invalid owners file", messages); |
| } |
| return messages; |
| } |
| |
| @VisibleForTesting |
| List<CommitValidationMessage> performValidation( |
| RevCommit c, RevWalk revWalk, String ownersFileName, boolean verbose) |
| throws IOException, ExecutionException { |
| // Collect all messages from all files. |
| List<CommitValidationMessage> messages = new LinkedList<>(); |
| // Collect all email addresses from all files and check each address only once. |
| Map<String, Set<String>> email2lines = new HashMap<>(); |
| Map<String, ObjectId> content = getChangedOwners(c, revWalk, ownersFileName); |
| for (String path : content.keySet()) { |
| ObjectLoader ol = revWalk.getObjectReader().open(content.get(path)); |
| try (InputStream in = ol.openStream()) { |
| if (RawText.isBinary(in)) { |
| add(messages, path + " is a binary file", true); // OWNERS files cannot be binary |
| continue; |
| } |
| } |
| checkFile(messages, email2lines, path, ol, verbose); |
| } |
| checkEmails(messages, emails, email2lines, verbose); |
| return messages; |
| } |
| |
| private static void checkEmails( |
| List<CommitValidationMessage> messages, |
| Emails emails, |
| Map<String, Set<String>> email2lines, |
| boolean verbose) { |
| List<String> owners = new ArrayList<>(email2lines.keySet()); |
| if (verbose) { |
| for (String owner : owners) { |
| add(messages, "owner: " + owner, false); |
| } |
| } |
| if (emails == null || owners.isEmpty()) { |
| return; |
| } |
| String[] ownerEmailsAsArray = new String[owners.size()]; |
| owners.toArray(ownerEmailsAsArray); |
| try { |
| Multimap<String, Account.Id> email2ids = emails.getAccountsFor(ownerEmailsAsArray); |
| for (String owner : ownerEmailsAsArray) { |
| boolean wrongEmail = (email2ids == null); |
| if (!wrongEmail) { |
| try { |
| Collection<Account.Id> ids = email2ids.get(owner); |
| wrongEmail = (ids == null || ids.size() != 1); |
| } catch (Exception e) { |
| wrongEmail = true; |
| } |
| } |
| if (wrongEmail) { |
| String locations = String.join(" ", email2lines.get(owner)); |
| add(messages, "unknown: " + owner + " at " + locations, true); |
| } |
| } |
| } catch (Exception e) { |
| add(messages, "checkEmails failed.", true); |
| } |
| } |
| |
| private static void checkFile( |
| List<CommitValidationMessage> messages, |
| Map<String, Set<String>> email2lines, |
| String path, |
| ObjectLoader ol, |
| boolean verbose) |
| throws IOException { |
| if (verbose) { |
| add(messages, "validate: " + path, false); |
| } |
| try (BufferedReader br = |
| new BufferedReader(new InputStreamReader(ol.openStream(), StandardCharsets.UTF_8))) { |
| int line = 0; |
| for (String l = br.readLine(); l != null; l = br.readLine()) { |
| line++; |
| checkLine(messages, email2lines, path, line, l); |
| } |
| } |
| } |
| |
| // Line patterns accepted by Parser.java in the find-owners plugin. |
| static final Pattern patComment = Pattern.compile("^ *(#.*)?$"); |
| static final Pattern patEmail = // email address or a "*" |
| Pattern.compile("^ *([^ <>@]+@[^ <>@#]+|\\*) *(#.*)?$"); |
| static final Pattern patFile = Pattern.compile("^ *file:.*$"); |
| static final Pattern patNoParent = Pattern.compile("^ *set +noparent *(#.*)?$"); |
| static final Pattern patPerFileNoParent = |
| Pattern.compile("^ *per-file +([^= ]+) *= *set +noparent *(#.*)?$"); |
| static final Pattern patPerFileEmail = |
| Pattern.compile("^ *per-file +([^= ]+) *= *([^ <>@]+@[^ <>@#]+|\\*) *(#.*)?$"); |
| |
| private static void collectEmail( |
| Map<String, Set<String>> map, String email, String file, int lineNumber) { |
| if (!email.equals("*")) { |
| map.computeIfAbsent(email, (String k) -> new HashSet<>()); |
| map.get(email).add(file + ":" + lineNumber); |
| } |
| } |
| |
| private static boolean hasError(List<CommitValidationMessage> messages) { |
| for (CommitValidationMessage m : messages) { |
| if (m.isError()) { |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| private static void add(List<CommitValidationMessage> messages, String msg, boolean error) { |
| messages.add(new CommitValidationMessage(msg, error)); |
| } |
| |
| private static void checkLine( |
| List<CommitValidationMessage> messages, |
| Map<String, Set<String>> email2lines, |
| String path, |
| int lineNumber, |
| String line) { |
| Matcher m; |
| if (patComment.matcher(line).find() |
| || patNoParent.matcher(line).find() |
| || patPerFileNoParent.matcher(line).find()) { |
| return; |
| } else if ((m = patEmail.matcher(line)).find()) { |
| collectEmail(email2lines, m.group(1), path, lineNumber); |
| } else if ((m = patPerFileEmail.matcher(line)).find()) { |
| collectEmail(email2lines, m.group(2).trim(), path, lineNumber); |
| } else { |
| String prefix = patFile.matcher(line).find() ? "ignored" : "syntax"; |
| add(messages, prefix + ": " + path + ":" + lineNumber + ": " + line, true); |
| } |
| } |
| |
| /** |
| * Find all changed OWNERS files which differ between the commit and its parents. Return a map |
| * from "Path to the changed file" to "ObjectId of the file". |
| */ |
| private static Map<String, ObjectId> getChangedOwners( |
| RevCommit c, RevWalk revWalk, String ownersFileName) throws IOException { |
| final Map<String, ObjectId> content = new HashMap<>(); |
| visitChangedEntries( |
| c, |
| revWalk, |
| new TreeWalkVisitor() { |
| @Override |
| public void onVisit(TreeWalk tw) { |
| if (isFile(tw) && ownersFileName.equals(tw.getNameString())) { |
| content.put(tw.getPathString(), tw.getObjectId(0)); |
| } |
| } |
| }); |
| return content; |
| } |
| |
| private static boolean isFile(TreeWalk tw) { |
| return FileMode.EXECUTABLE_FILE.equals(tw.getRawMode(0)) |
| || FileMode.REGULAR_FILE.equals(tw.getRawMode(0)); |
| } |
| |
| /** |
| * Find all TreeWalk entries which differ between the commit and its parents. If a TreeWalk entry |
| * is found this method calls the onVisit() method of the class TreeWalkVisitor. |
| */ |
| private static void visitChangedEntries(RevCommit c, RevWalk revWalk, TreeWalkVisitor visitor) |
| throws IOException { |
| try (TreeWalk tw = new TreeWalk(revWalk.getObjectReader())) { |
| tw.setRecursive(true); |
| tw.setFilter(TreeFilter.ANY_DIFF); |
| tw.addTree(c.getTree()); |
| if (c.getParentCount() > 0) { |
| for (RevCommit p : c.getParents()) { |
| if (p.getTree() == null) { |
| revWalk.parseHeaders(p); |
| } |
| tw.addTree(p.getTree()); |
| } |
| while (tw.next()) { |
| if (isDifferentToAllParents(c, tw)) { |
| visitor.onVisit(tw); |
| } |
| } |
| } else { |
| while (tw.next()) { |
| visitor.onVisit(tw); |
| } |
| } |
| } |
| } |
| |
| private static boolean isDifferentToAllParents(RevCommit c, TreeWalk tw) { |
| if (c.getParentCount() > 1) { |
| for (int p = 1; p <= c.getParentCount(); p++) { |
| if (tw.getObjectId(0).equals(tw.getObjectId(p))) { |
| return false; |
| } |
| } |
| } |
| return true; |
| } |
| } |