Add OwnersValidator to check changed OWNERS.

* Checks OWNERS file syntax and email addresses used in OWNERS files.
  Emails should belong to accounts found on the Gerrit server.
* This check is disabled by default and enabled in project.config with
  "rejectErrorInOwners = true".

Change-Id: I2fe59bd622dd118d4c59bbf6a1fb574b3a58a896
diff --git a/BUILD b/BUILD
index eb304bb..72c2671 100644
--- a/BUILD
+++ b/BUILD
@@ -40,6 +40,7 @@
   # resources = glob(['src/test/resources/**/*']),
   tags = ['findowners'],
   deps = PLUGIN_DEPS + PLUGIN_TEST_DEPS + [
+    '@commons_io//jar',
     ':find-owners-lib',
     ':find-owners-prolog-rules',
   ],
diff --git a/src/main/java/com/googlesource/gerrit/plugins/findowners/Config.java b/src/main/java/com/googlesource/gerrit/plugins/findowners/Config.java
index d7008b3..1b5bcd9 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/findowners/Config.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/findowners/Config.java
@@ -32,16 +32,15 @@
   static final String MAX_CACHE_AGE = "maxCacheAge"; // seconds to stay in cache
   static final String MAX_CACHE_SIZE = "maxCacheSize"; // number of OwnersDb in cache
   static final String MIN_OWNER_VOTE_LEVEL = "minOwnerVoteLevel"; // default +1
-  static final String OWNERS_FILE_NAME = "ownersFileName"; // default "OWNERS"
+  static final String OWNERS = "OWNERS"; // Default file name
+  static final String OWNERS_FILE_NAME = "ownersFileName"; // config key for file name
+  static final String REJECT_ERROR_IN_OWNERS = "rejectErrorInOwners"; // config key for validator
   static final String REPORT_SYNTAX_ERROR = "reportSyntaxError";
 
   // Name of plugin and namespace.
   static final String PLUGIN_NAME = "find-owners";
   static final String PROLOG_NAMESPACE = "find_owners";
 
-  // Default values.
-  private static final String DEFAULT_OWNERS_FILE_NAME = "OWNERS";
-
   // Global/plugin config parameters.
   private static PluginConfigFactory config = null;
   private static boolean addDebugMsg = false;
@@ -94,18 +93,18 @@
         String name =
             config
                 .getFromProjectConfigWithInheritance(project, PLUGIN_NAME)
-                .getString(OWNERS_FILE_NAME, DEFAULT_OWNERS_FILE_NAME);
+                .getString(OWNERS_FILE_NAME, OWNERS);
         if (name.trim().equals("")) {
           log.error(
               "Project " + project.get() + " has wrong " + OWNERS_FILE_NAME + ": \"" + name + "\"");
-          return DEFAULT_OWNERS_FILE_NAME;
+          return OWNERS;
         }
         return name;
       } catch (NoSuchProjectException e) {
         log.error("Cannot find project: " + project);
       }
     }
-    return DEFAULT_OWNERS_FILE_NAME;
+    return OWNERS;
   }
 
   @VisibleForTesting
diff --git a/src/main/java/com/googlesource/gerrit/plugins/findowners/Module.java b/src/main/java/com/googlesource/gerrit/plugins/findowners/Module.java
index 9fc2709..e57bd51 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/findowners/Module.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/findowners/Module.java
@@ -47,6 +47,7 @@
 
   @Override
   protected void configure() {
+    install(OwnersValidator.module());
     install(
         new RestApiModule() {
           @Override
diff --git a/src/main/java/com/googlesource/gerrit/plugins/findowners/OwnersValidator.java b/src/main/java/com/googlesource/gerrit/plugins/findowners/OwnersValidator.java
new file mode 100644
index 0000000..ce3b36e
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/findowners/OwnersValidator.java
@@ -0,0 +1,359 @@
+// 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(repo, 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(
+      Repository repo, 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(repo, 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, verbose);
+      }
+    }
+  }
+
+  // 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("*")) {
+      if (map.get(email) == null) {
+        map.put(email, 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,
+      boolean verbose) {
+    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(
+      Repository repo, RevCommit c, RevWalk revWalk, String ownersFileName) throws IOException {
+    final Map<String, ObjectId> content = new HashMap<>();
+    visitChangedEntries(
+        repo,
+        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(
+      Repository repo, 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;
+  }
+}
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index 9e81e69..fa046cc 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -70,6 +70,16 @@
 If a project has already used OWNERS files for other purpose,
 the "ownersFileName" parameter can be used to change the default.
 
+## Validate OWNERS files before upload
+
+To check syntax of OWNERS files before they are uploaded,
+set the following variable in project.config files.
+
+```bash
+[plugin "find-owners"]
+    rejectErrorInOwners = true
+```
+
 ## Example 0, call `submit_filter/2`
 
 The simplest configuration adds to `rules.pl` of the root
diff --git a/src/test/java/com/googlesource/gerrit/plugins/findowners/OwnersValidatorTest.java b/src/test/java/com/googlesource/gerrit/plugins/findowners/OwnersValidatorTest.java
new file mode 100644
index 0000000..dfbc0fa
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/findowners/OwnersValidatorTest.java
@@ -0,0 +1,314 @@
+// 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.google.common.truth.Truth.assertThat;
+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.base.Function;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.ImmutableSetMultimap;
+import com.google.common.collect.Lists;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.server.account.Emails;
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.HashMap;
+import java.util.LinkedList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.apache.commons.io.FileUtils;
+import org.eclipse.jgit.api.AddCommand;
+import org.eclipse.jgit.api.Git;
+import org.eclipse.jgit.api.errors.GitAPIException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevCommit;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.storage.file.FileRepositoryBuilder;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+/** Test OwnersValidator, which checks syntax of changed OWNERS files. */
+public class OwnersValidatorTest {
+
+  private class MockedEmails extends Emails {
+    Set<String> registered;
+
+    MockedEmails() {
+      super(null, null);
+      registered =
+          ImmutableSet.of(
+              "u1@g.com", "u2@g.com", "u2.m@g.com", "user1@google.com", "u1+review@g.com");
+    }
+
+    public ImmutableSetMultimap<String, Account.Id> getAccountsFor(String... emails) {
+      // Used by checkEmails; each email should have exactly one Account.Id
+      ImmutableSetMultimap.Builder<String, Account.Id> builder = ImmutableSetMultimap.builder();
+      int id = 1000000;
+      for (String s : registered) {
+        builder.put(s, new Account.Id(++id));
+      }
+      return builder.build();
+    }
+  }
+
+  private File repoFolder;
+  private Repository repo;
+
+  @Before
+  public void init() throws IOException {
+    repoFolder = File.createTempFile("Git", "");
+    repoFolder.delete();
+    repo = FileRepositoryBuilder.create(new File(repoFolder, ".git"));
+    repo.create();
+  }
+
+  @After
+  public void cleanup() throws IOException {
+    repo.close();
+    if (repoFolder.exists()) {
+      FileUtils.deleteDirectory(repoFolder);
+    }
+  }
+
+  private static final String OWNERS_ANDROID = "OWNERS.android"; // alternative OWNERS file name
+  private static final PluginConfig ANDROID_CONFIG = createAndroidConfig(); // use OWNERS_ANDROID
+  private static final PluginConfig EMPTY_CONFIG = new PluginConfig("", new Config());
+  private static final PluginConfig ENABLED_CONFIG = createEnabledConfig(); // use OWNERS
+  private static final PluginConfig DISABLED_CONFIG = createDisabledConfig();
+
+  @Test
+  public void chekIsActiveAndFileName() throws Exception {
+    // This check should be enabled in project.config, default is not active.
+    assertThat(OwnersValidator.isActive(EMPTY_CONFIG)).isFalse();
+    assertThat(OwnersValidator.isActive(ENABLED_CONFIG)).isTrue();
+    assertThat(OwnersValidator.isActive(ANDROID_CONFIG)).isTrue();
+    assertThat(OwnersValidator.isActive(DISABLED_CONFIG)).isFalse();
+    // Default file name is "OWNERS".
+    assertThat(OwnersValidator.getOwnersFileName(EMPTY_CONFIG)).isEqualTo(OWNERS);
+    assertThat(OwnersValidator.getOwnersFileName(ENABLED_CONFIG)).isEqualTo(OWNERS);
+    assertThat(OwnersValidator.getOwnersFileName(DISABLED_CONFIG)).isEqualTo(OWNERS);
+    assertThat(OwnersValidator.getOwnersFileName(ANDROID_CONFIG)).isEqualTo(OWNERS_ANDROID);
+  }
+
+  private static final Map<String, String> FILES_WITHOUT_OWNERS =
+      ImmutableMap.of("README", "any\n", "d1/test.c", "int x;\n");
+
+  @Test
+  public void testNoOwners() throws Exception {
+    try (RevWalk rw = new RevWalk(repo)) {
+      RevCommit c = makeCommit(rw, "Commit no OWNERS.", FILES_WITHOUT_OWNERS);
+      assertThat(validate(rw, c, false, ENABLED_CONFIG)).isEmpty();
+      assertThat(validate(rw, c, true, ENABLED_CONFIG)).isEmpty();
+    }
+  }
+
+  private static final Map<String, String> FILES_WITH_NO_ERROR =
+      ImmutableMap.of(
+          OWNERS,
+          "\n\n#comments ...\n  ###  more comments\n"
+              + "   user1@google.com # comment\n"
+              + "u1+review@g.com###\n"
+              + " * # everyone can approve\n"
+              + "per-file *.py =  set  noparent###\n"
+              + "per-file   *.py=u2.m@g.com\n"
+              + "per-file *.txt = * # everyone can approve\n"
+              + "set  noparent  # comment\n");
+
+  private static final Set<String> EXPECTED_VERBOSE_OUTPUT =
+      ImmutableSet.of(
+          "MSG: validate: " + OWNERS,
+          "MSG: owner: user1@google.com",
+          "MSG: owner: u1+review@g.com",
+          "MSG: owner: u2.m@g.com");
+
+  @Test
+  public void testGoodInput() throws Exception {
+    try (RevWalk rw = new RevWalk(repo)) {
+      RevCommit c = makeCommit(rw, "Commit good files", FILES_WITH_NO_ERROR);
+      assertThat(validate(rw, c, false, ENABLED_CONFIG)).isEmpty();
+      assertThat(validate(rw, c, true, ENABLED_CONFIG))
+          .containsExactlyElementsIn(EXPECTED_VERBOSE_OUTPUT);
+    }
+  }
+
+  private static final Map<String, String> FILES_WITH_WRONG_SYNTAX =
+      ImmutableMap.of(
+          "README",
+          "# some content\nu2@g.com\n",
+          OWNERS,
+          "\nwrong syntax\n#comment\nuser1@google.com\n",
+          "d2/" + OWNERS,
+          "u1@g.com\nu3@g.com\n*\n",
+          "d3/" + OWNERS,
+          "\nfile: common/Owners\n");
+
+  private static final Set<String> EXPECTED_WRONG_SYNTAX =
+      ImmutableSet.of(
+          "ERROR: syntax: " + OWNERS + ":2: wrong syntax",
+          "ERROR: unknown: u3@g.com at d2/" + OWNERS + ":2",
+          "ERROR: ignored: d3/" + OWNERS + ":2: file: common/Owners");
+
+  private static final Set<String> EXPECTED_VERBOSE_WRONG_SYNTAX =
+      ImmutableSet.of(
+          "MSG: validate: d3/" + OWNERS,
+          "MSG: validate: d2/" + OWNERS,
+          "MSG: validate: " + OWNERS,
+          "MSG: owner: user1@google.com",
+          "MSG: owner: u1@g.com",
+          "MSG: owner: u3@g.com",
+          "ERROR: syntax: " + OWNERS + ":2: wrong syntax",
+          "ERROR: unknown: u3@g.com at d2/" + OWNERS + ":2",
+          "ERROR: ignored: d3/" + OWNERS + ":2: file: common/Owners");
+
+  @Test
+  public void testWrongSyntax() throws Exception {
+    try (RevWalk rw = new RevWalk(repo)) {
+      RevCommit c = makeCommit(rw, "Commit wrong syntax", FILES_WITH_WRONG_SYNTAX);
+      assertThat(validate(rw, c, false, ENABLED_CONFIG))
+          .containsExactlyElementsIn(EXPECTED_WRONG_SYNTAX);
+      assertThat(validate(rw, c, true, ENABLED_CONFIG))
+          .containsExactlyElementsIn(EXPECTED_VERBOSE_WRONG_SYNTAX);
+    }
+  }
+
+  private static final Map<String, String> FILES_WITH_WRONG_EMAILS =
+      ImmutableMap.of("d1/" + OWNERS, "u1@g.com\n", "d2/" + OWNERS_ANDROID, "u2@g.com\n");
+
+  private static final Set<String> EXPECTED_VERBOSE_DEFAULT =
+      ImmutableSet.of("MSG: validate: d1/" + OWNERS, "MSG: owner: u1@g.com");
+
+  private static final Set<String> EXPECTED_VERBOSE_ANDROID =
+      ImmutableSet.of("MSG: validate: d2/" + OWNERS_ANDROID, "MSG: owner: u2@g.com");
+
+  @Test
+  public void checkWrongEmails() throws Exception {
+    try (RevWalk rw = new RevWalk(repo)) {
+      RevCommit c = makeCommit(rw, "Commit Default", FILES_WITH_WRONG_EMAILS);
+      assertThat(validate(rw, c, true, ENABLED_CONFIG))
+          .containsExactlyElementsIn(EXPECTED_VERBOSE_DEFAULT);
+    }
+  }
+
+  @Test
+  public void checkAndroidOwners() throws Exception {
+    try (RevWalk rw = new RevWalk(repo)) {
+      RevCommit c = makeCommit(rw, "Commit Android", FILES_WITH_WRONG_EMAILS);
+      assertThat(validate(rw, c, true, ANDROID_CONFIG))
+          .containsExactlyElementsIn(EXPECTED_VERBOSE_ANDROID);
+    }
+  }
+
+  private static PluginConfig createEnabledConfig() {
+    PluginConfig c = new PluginConfig("", new Config());
+    c.setBoolean(REJECT_ERROR_IN_OWNERS, true);
+    return c;
+  }
+
+  private static PluginConfig createDisabledConfig() {
+    PluginConfig c = new PluginConfig("", new Config());
+    c.setBoolean(REJECT_ERROR_IN_OWNERS, false);
+    return c;
+  }
+
+  private static PluginConfig createAndroidConfig() {
+    PluginConfig c = createEnabledConfig();
+    c.setString(OWNERS_FILE_NAME, OWNERS_ANDROID);
+    return c;
+  }
+
+  private RevCommit makeCommit(RevWalk rw, String message, Map<String, String> fileStrings)
+      throws IOException, GitAPIException {
+    Map<File, byte[]> fileBytes = new HashMap<>();
+    for (String path : fileStrings.keySet()) {
+      fileBytes.put(
+          new File(repo.getDirectory().getParent(), path),
+          fileStrings.get(path).getBytes(StandardCharsets.UTF_8));
+    }
+    return makeCommit(rw, repo, message, fileBytes);
+  }
+
+  private List<String> validate(RevWalk rw, RevCommit c, boolean verbose, PluginConfig cfg)
+      throws Exception {
+    MockedEmails myEmails = new MockedEmails();
+    OwnersValidator validator = new OwnersValidator(null, null, null, myEmails);
+    String ownersFileName = OwnersValidator.getOwnersFileName(cfg);
+    List<CommitValidationMessage> m =
+        validator.performValidation(repo, c, rw, ownersFileName, verbose);
+    return transformMessages(m);
+  }
+
+  private static String generateFilePattern(File f, Git git) {
+    return f.getAbsolutePath()
+        .replace(git.getRepository().getWorkTree().getAbsolutePath(), "")
+        .substring(1);
+  }
+
+  private static void addFiles(Git git, Map<File, byte[]> files)
+      throws IOException, GitAPIException {
+    AddCommand ac = git.add();
+    for (File f : files.keySet()) {
+      if (!f.exists()) {
+        FileUtils.touch(f);
+      }
+      if (files.get(f) != null) {
+        FileUtils.writeByteArrayToFile(f, files.get(f));
+      }
+      ac = ac.addFilepattern(generateFilePattern(f, git));
+    }
+    ac.call();
+  }
+
+  private static RevCommit makeCommit(
+      RevWalk rw, Repository repo, String message, Map<File, byte[]> files)
+      throws IOException, GitAPIException {
+    try (Git git = new Git(repo)) {
+      if (files != null) {
+        addFiles(git, files);
+      }
+      return rw.parseCommit(git.commit().setMessage(message).call());
+    }
+  }
+
+  private static List<String> transformMessages(List<CommitValidationMessage> messages) {
+    return Lists.transform(
+        messages,
+        new Function<CommitValidationMessage, String>() {
+          @Override
+          public String apply(CommitValidationMessage input) {
+            String pre = (input.isError()) ? "ERROR: " : "MSG: ";
+            return pre + input.getMessage();
+          }
+        });
+  }
+
+  @Test
+  public void testTransformer() {
+    List<CommitValidationMessage> messages = new LinkedList<>();
+    messages.add(new CommitValidationMessage("a message", false));
+    messages.add(new CommitValidationMessage("an error", true));
+    Set<String> expected = ImmutableSet.of("ERROR: an error", "MSG: a message");
+    assertThat(transformMessages(messages)).containsExactlyElementsIn(expected);
+  }
+}