CopyrightConfig to manage plugin configurations.

Copyright plugin part 2 of 3: CopyrightConfig manages and validates
plugin configurations described in the gerrit.config file, and in the
All-Projects project.config and groups files.

ScannerConfig wraps all of the parameters needed for scanning files.

CheckConfig wraps facilitates validation of the configuration. It also
provides the main entry point for a command-line tool to check for
performance-harming regular expression patterns.

When a new configuration changes the scanner pattern, the gerrit server
may be configured to require the submitter run the command-line tool
and copy its output to the commit message. By default, the server will
require this process, which can be turned off by making the timeTestMax
value 0.

Change-Id: I82c034be01b9b33b1f57041627d6065e27b021db
diff --git a/BUILD b/BUILD
index 73cc5c5..f36a951 100644
--- a/BUILD
+++ b/BUILD
@@ -30,7 +30,30 @@
     deps = [":copyright_scanner"],
 )
 
-TEST_SRCS = "src/test/java/**/*Test.java"
+java_binary(
+    name = "check_new_config",
+    srcs = ["src/main/java/com/googlesource/gerrit/plugins/copyright/CheckConfig.java"],
+    main_class = "com.googlesource.gerrit.plugins.copyright.CheckConfig",
+    deps = [":copyright_scanner"] + PLUGIN_DEPS,
+)
+
+gerrit_plugin(
+    name = "copyright",
+    srcs = glob(["src/main/java/**/*.java"]),
+    manifest_entries = [
+        "Gerrit-PluginName: copyright",
+        "Gerrit-ReloadMode: restart",
+        "Gerrit-ApiVersion: 3.0-SNAPSHOT",
+        "Gerrit-ApiType: plugin",
+        "Gerrit-Module: com.googlesource.gerrit.plugins.copyright.Module",
+    ],
+    resources = glob(["src/main/resources/**/*"]),
+)
+
+TEST_SRCS = [
+    "src/test/java/**/*IT.java",
+    "src/test/java/**/*Test.java",
+]
 
 TEST_DEPS = PLUGIN_DEPS + PLUGIN_TEST_DEPS + [
     ":copyright_scanner",
@@ -39,12 +62,22 @@
     "@mockito//jar",
 ]
 
+java_library(
+    name = "testutils",
+    testonly = 1,
+    srcs = glob(
+        ["src/test/java/**/*.java"],
+        exclude = TEST_SRCS,
+    ),
+    deps = TEST_DEPS,
+)
+
 junit_tests(
     name = "copyright_scanner_tests",
     testonly = 1,
-    srcs = glob([TEST_SRCS]),
+    srcs = glob(TEST_SRCS),
     tags = ["copyright"],
-    deps = TEST_DEPS,
+    deps = [":testutils"] + TEST_DEPS,
 )
 
 sh_test(
diff --git a/src/main/java/com/googlesource/gerrit/plugins/copyright/CheckConfig.java b/src/main/java/com/googlesource/gerrit/plugins/copyright/CheckConfig.java
new file mode 100644
index 0000000..e6654c7
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/copyright/CheckConfig.java
@@ -0,0 +1,539 @@
+// Copyright (C) 2019 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.copyright;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Preconditions;
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
+import com.google.common.hash.Hashing;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.CurrentUser;
+import com.google.gerrit.server.IdentifiedUser;
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.git.validators.ValidationMessage;
+import com.googlesource.gerrit.plugins.copyright.lib.IndexedLineReader;
+import java.io.ByteArrayInputStream;
+import java.io.FileInputStream;
+import java.io.IOException;
+import java.nio.CharBuffer;
+import java.nio.file.Paths;
+import java.util.Arrays;
+import java.util.Objects;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Utility for verifying copyright plugin configurations.
+ *
+ * <p>{@code main} implements a command-line tool for measuring performance against a large test
+ * file constructed to trigger excessive backtracking.
+ */
+public class CheckConfig {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public static final String toolName = "check_new_config";
+
+  private static final String ACCESS = "access";
+  private static final String LABEL = "label";
+  private static final String PLUGIN = "plugin";
+  private static final int BUFFER_SIZE = 2048;
+  private static final char[] BUFFER = new char[BUFFER_SIZE];
+
+  private String pluginName;
+  private Config configProject;
+  ScannerConfig scannerConfig;
+
+  public CheckConfig(String pluginName, String projectConfigContents)
+      throws ConfigInvalidException {
+    this.pluginName = pluginName;
+
+    configProject = new Config();
+    configProject.fromText(projectConfigContents);
+    Config config = new Config();
+    for (String name : configProject.getNames(PLUGIN, pluginName)) {
+      config.setStringList(
+          PLUGIN,
+          pluginName,
+          name,
+          Arrays.asList(configProject.getStringList(PLUGIN, pluginName, name)));
+    }
+    PluginConfig pluginConfig = new PluginConfig(pluginName, config);
+    this.scannerConfig = new ScannerConfig(pluginName);
+    this.scannerConfig.readConfigFile(pluginConfig);
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (this == other) {
+      return true;
+    }
+    if (other == null) {
+      return false;
+    }
+    if (other instanceof CheckConfig) {
+      CheckConfig that = (CheckConfig) other;
+      return Objects.equals(this.pluginName, that.pluginName)
+          && Objects.equals(this.scannerConfig, that.scannerConfig);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(pluginName, scannerConfig);
+  }
+
+  /**
+   * Validates the final state of {@code trialConfig}.
+   *
+   * <p>Uses {@code reviewApi}, when given, to verify account information. When {@code
+   * pluginEnabled} is false, treats errors as warnings.
+   */
+  public static void checkProjectConfig(
+      CopyrightReviewApi reviewApi, boolean pluginEnabled, CheckConfig trialConfig) {
+    CurrentUser fromUser =
+        reviewApi == null
+            ? null
+            : reviewApi.getSendingUser(trialConfig.scannerConfig.fromAccountId);
+    if (Strings.nullToEmpty(trialConfig.scannerConfig.reviewLabel).trim().isEmpty()) {
+      trialConfig.scannerConfig.messages.add(
+          new CommitValidationMessage(
+              trialConfig.scannerConfig.pluginKeyRequired(
+                  ScannerConfig.KEY_REVIEW_LABEL,
+                  "please use \""
+                      + ScannerConfig.KEY_REVIEW_LABEL
+                      + " = <label name>\" to identify the label "
+                      + (trialConfig.scannerConfig.fromAccountId < 1 || fromUser == null
+                          ? "the plugin"
+                          : fromUser.getLoggableName())
+                      + " will vote on"),
+              pluginEnabled ? ValidationMessage.Type.ERROR : ValidationMessage.Type.WARNING));
+    } else {
+      String labelName = trialConfig.scannerConfig.reviewLabel.trim();
+      if (!trialConfig.configProject.getSubsections(LABEL).contains(labelName)) {
+        trialConfig.scannerConfig.messages.add(
+            new CommitValidationMessage(
+                trialConfig.scannerConfig.pluginKeyValueMessage(
+                    ScannerConfig.KEY_REVIEW_LABEL,
+                    labelName,
+                    "no [" + LABEL + " \"" + labelName + "\"] section configured."),
+                pluginEnabled ? ValidationMessage.Type.ERROR : ValidationMessage.Type.WARNING));
+      }
+      String[] voters =
+          trialConfig.configProject.getStringList(
+              ACCESS, RefNames.REFS_HEADS + "*", "label-" + labelName);
+      boolean foundApprover = false;
+      for (String voter : voters) {
+        if (voter.trim().split("\\s", 2)[0].endsWith("+2")) {
+          foundApprover = true;
+          break;
+        }
+      }
+      if (!foundApprover) {
+        trialConfig.scannerConfig.messages.add(
+            new CommitValidationMessage(
+                trialConfig.scannerConfig.pluginKeyValueMessage(
+                    ScannerConfig.KEY_REVIEW_LABEL,
+                    labelName,
+                    "no configured approvers for "
+                        + labelName
+                        + " on "
+                        + RefNames.REFS_HEADS
+                        + "*"),
+                pluginEnabled ? ValidationMessage.Type.ERROR : ValidationMessage.Type.WARNING));
+      }
+      voters =
+          trialConfig.configProject.getStringList(
+              ACCESS, RefNames.REFS_CONFIG, "label-" + labelName);
+      foundApprover = false;
+      for (String voter : voters) {
+        if (voter.trim().split("\\s", 2)[0].endsWith("+2")) {
+          foundApprover = true;
+          break;
+        }
+      }
+      if (!foundApprover) {
+        trialConfig.scannerConfig.messages.add(
+            new CommitValidationMessage(
+                trialConfig.scannerConfig.pluginKeyValueMessage(
+                    ScannerConfig.KEY_REVIEW_LABEL,
+                    labelName,
+                    "no configured approvers for " + labelName + " on " + RefNames.REFS_CONFIG),
+                pluginEnabled ? ValidationMessage.Type.ERROR : ValidationMessage.Type.WARNING));
+      }
+    }
+    if (trialConfig.scannerConfig.reviewers.isEmpty()) {
+      trialConfig.scannerConfig.messages.add(
+          ScannerConfig.warningMessage(
+              trialConfig.scannerConfig.pluginKeyRequired(
+                  ScannerConfig.KEY_REVIEWER, "adding no qualified reviewer may cause confusion")));
+    }
+    if (trialConfig.scannerConfig.fromAccountId < 1) {
+      trialConfig.scannerConfig.messages.add(
+          new CommitValidationMessage(
+              trialConfig.scannerConfig.pluginKeyRequired(
+                  ScannerConfig.KEY_FROM,
+                  "please use \""
+                      + ScannerConfig.KEY_FROM
+                      + " = <account id>\" to identify a"
+                      + " non-interactive user with full voting permissions for the review label '"
+                      + trialConfig.scannerConfig.reviewLabel
+                      + "'"),
+              pluginEnabled ? ValidationMessage.Type.ERROR : ValidationMessage.Type.WARNING));
+      // TODO: inject ReviewerAdder into reviewApi, and use ReviewerAdder.prepare
+      //       a la
+      // https://gerrit.googlesource.com/gerrit/+/refs/heads/master/java/com/google/gerrit/server/restapi/change/PostReview.java#265
+      //       to verify the reviewers and ccs are valid.
+    }
+    if (fromUser != null && fromUser instanceof IdentifiedUser) {
+      IdentifiedUser sendingUser = (IdentifiedUser) fromUser;
+      Account account = sendingUser.getAccount();
+      if (Strings.isNullOrEmpty(account.getFullName())
+          && Strings.isNullOrEmpty(account.getPreferredEmail())) {
+        trialConfig.scannerConfig.messages.add(
+            ScannerConfig.warningMessage(
+                trialConfig.scannerConfig.pluginKeyValueMessage(
+                    ScannerConfig.KEY_FROM,
+                    Long.toString(trialConfig.scannerConfig.fromAccountId),
+                    fromUser.getLoggableName()
+                        + " (account id "
+                        + trialConfig.scannerConfig.fromAccountId
+                        + ") has no full name or preferred email")));
+      }
+      if (!account.isActive()) {
+        trialConfig.scannerConfig.messages.add(
+            ScannerConfig.warningMessage(
+                trialConfig.scannerConfig.pluginKeyValueMessage(
+                    ScannerConfig.KEY_FROM,
+                    Long.toString(trialConfig.scannerConfig.fromAccountId),
+                    fromUser.getLoggableName()
+                        + " (account id "
+                        + trialConfig.scannerConfig.fromAccountId
+                        + ") account is no longer active")));
+      }
+    } else if (fromUser != null && !fromUser.getUserName().isPresent()) {
+      trialConfig.scannerConfig.messages.add(
+          ScannerConfig.warningMessage(
+              trialConfig.scannerConfig.pluginKeyValueMessage(
+                  ScannerConfig.KEY_FROM,
+                  Long.toString(trialConfig.scannerConfig.fromAccountId),
+                  fromUser.getLoggableName()
+                      + " (account id "
+                      + trialConfig.scannerConfig.fromAccountId
+                      + ") has no user name")));
+    }
+  }
+
+  /**
+   * Confirms whether submitter ran {@code main} and copied the output to the commit message.
+   *
+   * <p>When a new commit alters the configured scanner patterns, the push will fail with a message
+   * to download the plugin source, to run a shell script that runs {@code main} below, and to copy
+   * the output on success into the commit message.
+   *
+   * <p>This method scans the commit message to find the copied text. If the text was created for
+   * the same pattern signagure, this method returns a single valid finding with the number of
+   * microseconds it took to scan a large file, which can be used to block patterns that cause
+   * excessive backtracking.
+   *
+   * <p>If the commit message contains one or more copied texts for other pattern signatures, this
+   * method retuns an invalid finding for each.
+   *
+   * <p>If the commit message contains no copied texts, this method returns an empty list of
+   * findings, which {@link com.googlesource.gerrit.plugins.copyright.CopyrightConfig} uses as a
+   * signal to instruct the submitter to run the shell script in the first place.
+   *
+   * @param commitMessage from the pushed change
+   */
+  public ImmutableList<CopyrightReviewApi.CommitMessageFinding> checkCommitMessage(
+      String commitMessage) {
+    Preconditions.checkArgument(hasScanner(this));
+
+    Pattern pattern =
+        Pattern.compile(
+            "Copyright-check:\\s*([\\p{N}a-fA-F]{1,9})[.]([\\p{N}a-fA-F]{16})\\s*(?:\n|$)",
+            Pattern.CASE_INSENSITIVE | Pattern.MULTILINE | Pattern.UNICODE_CASE | Pattern.DOTALL);
+    Matcher m = pattern.matcher(commitMessage);
+    ImmutableList.Builder<CopyrightReviewApi.CommitMessageFinding> builder =
+        ImmutableList.builder();
+    StringBuilder sb = new StringBuilder();
+    while (m.find()) {
+      sb.setLength(0);
+      sb.append(m.group(1));
+      sb.append("us");
+      sb.append(scannerConfig.patternSignature);
+      String signature =
+          Hashing.farmHashFingerprint64().hashBytes(sb.toString().getBytes(UTF_8)).toString();
+      if (signature.equals(m.group(2))) {
+        return ImmutableList.of(
+            new CopyrightReviewApi.CommitMessageFinding(
+                commitMessage, m.group(), m.group(1), m.start(), m.end()));
+      }
+      logger.atSevere().log("signature for %s is %s", m.group(1), signature);
+      builder.add(
+          new CopyrightReviewApi.CommitMessageFinding(
+              commitMessage, m.group(), m.start(), m.end()));
+    }
+    return builder.build();
+  }
+
+  /**
+   * Returns true when {@code findings} indicate a problem to correct.
+   *
+   * <p>Problems include finding no time signature, finding a time signature for a different commit,
+   * or finding a valid time signature for a test that took longer than {@code maxElapsedSeconds}.
+   */
+  public static boolean mustReportFindings(
+      ImmutableList<CopyrightReviewApi.CommitMessageFinding> findings, long maxElapsedSeconds) {
+    if (findings.size() == 1 && findings.get(0).isValid()) {
+      return findings.get(0).elapsedMicros > maxElapsedSeconds * 1000000;
+    }
+    return true;
+  }
+
+  /** Returns true when {@code trialConfig} is non-null and has a non-null {@code scanner}. */
+  public static boolean hasScanner(CheckConfig trialConfig) {
+    if (trialConfig == null || trialConfig.scannerConfig == null) {
+      return false;
+    }
+    return trialConfig.scannerConfig.scanner != null;
+  }
+
+  /** Returns true when both configs, {@code a} and {@code b}, have the same scanner pattern. */
+  public static boolean scannersEqual(CheckConfig a, CheckConfig b) {
+    if (!hasScanner(a)) {
+      return !hasScanner(b);
+    }
+    return Objects.equals(a.scannerConfig.scanner, b.scannerConfig.scanner);
+  }
+
+  /** Checks whether {@code trialConfig} might cause excessive backtracking. */
+  private long timeLargeFileInMicros() throws IOException {
+    long startNanos = System.nanoTime();
+    try {
+      IndexedLineReader file = largeFile();
+      scannerConfig.scanner.findMatches("file", -1, file);
+    } finally {
+      long elapsedNanos = System.nanoTime() - startNanos;
+      long elapsedMicros = elapsedNanos / 1000;
+      logger.atFine().log("timeLargeFile %dms", elapsedMicros / 1000);
+      return elapsedMicros;
+    }
+  }
+
+  /** Returns {@code IndexedLineReader} for in-memory pattern that can trigger backtracking. */
+  private IndexedLineReader largeFile() {
+    StringBuilder sb = new StringBuilder();
+    sb.append("                                                                "); // 64 spaces
+    sb.append(sb); // 128
+    sb.append(sb); // 256
+    sb.append(sb); // 512
+    sb.append(sb); // 1024
+    sb.append('\n');
+    String space1k = sb.toString();
+
+    sb.setLength(0);
+    for (int i = 0; i < 256; i++) {
+      sb.append(String.format(" x%2x", i));
+    }
+    sb.append('\n');
+    String mixed1k = sb.toString();
+
+    sb.setLength(0);
+    for (int i = 255; i >= 0; i--) {
+      sb.append(String.format(", %2x", i));
+    }
+    sb.append('\n');
+    String comma1k = sb.toString();
+
+    sb.setLength(0);
+    for (int i = 255; i >= 0; i--) {
+      sb.append(String.format("%2x%2x", 255-i, i));
+    }
+    sb.append('\n');
+    String alnum1k = sb.toString();
+
+    sb.setLength(0);
+    sb.append("AbcdefGhijkLmnopQrstuVwxyzaBCDEFgHIJKlMNOPqRSTUvWXYZaeiouyAEIUOY"); // 64
+    sb.append(sb); // 128
+    sb.append(sb); // 256
+    sb.append(sb); // 512
+    sb.append(sb); // 1024
+    sb.append('\n');
+    String alpha1k = sb.toString();
+
+    for (int i = 0; i < 16; i++) {  // 16k + 48k = 64k
+      sb.append(space1k);
+    }
+    for (int i = 0; i < 48; i++) {
+      sb.append(space1k, 0, 512 - 10 * i);
+      sb.append(mixed1k, 512 + 10 * i, mixed1k.length());
+    }
+
+    for (int i = 0; i < 16; i++) { // 64k + 16k + 48k = 128k
+      sb.append(alnum1k);
+    }
+    for (int i = 0; i < 48; i++) {
+      sb.append(comma1k);
+    }
+
+    for (int i = 0; i < 16; i++) { // 128k + 16k + 48k = 192k
+      sb.append(mixed1k);
+    }
+    for (int i = 0; i < 48; i++) {
+      sb.append(alpha1k);
+    }
+
+    for (int i = 0; i < 16; i++) { // 192k + 16k + 48k = 256k
+      sb.append(alpha1k);
+    }
+    for (int i = 0; i < 48; i++) {
+      sb.append(space1k, 0, 512 - 10 * i);
+      sb.append(comma1k, 512 + 10 * i, comma1k.length());
+    }
+
+    return new IndexedLineReader(
+        "big_file", -1, new ByteArrayInputStream(sb.toString().getBytes(UTF_8)));
+  }
+
+  /** Output validation messages on the error console. */
+  private void printErrors() {
+    for (CommitValidationMessage message : scannerConfig.messages) {
+      System.err.printf("%s: %s\n", message.getType(), message.getMessage());
+    }
+  }
+
+  /** Output a usage message on the error console. */
+  private static void usage() {
+    System.err.printf(
+        "%s <plugin-name> <project.config>\n  where:\n"
+            + "    <plugin-name> is the name of the plugin. e.g. 'copyright'\n"
+            + "    <project.config> is the path to the project.config file\n",
+        toolName);
+    System.exit(1);
+  }
+
+  /** Read the contents of a project.config file from {@code ilr}. */
+  private static String readProjectConfigFile(IndexedLineReader ilr) throws IOException {
+    StringBuilder sb = new StringBuilder();
+    CharBuffer cb = CharBuffer.wrap(BUFFER);
+    while (ilr.read(cb) >= 0) {
+      cb.flip();
+      sb.append(cb);
+      cb.clear();
+    }
+    String contents = sb.toString();
+    sb.setLength(0);
+    sb = null;
+    return contents;
+  }
+
+  /** Calculates the time signature for {@code elapsedMicros} and the current scanner pattern. */
+  @VisibleForTesting
+  String timeSignature(long elapsedMicros) {
+    StringBuilder sb = new StringBuilder();
+    sb.append(Long.toString(elapsedMicros));
+    sb.append("us");
+    sb.append(scannerConfig.patternSignature);
+
+    return Hashing.farmHashFingerprint64().hashBytes(sb.toString().getBytes(UTF_8)).toString();
+  }
+
+  /** Entry point for command-line tool to check for excessive backtracking. */
+  public static void main(String[] args) {
+    if (args.length != 2) {
+      usage();
+    }
+    String pluginName = args[0];
+    String fileName = args[1].trim();
+    try {
+      IndexedLineReader ilr =
+          new IndexedLineReader(
+              fileName, Paths.get(fileName).toFile().length(), new FileInputStream(fileName));
+      CheckConfig myConfig = new CheckConfig(pluginName, readProjectConfigFile(ilr));
+      checkProjectConfig(null, false, myConfig);
+      if (myConfig.scannerConfig.hasErrors()) {
+        myConfig.printErrors();
+        System.exit(1);
+      }
+      Preconditions.checkNotNull(myConfig.scannerConfig.scanner);
+
+      long elapsedMicros = myConfig.timeLargeFileInMicros();
+      Preconditions.checkArgument(elapsedMicros >= 0);
+
+      if (elapsedMicros > 1000000) { // longer than a second migth be a problem...
+        System.err.println(
+            "\n\nThis tool used your copyright plugin config against a large, hard-");
+        System.err.println("to-scan file.");
+        System.err.println(
+            "\nEven for large, difficult to scan files, it's best to keep scan times");
+        System.err.println("below 1 second, due to load on the server.\n");
+        System.err.println(CopyrightReviewApi.typicalBacktrackingCauses());
+        if (elapsedMicros > 60000000) { // minutes is way too long on any server...
+          System.err.printf(
+              "\nAt %ds, the scan took longer than %d minutes! Please change\n",
+              elapsedMicros / 1000000, elapsedMicros / 60000000);
+          System.err.println("whatever pattern causes the problem befure submitting.");
+        } else if (elapsedMicros > 10000000) { // tens of seconds is too long on any server...
+          System.err.printf(
+              "\nThe scan took longer than %d seconds! It's very likely this\n",
+              elapsedMicros / 1000000);
+          System.err.println(
+              "configuration will cause problems on your server. Please try to change");
+          System.err.println("whatever pattern causes the problem.");
+        } else if (elapsedMicros > 2000000) { // multiple seconds is likely a problem...
+          System.err.printf(
+              "\nThe scan took longer than %d seconds. It's possible this configuration\n",
+              elapsedMicros / 1000000);
+          System.err.println(
+              "might cause problems on your server. Please compare wtih the current");
+          System.err.println(
+              "configuration, and if this configuration is significantly slower,");
+          System.err.println("consider changing whatever pattern might cause the problem.");
+        } else { // between 1s and 2s might be needed but could at least try to do better
+          System.err.printf(
+              "\nAt %dms, the scan took just longer than 1 second. This\n", elapsedMicros / 1000);
+          System.err.println(
+              "configuration might work okay, but takes longer than ideal. Please");
+          System.err.println(
+              "investigate whether an added pattern is more costly than needed.");
+        }
+      } else if (elapsedMicros > 1000) {
+        System.err.printf("\nScanned the test load in %dms.\n", elapsedMicros / 1000);
+      } else {
+        System.err.printf("\nScanned the test load in %d microseconds.\n", elapsedMicros);
+      }
+
+      String signature = myConfig.timeSignature(elapsedMicros);
+      System.out.println("\n\nCopy the line below into your commit message:");
+      System.out.printf("\nCopyright-check: %x.%s\n\n\n", elapsedMicros, signature);
+      System.exit(0);
+    } catch (IOException e) {
+      System.err.printf("Could not read %s\n%s\n", fileName, e.getMessage());
+      System.exit(1);
+    } catch (ConfigInvalidException e) {
+      System.err.printf("Could not parse %s\n%s\n", fileName, e.getMessage());
+      System.exit(1);
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/copyright/CopyrightConfig.java b/src/main/java/com/googlesource/gerrit/plugins/copyright/CopyrightConfig.java
index c7b0e18..ac76aa4 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/copyright/CopyrightConfig.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/copyright/CopyrightConfig.java
@@ -14,12 +14,12 @@
 
 package com.googlesource.gerrit.plugins.copyright;
 
-import static com.google.common.base.Preconditions.checkArgument;
-import static java.util.Objects.requireNonNull;
+import static java.nio.charset.StandardCharsets.UTF_8;
 
 import com.google.common.annotations.VisibleForTesting;
-import com.google.common.base.Joiner;
 import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.flogger.FluentLogger;
 import com.google.gerrit.extensions.annotations.PluginName;
 import com.google.gerrit.extensions.api.changes.AddReviewerResult;
 import com.google.gerrit.extensions.api.changes.ReviewResult;
@@ -27,72 +27,98 @@
 import com.google.gerrit.extensions.events.RevisionCreatedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.metrics.Description;
+import com.google.gerrit.metrics.Description.Units;
 import com.google.gerrit.metrics.MetricMaker;
-import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.metrics.Timer0;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.CurrentUser;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.PluginConfig;
 import com.google.gerrit.server.config.PluginConfigFactory;
 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.git.validators.ValidationMessage;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gerrit.server.project.ProjectConfig;
 import com.google.gerrit.server.project.ProjectState;
 import com.google.inject.AbstractModule;
 import com.google.inject.Singleton;
-import com.googlesource.gerrit.plugins.copyright.lib.CopyrightPatterns;
-import com.googlesource.gerrit.plugins.copyright.lib.CopyrightPatterns.UnknownPatternName;
-import com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner;
-import com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.Match;
-import com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.MatchType;
-import com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType;
-import com.googlesource.gerrit.plugins.copyright.lib.IndexedLineReader;
-import java.io.ByteArrayInputStream;
 import java.io.IOException;
-import java.nio.charset.StandardCharsets;
-import java.util.ArrayList;
-import java.util.Collection;
 import java.util.Collections;
-import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.concurrent.TimeUnit;
-import java.util.function.Consumer;
-import java.util.regex.Pattern;
-import java.util.regex.PatternSyntaxException;
 import javax.inject.Inject;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Constants;
 import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectLoader;
 import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevTree;
+import org.eclipse.jgit.revwalk.RevWalk;
+import org.eclipse.jgit.treewalk.TreeWalk;
 
 /** Listener to manage configuration for enforcing review of copyright declarations and licenses. */
 @Singleton
-class CopyrightConfig {
+class CopyrightConfig
+    implements CommitValidationListener, RevisionCreatedListener, GitReferenceUpdatedListener {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private static final String DEFAULT_REVIEW_LABEL = "Copyright-Review";
+  private final long DEFAULT_MAX_ELAPSED_SECONDS = 8;
 
   private final Metrics metrics;
   private final AllProjectsName allProjectsName;
   private final String pluginName;
   private final GitRepositoryManager repoManager;
   private final ProjectCache projectCache;
-  private final ProjectConfig.Factory projectConfigFactory;
   private final PluginConfigFactory pluginConfigFactory;
   private final CopyrightReviewApi reviewApi;
 
-  private ScannerConfig scannerConfig;
+  private PluginConfig gerritConfig;
+  private CheckConfig checkConfig;
+
+  static AbstractModule module() {
+    return new AbstractModule() {
+      @Override
+      protected void configure() {
+        DynamicSet.bind(binder(), CommitValidationListener.class).to(CopyrightConfig.class);
+        DynamicSet.bind(binder(), RevisionCreatedListener.class).to(CopyrightConfig.class);
+        DynamicSet.bind(binder(), GitReferenceUpdatedListener.class).to(CopyrightConfig.class);
+      }
+    };
+  }
 
   @Singleton
   private static class Metrics {
+    final Timer0 readConfigTimer;
+    final Timer0 checkConfigTimer;
+    final Timer0 testConfigTimer;
+
     @Inject
-    Metrics(MetricMaker metricMaker) {}
+    Metrics(MetricMaker metricMaker) {
+      readConfigTimer =
+          metricMaker.newTimer(
+              "plugins/copyright/read_config_latency",
+              new Description("Time spent reading and parsing plugin configurations")
+                  .setCumulative()
+                  .setUnit(Units.MICROSECONDS));
+      checkConfigTimer =
+          metricMaker.newTimer(
+              "plugins/copyright/check_config_latency",
+              new Description("Time spent testing proposed plugin configurations")
+                  .setCumulative()
+                  .setUnit(Units.MICROSECONDS));
+      testConfigTimer =
+          metricMaker.newTimer(
+              "plugins/copyright/test_config_latency",
+              new Description("Time spent testing configurations against difficult file pattern")
+                  .setCumulative()
+                  .setUnit(Units.MICROSECONDS));
+    }
   }
 
   @Inject
@@ -102,121 +128,341 @@
       @PluginName String pluginName,
       GitRepositoryManager repoManager,
       ProjectCache projectCache,
-      ProjectConfig.Factory projectConfigFactory,
       PluginConfigFactory pluginConfigFactory,
-      CopyrightReviewApi reviewApi) throws IOException {
+      CopyrightReviewApi reviewApi)
+      throws IOException, ConfigInvalidException {
     this.metrics = metrics;
     this.allProjectsName = allProjectsName;
     this.pluginName = pluginName;
     this.repoManager = repoManager;
     this.projectCache = projectCache;
-    this.projectConfigFactory = projectConfigFactory;
     this.pluginConfigFactory = pluginConfigFactory;
     this.reviewApi = reviewApi;
+
+    long nanoStart = System.nanoTime();
+    try {
+      checkConfig = readConfig(projectCache.getAllProjects().getProject().getConfigRefState());
+    } finally {
+      long elapsedMicros = (System.nanoTime() - nanoStart) / 1000;
+      metrics.readConfigTimer.record(elapsedMicros, TimeUnit.MICROSECONDS);
+    }
   }
 
-  private CopyrightConfig(MetricMaker metricMaker, CopyrightReviewApi reviewApi) {
+  private CopyrightConfig(
+      MetricMaker metricMaker, CopyrightReviewApi reviewApi, String projectConfigContents)
+      throws ConfigInvalidException {
     metrics = new Metrics(metricMaker);
     allProjectsName = new AllProjectsName("All-Projects");
     pluginName = "copyright";
     repoManager = null;
     projectCache = null;
-    projectConfigFactory = null;
     pluginConfigFactory = null;
     this.reviewApi = reviewApi;
-    scannerConfig = this.new ScannerConfig();
+    checkConfig = new CheckConfig(pluginName, projectConfigContents);
   }
 
   @VisibleForTesting
-  static CopyrightConfig createTestInstance(MetricMaker metricMaker, CopyrightReviewApi reviewApi) {
-    return new CopyrightConfig(metricMaker, reviewApi);
+  static CopyrightConfig createTestInstance(
+      MetricMaker metricMaker, CopyrightReviewApi reviewApi, String projectConfigContents)
+      throws ConfigInvalidException {
+    return new CopyrightConfig(metricMaker, reviewApi, projectConfigContents);
   }
 
-  @VisibleForTesting
   ScannerConfig getScannerConfig() {
-    return scannerConfig;
+    return checkConfig.scannerConfig;
   }
 
-  /** Returns true if any pattern in {@code regexes} found in {@code text}. */
-  private static boolean matchesAny(String text, Collection<Pattern> regexes) {
-    requireNonNull(regexes);
-    for (Pattern pattern : regexes) {
-      if (pattern.matcher(text).find()) {
-        return true;
-      }
+  /** Listens for merges to /refs/meta/config on All-Projects to reload plugin configuration. */
+  @Override
+  public void onGitReferenceUpdated(GitReferenceUpdatedListener.Event event) {
+    if (!event.getRefName().equals(RefNames.REFS_CONFIG)) {
+      return;
     }
-    return false;
+    if (!event.getProjectName().equals(allProjectsName.get())) {
+      return;
+    }
+    long nanoStart = System.nanoTime();
+    try {
+      clearConfig();
+      checkConfig = readConfig(event.getNewObjectId());
+    } catch (IOException | ConfigInvalidException e) {
+      logger.atSevere().withCause(e).log("%s plugin unable to load configuration", pluginName);
+      checkConfig = null;
+      return;
+    } finally {
+      long elapsedMicros = (System.nanoTime() - nanoStart) / 1000;
+      metrics.readConfigTimer.record(elapsedMicros, TimeUnit.MICROSECONDS);
+    }
   }
 
-  /** Configuration state for {@link CopyrightValidator}. */
-  class ScannerConfig {
-    CopyrightScanner scanner;
-    final ArrayList<CommitValidationMessage> messages;
-    final LinkedHashSet<Pattern> alwaysReviewPath;
-    final LinkedHashSet<Pattern> matchProjects;
-    final LinkedHashSet<Pattern> excludeProjects;
-    final LinkedHashSet<Pattern> thirdPartyAllowedProjects;
-    final LinkedHashSet<String> reviewers;
-    final LinkedHashSet<String> ccs;
-    String reviewLabel;
-    boolean defaultEnable;
-    int fromAccountId;
-
-    ScannerConfig() {
-      this.messages = new ArrayList<>();
-      this.alwaysReviewPath = new LinkedHashSet<>();
-      this.matchProjects = new LinkedHashSet<>();
-      this.excludeProjects = new LinkedHashSet<>();
-      this.thirdPartyAllowedProjects = new LinkedHashSet<>();
-      this.reviewers = new LinkedHashSet<>();
-      this.ccs = new LinkedHashSet<>();
-      this.reviewLabel = DEFAULT_REVIEW_LABEL;
-      this.defaultEnable = false;
-      this.fromAccountId = 0;
+  /** Blocks upload of bad plugin configurations to /refs/meta/config on All-Projects. */
+  @Override
+  public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent event)
+      throws CommitValidationException {
+    if (!event.getBranchNameKey().get().equals(RefNames.REFS_CONFIG)) {
+      return Collections.emptyList();
     }
+    if (!event.getProjectNameKey().equals(allProjectsName)) {
+      return Collections.emptyList();
+    }
+    long readStart = System.nanoTime();
+    long checkStart = -1;
+    long elapsedMicros = -1;
+    CheckConfig trialConfig = null;
+    try {
+      trialConfig = readConfig(event.commit.getName());
+      elapsedMicros = (System.nanoTime() - readStart) / 1000;
+      metrics.readConfigTimer.record(elapsedMicros, TimeUnit.MICROSECONDS);
+      if (Objects.equals(trialConfig.scannerConfig, checkConfig.scannerConfig)) {
+        return Collections.emptyList();
+      }
+      checkStart = System.nanoTime();
+      long maxElapsedSeconds =
+          gerritConfig == null
+              ? DEFAULT_MAX_ELAPSED_SECONDS
+              : gerritConfig.getLong(ScannerConfig.KEY_TIME_TEST_MAX, DEFAULT_MAX_ELAPSED_SECONDS);
+      if (maxElapsedSeconds > 0
+          && CheckConfig.hasScanner(trialConfig)
+          && !CheckConfig.scannersEqual(trialConfig, checkConfig)) {
+        String commitMessage = event.commit.getFullMessage();
+        ImmutableList<CopyrightReviewApi.CommitMessageFinding> findings =
+            trialConfig.checkCommitMessage(commitMessage);
+        if (CheckConfig.mustReportFindings(findings, maxElapsedSeconds)) {
+          throw reviewApi.getCommitMessageException(pluginName, findings, maxElapsedSeconds);
+        }
+      }
+      boolean pluginEnabled =
+          gerritConfig != null && gerritConfig.getBoolean(ScannerConfig.KEY_ENABLE, false);
+      CheckConfig.checkProjectConfig(reviewApi, pluginEnabled, trialConfig);
+      return trialConfig == null || trialConfig.scannerConfig == null
+          ? Collections.emptyList()
+          : trialConfig.scannerConfig.messages;
+    } catch (IOException e) {
+      logger.atSevere().withCause(e).log(
+          "failed to read new project.config for %s plugin", pluginName);
+      throw new CommitValidationException("failed to read new project.config", e);
+    } catch (ConfigInvalidException e) {
+      logger.atSevere().withCause(e).log("unable to parse %s plugin config", pluginName);
+      if (trialConfig != null && trialConfig.scannerConfig != null) {
+        trialConfig.scannerConfig.messages.add(ScannerConfig.errorMessage(e.getMessage()));
+        return trialConfig.scannerConfig.messages;
+      } else {
+        throw new CommitValidationException("unable to parse new project.config", e);
+      }
+    } finally {
+      if (elapsedMicros < 0) {
+        elapsedMicros = (System.nanoTime() - readStart) / 1000;
+        metrics.readConfigTimer.record(elapsedMicros, TimeUnit.MICROSECONDS);
+      } else if (checkStart >= 0) {
+        long elapsedNanos = System.nanoTime() - checkStart;
+        metrics.checkConfigTimer.record(elapsedNanos, TimeUnit.NANOSECONDS);
+      }
+      if (trialConfig != null
+          && trialConfig.scannerConfig != null
+          && trialConfig.scannerConfig.hasErrors()) {
+        StringBuilder sb = new StringBuilder();
+        sb.append("\nerror in ");
+        sb.append(pluginName);
+        sb.append(" plugin configuration");
+        trialConfig.scannerConfig.appendMessages(sb);
+        throw new CommitValidationException(sb.toString());
+      }
+    }
+  }
 
-    @Override
-    public boolean equals(Object other) {
-      if (this == other) {
-        return true;
+  /** Warns on review thread about suspect plugin configurations. */
+  @Override
+  public void onRevisionCreated(RevisionCreatedListener.Event event) {
+    String project = event.getChange().project;
+    String branch = event.getChange().branch;
+
+    if (!branch.equals(RefNames.REFS_CONFIG)) {
+      return;
+    }
+    if (!project.equals(allProjectsName.get())) {
+      return;
+    }
+    if (!event.getRevision().files.keySet().contains(ProjectConfig.PROJECT_CONFIG)) {
+      return;
+    }
+    // passed onCommitReceived so expect at worst only warnings here
+    long readStart = System.nanoTime();
+    long checkStart = -1;
+    long elapsedMicros = -1;
+    CheckConfig trialConfig = null;
+    try {
+      trialConfig = readConfig(event.getChange().currentRevision);
+      elapsedMicros = (System.nanoTime() - readStart) / 1000;
+      metrics.readConfigTimer.record(elapsedMicros, TimeUnit.MICROSECONDS);
+      if (Objects.equals(trialConfig, checkConfig.scannerConfig)) {
+        return;
       }
-      if (other == null) {
-        return false;
+
+      checkStart = System.nanoTime();
+      if (CheckConfig.hasScanner(trialConfig)
+          && !CheckConfig.scannersEqual(trialConfig, checkConfig)) {
+        long maxElapsedSeconds =
+            gerritConfig == null
+                ? DEFAULT_MAX_ELAPSED_SECONDS
+                : gerritConfig.getLong(
+                    ScannerConfig.KEY_TIME_TEST_MAX, DEFAULT_MAX_ELAPSED_SECONDS);
+        if (maxElapsedSeconds > 0) {
+          String commitMessage = event.getRevision().commitWithFooters;
+          ImmutableList<CopyrightReviewApi.CommitMessageFinding> findings =
+              trialConfig.checkCommitMessage(commitMessage);
+          ReviewResult result =
+              reviewApi.reportCommitMessageFindings(
+                  pluginName,
+                  allProjectsName.get(),
+                  checkConfig == null ? null : checkConfig.scannerConfig,
+                  trialConfig.scannerConfig,
+                  event,
+                  findings,
+                  maxElapsedSeconds);
+          logReviewResultErrors(event, result);
+        }
       }
-      if (other instanceof ScannerConfig) {
-        ScannerConfig otherConfig = (ScannerConfig) other;
-        return defaultEnable == otherConfig.defaultEnable
-            && messages.equals(otherConfig.messages)
-            && alwaysReviewPath.equals(otherConfig.alwaysReviewPath)
-            && matchProjects.equals(otherConfig.matchProjects)
-            && excludeProjects.equals(otherConfig.excludeProjects)
-            && thirdPartyAllowedProjects.equals(otherConfig.thirdPartyAllowedProjects)
-            && reviewers.equals(otherConfig.reviewers)
-            && ccs.equals(otherConfig.ccs)
-            && Objects.equals(reviewLabel, otherConfig.reviewLabel)
-            && Objects.equals(scanner, otherConfig.scanner);
+      boolean pluginEnabled =
+          gerritConfig != null && gerritConfig.getBoolean(ScannerConfig.KEY_ENABLE, false);
+      CheckConfig.checkProjectConfig(reviewApi, pluginEnabled, trialConfig);
+      return;
+    } catch (RestApiException | ConfigInvalidException | IOException e) {
+      logger.atSevere().withCause(e).log("%s plugin unable to read new configuration", pluginName);
+      // throw IllegalStateException? RestApiException?
+      return;
+    } finally {
+      if (trialConfig != null
+          && trialConfig.scannerConfig != null
+          && !trialConfig.scannerConfig.hasErrors()) {
+        try {
+          ReviewResult result =
+              reviewApi.reportConfigMessages(
+                  pluginName,
+                  project,
+                  ProjectConfig.PROJECT_CONFIG,
+                  checkConfig.scannerConfig,
+                  trialConfig.scannerConfig,
+                  event);
+          logReviewResultErrors(event, result);
+        } catch (RestApiException e) {
+          logger.atSevere().withCause(e).log(
+              "%s plugin unable to read new configuration", pluginName);
+          // throw IllegalStateException? RestApiException?
+          return;
+        }
       }
+      if (elapsedMicros < 0) {
+        elapsedMicros = (System.nanoTime() - readStart) / 1000;
+        metrics.readConfigTimer.record(elapsedMicros, TimeUnit.MICROSECONDS);
+      } else if (checkStart >= 0) {
+        long elapsedNanos = System.nanoTime() - checkStart;
+        metrics.checkConfigTimer.record(elapsedNanos, TimeUnit.NANOSECONDS);
+      }
+    }
+  }
+
+  /** Returns true if copyright validation enabled for {@code project}. */
+  boolean isProjectEnabled(ScannerConfig scannerConfig, String project) {
+    // scan all projects when missing
+    if (!scannerConfig.matchProjects.isEmpty()
+        && !ScannerConfig.matchesAny(project, scannerConfig.matchProjects)) {
+      // doesn't match == isn't checked
       return false;
     }
-
-    @Override
-    public int hashCode() {
-      return Objects.hash(
-          defaultEnable,
-          messages,
-          alwaysReviewPath,
-          matchProjects,
-          excludeProjects,
-          thirdPartyAllowedProjects,
-          reviewers,
-          ccs,
-          reviewLabel,
-          scanner);
+    // exclude no projects when missing
+    if (!scannerConfig.excludeProjects.isEmpty()
+        && ScannerConfig.matchesAny(project, scannerConfig.excludeProjects)) {
+      // does match == isn't checked
+      return false;
     }
+    ProjectState projectState;
+    try {
+      projectState = projectCache.checkedGet(new Project.NameKey(project));
+    } catch (IOException e) {
+      logger.atSevere().withCause(e).log("error getting project state of %s", project);
+      // throw IllegalStateException? RestApiException?
+      return scannerConfig.defaultEnable;
+    }
+    if (projectState == null) {
+      logger.atSevere().log("error getting project state of %s", project);
+      // throw IllegalStateException? RestApiException?
+      return scannerConfig.defaultEnable;
+    }
+    ProjectConfig projectConfig = projectState.getConfig();
+    if (projectConfig == null) {
+      logger.atWarning().log("error getting project config of %s", project);
+      // throw IllegalStateException? RestApiException? return?
+      return scannerConfig.defaultEnable;
+    }
+    PluginConfig pluginConfig = projectConfig.getPluginConfig(pluginName);
+    if (pluginConfig == null) {
+      // no plugin config section in project so use default
+      return scannerConfig.defaultEnable;
+    }
+    return pluginConfig.getBoolean(ScannerConfig.KEY_ENABLE, scannerConfig.defaultEnable);
+  }
 
-    /** Returns true if {@code project} repository allows third-party code. */
-    boolean isThirdPartyAllowed(String project) {
-      return matchesAny(project, thirdPartyAllowedProjects);
+  /**
+   * Loads and compiles configured patterns from {@code ref/meta/All-Projects/project.config} and
+   * {@code gerrit.config}.
+   *
+   * @param projectConfigObjectId identifies the version of project.config to load and to compile
+   * @return the new scanner configuration to check
+   * @throws IOException if accessing the repository fails
+   */
+  private CheckConfig readConfig(String projectConfigObjectId)
+      throws IOException, ConfigInvalidException {
+    CheckConfig checkConfig = null;
+    // new All-Projects project.config not yet in cache -- read from repository
+    ObjectId id = ObjectId.fromString(projectConfigObjectId);
+    if (ObjectId.zeroId().equals(id)) {
+      return checkConfig;
+    }
+    try (Repository repo = repoManager.openRepository(allProjectsName)) {
+      checkConfig =
+          new CheckConfig(pluginName, readFileContents(repo, id, ProjectConfig.PROJECT_CONFIG));
+    }
+    gerritConfig = pluginConfigFactory.getFromGerritConfig(pluginName, true);
+    if (gerritConfig == null) {
+      // throw IllegalStateException? RestApiException?
+      checkConfig.scannerConfig.messages.add(
+          ScannerConfig.hintMessage(
+              "missing [plugin \"" + pluginName + "\"] section in gerrit.config"));
+    } else {
+      checkConfig.scannerConfig.defaultEnable =
+          gerritConfig.getBoolean(ScannerConfig.KEY_ENABLE, false);
+    }
+    return checkConfig;
+  }
+
+  /** Erases any prior configuration state. */
+  private void clearConfig() {
+    checkConfig = null;
+  }
+
+  private void logReviewResultErrors(RevisionCreatedListener.Event event, ReviewResult result) {
+    if (!Strings.isNullOrEmpty(result.error)) {
+      logger.atSevere().log(
+          "%s plugin revision %s: error posting review: %s",
+          pluginName, event.getChange().currentRevision, result.error);
+    }
+    for (Map.Entry<String, AddReviewerResult> entry : result.reviewers.entrySet()) {
+      AddReviewerResult arr = entry.getValue();
+      if (!Strings.isNullOrEmpty(arr.error)) {
+        logger.atSevere().log(
+            "%s plugin revision %s: error adding reviewer %s: %s",
+            pluginName, event.getChange().currentRevision, entry.getKey(), arr.error);
+      }
+    }
+  }
+
+  private String readFileContents(Repository repo, ObjectId objectId, String filename)
+      throws IOException {
+    RevWalk rw = new RevWalk(repo);
+    RevTree tree = rw.parseTree(objectId);
+    try (TreeWalk tw = TreeWalk.forPath(rw.getObjectReader(), filename, tree)) {
+      ObjectLoader loader = repo.open(tw.getObjectId(0), Constants.OBJ_BLOB);
+      return new String(loader.getCachedBytes(), UTF_8);
     }
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/copyright/CopyrightReviewApi.java b/src/main/java/com/googlesource/gerrit/plugins/copyright/CopyrightReviewApi.java
index 89c9c78..1463db3 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/copyright/CopyrightReviewApi.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/copyright/CopyrightReviewApi.java
@@ -17,12 +17,13 @@
 import static com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.MatchType.AUTHOR_OWNER;
 import static com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.MatchType.LICENSE;
 import static com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType.FIRST_PARTY;
-import static com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType.THIRD_PARTY;
 import static com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType.FORBIDDEN;
+import static com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType.THIRD_PARTY;
 import static com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType.UNKNOWN;
 
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Preconditions;
+import com.google.common.base.Strings;
 import com.google.common.base.Throwables;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
@@ -53,19 +54,23 @@
 import com.google.gerrit.server.PluginUser;
 import com.google.gerrit.server.change.ChangeResource;
 import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.git.validators.CommitValidationException;
 import com.google.gerrit.server.git.validators.CommitValidationMessage;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.restapi.change.ListChangeComments;
 import com.google.gerrit.server.restapi.change.PostReview;
-import com.google.inject.Singleton;
 import com.google.inject.Provider;
+import com.google.inject.Singleton;
 import com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.Match;
 import com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.MatchType;
 import com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Objects;
 import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
 import javax.inject.Inject;
 
 /** Utility to report revision findings on the review thread. */
@@ -156,6 +161,132 @@
   }
 
   /**
+   * Reports failure findings from or about the check_new_config.sh tool on the review thread.
+   *
+   * <p>If no output found in the commit message, directs the submitter to run the tool and copy its
+   * output into the commit message.
+   *
+   * <p>If the output was found for a different scanner pattern, directs the submitter to run again
+   * for the current commit.
+   *
+   * <p>Otherwise, the duration of the timing run must exceed the configured limit. Describes
+   * patterns known to cause problems and directs the submitter to change the pattern.
+   *
+   * @param pluginName as installed in gerrit
+   * @param project identifies the All-Projects config for recording metrics
+   * @param oldConfig the prior state of the plugin configuration
+   * @param newConfig the new state of the plugin configuration
+   * @param event describes the pushed revision with the new configuration
+   * @param findings describes the line numbers, validity and timing run durations found
+   * @param maxElapsedSeconds the largest allows timing run duration in seconds
+   * @throws RestApiException if an error occurs updating the review thread
+   */
+  ReviewResult reportCommitMessageFindings(
+      String pluginName,
+      String project,
+      ScannerConfig oldConfig,
+      ScannerConfig newConfig,
+      RevisionCreatedListener.Event event,
+      ImmutableList<CommitMessageFinding> findings,
+      long maxElapsedSeconds)
+      throws RestApiException {
+    Preconditions.checkNotNull(newConfig);
+    Preconditions.checkNotNull(newConfig.scanner);
+    Preconditions.checkArgument(
+        oldConfig == null
+            || oldConfig.scanner == null
+            || !oldConfig.scanner.equals(newConfig.scanner));
+    Preconditions.checkArgument(
+        findings.size() != 1
+            || !findings.get(0).isValid());
+    Preconditions.checkArgument(maxElapsedSeconds > 0);
+
+    long startNanos = System.nanoTime();
+    metrics.reviewCount.increment();
+    metrics.reviewCountByProject.increment(project);
+
+    int fromAccountId =
+        oldConfig != null && oldConfig.fromAccountId > 0
+            ? oldConfig.fromAccountId
+            : newConfig.fromAccountId;
+    ChangeResource change = getChange(event, fromAccountId);
+    Map<String, List<CommentInfo>> priorReviewComments = getComments(change);
+    if (priorReviewComments == null) {
+      priorReviewComments = ImmutableMap.of();
+    }
+    HashSet<CommentInfo> priorComments = new HashSet<>();
+    priorComments.addAll(priorReviewComments.get("/COMMIT_MSG"));
+
+    ReviewInput ri = new ReviewInput();
+    ri.message(getCommitMessageMessage(pluginName, findings, maxElapsedSeconds));
+    ImmutableList<CommentInput> comments =
+        ImmutableList.copyOf(
+            getCommitMessageComments(pluginName, findings, maxElapsedSeconds).stream()
+                .filter(ci -> !priorComments.contains(ci))
+                .toArray(i -> new CommentInput[i]));
+    if (!comments.isEmpty()) {
+      ri.comments = ImmutableMap.of("/COMMIT_MSG", comments);
+    }
+    String label = "Code-Review";
+    if (!Strings.isNullOrEmpty(oldConfig.reviewLabel)) {
+      label = oldConfig.reviewLabel;
+    } else if (!Strings.isNullOrEmpty(newConfig.reviewLabel)) {
+      label = newConfig.reviewLabel;
+    }
+    int vote = (findings.size() != 1 || !findings.get(0).isValid()) ? -2 : 2;
+    if (vote == 2) {
+      long elapsedMicros = findings.get(0).elapsedMicros;
+      if (elapsedMicros > maxElapsedSeconds * 1000000) {
+        vote = -2;
+      } else if (elapsedMicros > 2000000) {
+        vote = 1;
+      }
+    }
+    ri.label(label, vote);
+    return review(change, ri);
+  }
+
+  /**
+   * Returns a {@link com.google.gerrit.server.git.validators.CommitValidationException} describing
+   * failure findings from or about the check_new_config.sh tool.
+   *
+   * <p>If no output found in the commit message, directs the submitter to run the tool and copy its
+   * output into the commit message.
+   *
+   * <p>If the output was found for a different scanner pattern, directs the submitter to run again
+   * for the current commit.
+   *
+   * <p>Otherwise, the duration of the timing run must exceed the configured limit. Describes
+   * patterns known to cause problems and directs the submitter to change the pattern.
+   *
+   * @param pluginName as installed in gerrit
+   * @param findings describes the line numbers, validity and timing run durations found
+   * @param maxElapsedSeconds the largest allows timing run duration in seconds
+   */
+  public CommitValidationException getCommitMessageException(
+      String pluginName, ImmutableList<CommitMessageFinding> findings, long maxElapsedSeconds) {
+    Preconditions.checkArgument(
+        findings.size() != 1
+        || !findings.get(0).isValid()
+        || findings.get(0).elapsedMicros > maxElapsedSeconds * 1000000);
+    StringBuilder sb = new StringBuilder();
+    sb.append(getCommitMessageMessage(pluginName, findings, maxElapsedSeconds));
+    sb.append("\n\n");
+    ImmutableList<CommentInput> comments =
+        getCommitMessageComments(pluginName, findings, maxElapsedSeconds);
+    for (CommentInput ci : comments) {
+      if (ci.line != 0) {
+        sb.append("commit message line ");
+        sb.append(Integer.toString(ci.range.startLine));
+        sb.append(":\n");
+      }
+      sb.append(ci.message);
+      sb.append("\n");
+    }
+    return new CommitValidationException(sb.toString());
+  }
+
+  /**
    * Reports validation findings for a proposed new plugin configuration to the review thread for
    * the newly pushed revision.
    *
@@ -171,9 +302,10 @@
       String pluginName,
       String project,
       String path,
-      CopyrightConfig.ScannerConfig oldConfig,
-      CopyrightConfig.ScannerConfig newConfig,
-      RevisionCreatedListener.Event event) throws RestApiException {
+      ScannerConfig oldConfig,
+      ScannerConfig newConfig,
+      RevisionCreatedListener.Event event)
+      throws RestApiException {
     Preconditions.checkNotNull(newConfig);
     Preconditions.checkNotNull(newConfig.messages);
     Preconditions.checkArgument(!newConfig.messages.isEmpty());
@@ -186,13 +318,12 @@
       int fromAccountId =
           oldConfig != null && oldConfig.fromAccountId > 0
               ? oldConfig.fromAccountId
-                  : newConfig.fromAccountId;
+              : newConfig.fromAccountId;
       ChangeResource change = getChange(event, fromAccountId);
       StringBuilder message = new StringBuilder();
       message.append(pluginName);
       message.append(" plugin issues parsing new configuration");
-      ReviewInput ri = new ReviewInput()
-          .message(message.toString());
+      ReviewInput ri = new ReviewInput().message(message.toString());
 
       Map<String, List<CommentInfo>> priorComments = getComments(change);
       if (priorComments == null) {
@@ -240,9 +371,10 @@
    */
   ReviewResult reportScanFindings(
       String project,
-      CopyrightConfig.ScannerConfig scannerConfig,
+      ScannerConfig scannerConfig,
       RevisionCreatedListener.Event event,
-      Map<String, ImmutableList<Match>> findings) throws RestApiException {
+      Map<String, ImmutableList<Match>> findings)
+      throws RestApiException {
     long startNanos = System.nanoTime();
     metrics.reviewCount.increment();
     metrics.reviewCountByProject.increment(project);
@@ -266,9 +398,10 @@
         }
       }
       ChangeResource change = getChange(event, scannerConfig.fromAccountId);
-      ReviewInput ri = new ReviewInput()
-          .message("Copyright scan")
-          .label(scannerConfig.reviewLabel, reviewRequired ? -1 : +2);
+      ReviewInput ri =
+          new ReviewInput()
+              .message("Copyright scan")
+              .label(scannerConfig.reviewLabel, reviewRequired ? -1 : +2);
 
       if (reviewRequired) {
         ri = addReviewers(ri, scannerConfig.ccs, ReviewerState.CC);
@@ -301,9 +434,11 @@
           PartyType pt = partyType(entry.getValue());
           newComments = reviewComments(project, pt, tpAllowed, entry.getValue());
           List<CommentInfo> prior = priorComments.get(entry.getKey());
-          newComments = ImmutableList.copyOf(
-              newComments.stream().filter(ci -> !containsComment(prior, ci))
-                  .toArray(i -> new CommentInput[i]));
+          newComments =
+              ImmutableList.copyOf(
+                  newComments.stream()
+                      .filter(ci -> !containsComment(prior, ci))
+                      .toArray(i -> new CommentInput[i]));
           if (newComments.isEmpty()) {
             continue;
           }
@@ -328,18 +463,179 @@
   /**
    * Returns the {@link com.google.gerrit.server.CurrentUser} seeming to send the review comments.
    *
-   * Impersonates {@code fromAccountId} if configured by {@code fromAccountId =} in plugin
+   * <p>Impersonates {@code fromAccountId} if configured by {@code fromAccountId =} in plugin
    * configuration -- falling back to the identity of the user pushing the revision.
    */
   CurrentUser getSendingUser(int fromAccountId) {
-      PluginUser pluginUser = pluginUserProvider.get();
-      return fromAccountId <= 0 ? userProvider.get() :
-          identifiedUserFactory.runAs(null, new Account.Id(fromAccountId), pluginUser);
+    PluginUser pluginUser = pluginUserProvider.get();
+    return fromAccountId <= 0
+        ? userProvider.get()
+        : identifiedUserFactory.runAs(null, new Account.Id(fromAccountId), pluginUser);
   }
 
   /**
-   * Constructs a {@link com.google.gerrit.server.change.ChangeResource} from the notes log for
-   * the change onto which a new revision was pushed.
+   * Returns 1 of 3 review messages depending on the check_new_config.sh tool output.
+   *
+   * @param pluginName as installed in gerrit
+   * @param findings describes the line numbers, validity and timing run durations found
+   */
+  private String getCommitMessageMessage(
+      String pluginName, ImmutableList<CommitMessageFinding> findings, long maxElapsedSeconds) {
+    StringBuilder sb = new StringBuilder();
+    if (findings.isEmpty()) {
+      sb.append(pluginName);
+      sb.append(" plugin: match patterns have changed; please run check_new_config tool");
+    } else if (findings.size() == 1 && findings.get(0).isValid()) {
+      long elapsedMicros = findings.get(0).elapsedMicros;
+      if (elapsedMicros > maxElapsedSeconds * 1000000) {
+        sb.append("check_new_config: problem pattern detected");
+      } else if (elapsedMicros > 2000000) {
+        sb.append("check_new_config: possible problem pattern detected");
+      } else if (elapsedMicros > 1000000) {
+        sb.append("check_new_config: possibly okay");
+      } else {
+        sb.append("check_new_config: okay");
+      }
+    } else {
+      sb.append("check_new_config: results for wrong commit; please run again for current");
+    }
+    return sb.toString();
+  }
+
+  /**
+   * Returns one or more of 3 review comment versions based on the check_new_config.sh tool output.
+   *
+   * @param pluginName as installed in gerrit
+   * @param findings describes the line numbers, validity and timing run durations found
+   * @param maxElapsedSeconds the largest allows timing run duration in seconds
+   */
+  private ImmutableList<CommentInput> getCommitMessageComments(
+      String pluginName, ImmutableList<CommitMessageFinding> findings, long maxElapsedSeconds) {
+    ImmutableList.Builder<CommentInput> comments = ImmutableList.builder();
+    StringBuilder sb = new StringBuilder();
+    CommentInput ci = new CommentInput();
+    if (findings.isEmpty()) {
+      sb.append("While most patterns are fine, some patterns can force your gerrit server\n");
+      sb.append("to work too hard. To protect your server, there is a tool that can\n");
+      sb.append("detect these patterns before the configuration gets submitted.\n\n");
+      sb.append("Please use git to download the tool from:\n");
+      sb.append("https://gerrit.googlesource.com/plugins/copyright/+/refs/heads/master\n\n");
+      sb.append("After downloading, run tools/check_new_config.sh (requires bazel):\n");
+      sb.append("<path>/tools/check_new_config.sh '");
+      sb.append(pluginName);
+      sb.append("' '<path>/project.config'\n");
+      sb.append("and copy it's output to your commit message.\n\n");
+      sb.append("e.g. if your local All-Projects is at workspace/All-Projects and if you\n");
+      sb.append("downloaded plugins/copyright to workspace/copyright, you might run:\n");
+      sb.append("../copyright/tools/check_new_config.sh '");
+      sb.append(pluginName);
+      sb.append("' project.config\n");
+      sb.append("from the workspace/All-Projects directory.\n");
+      ci.line = 0;
+      ci.unresolved = true;
+      ci.message = sb.toString();
+      comments.add(ci);
+    } else if (findings.size() == 1 && findings.get(0).isValid()) {
+      CommitMessageFinding finding = findings.get(0);
+      if (finding.elapsedMicros > maxElapsedSeconds * 1000000) {
+        sb.append("Scanning the test file took longer than ");
+        sb.append(Long.toString(maxElapsedSeconds));
+        sb.append(" seconds.");
+        if (finding.elapsedMicros - (maxElapsedSeconds * 1000000) > 1000000) {
+          sb.append(" (");
+          sb.append(Long.toString(finding.elapsedMicros / 1000000));
+          sb.append(" seconds)");
+        }
+        sb.append("\n\nThis is much longer than usual even on a slower, modern computer.\n\n");
+        sb.append("The result suggests a pattern that causes excessive backtracking.\n");
+        sb.append(typicalBacktrackingCauses());
+        sb.append("\nPlease fix any problematic patterns and try again.\n");
+      } else if (finding.elapsedMicros > 2000000) {
+        sb.append("Scanning the test file took longer than 2 seconda. (");
+        if (finding.elapsedMicros > 3000000) {
+          sb.append(Long.toString(finding.elapsedMicros / 1000000));
+          sb.append(" seconds)");
+        } else {
+          sb.append(Long.toString(finding.elapsedMicros / 1000));
+          sb.append(" ms)");
+        }
+        sb.append("\n\nThe result suggests a pattern that might cause excessive backtracking.\n");
+        sb.append(typicalBacktrackingCauses());
+        sb.append("\nPlease try to fix any problematic patterns before proceeding.\n");
+      } else if (finding.elapsedMicros > 1000000) {
+        sb.append("Scanning the test file took just longer than a second. (");
+        sb.append(Long.toString(finding.elapsedMicros / 1000));
+        sb.append("ms)");
+        sb.append("\n\nThe result is a little longer than ideal.\n\n");
+        sb.append(typicalBacktrackingCauses());
+        sb.append("\n\nCompare with the current config, and if this config is signigicantly\n");
+        sb.append("slower, consider changing whatever pattern causes the problem.\n");
+      } else if (finding.elapsedMicros > 1000) {
+        sb.append("Scanning the test file took ");
+        sb.append(Long.toString(finding.elapsedMicros / 1000));
+        sb.append("ms.");
+      } else {
+        sb.append("Scanning the test file took ");
+        sb.append(Long.toString(finding.elapsedMicros));
+        sb.append(" microsends.");
+      }
+      ci.line = finding.endLine;
+      ci.range = new CommentInput.Range();
+      ci.range.startLine = finding.startLine;
+      ci.range.endLine = finding.endLine;
+      ci.range.startCharacter = finding.startCol;
+      ci.range.endCharacter = finding.endCol;
+      ci.unresolved = finding.elapsedMicros > 1000000;
+      ci.message = sb.toString();
+      comments.add(ci);
+    } else {
+      for (CommitMessageFinding finding : findings) {
+        sb.setLength(0);
+        sb.append("'");
+        sb.append(finding.text.trim());
+        sb.append("'\nis not a result for the patterns in the current revision");
+        ci.line = finding.endLine;
+        ci.range = new CommentInput.Range();
+        ci.range.startLine = finding.startLine;
+        ci.range.endLine = finding.endLine;
+        ci.range.startCharacter = finding.startCol;
+        ci.range.endCharacter = finding.endCol;
+        ci.unresolved = true;
+        ci.message = sb.toString();
+        comments.add(ci);
+        ci = new CommentInput();
+      }
+    }
+    return comments.build();
+  }
+
+  public static String typicalBacktrackingCauses() {
+    StringBuilder sb = new StringBuilder();
+    sb.append("Typical causes of excessive backtracking include:\n");
+    sb.append("  1. unbounded repetitions of wildcards or\n");
+    sb.append("  2. zero-length look-ahead/look-behind patterns\n\n");
+    sb.append("Wildcards:\n");
+    sb.append("  The scanner automatically handles .* and .+ patterns, but it's\n");
+    sb.append("  possible to accidentally compose equivalents or near-equivalents:\n");
+    sb.append("  e.g. (?:[a]|[^a])* or [\\\\s\\\\p{N}\\\\p{L}\\\\p{P}]+ match nearly everything\n");
+    sb.append("  If your new pattern contains something similar, consider using .* for\n");
+    sb.append("  automatic handling instead, or use a smaller character class.\n\n");
+    sb.append("Unbounded repetitions:\n");
+    sb.append("  If your new pattern uses * or + for unlimited repetitions, consider\n");
+    sb.append("  using a more limited repetition like {0,10} or {1,50} that is long\n");
+    sb.append("  enough to match what you need but short enough to scan quickly.\n\n");
+    sb.append("Zero-length look-ahead or look-behind:\n");
+    sb.append("  Patterns like (?!word), (?=word), (?<!word). (?<=word) etc. can cause\n");
+    sb.append("  excessive backtracking too. Sometimes, it is faster to match a little\n");
+    sb.append("  more than needed and use an excludePattern to eliminate unwanted hits.\n");
+    sb.append("  e.g. forbiddenPattern = owner some pattern \\p{L}*\n");
+    sb.append("       excludePattern = some pattern word\n");
+    return sb.toString();
+  }
+
+  /**
+   * Constructs a {@link com.google.gerrit.server.change.ChangeResource} from the notes log for the
+   * change onto which a new revision was pushed.
    *
    * @param event describes the newly pushed revision
    * @param fromAccountId identifies the configured user to impersonate when sending review comments
@@ -355,7 +651,8 @@
     } catch (Exception e) {
       Throwables.throwIfUnchecked(e);
       throw e instanceof RestApiException
-          ? (RestApiException) e : new RestApiException("Cannot load change", e);
+          ? (RestApiException) e
+          : new RestApiException("Cannot load change", e);
     }
   }
 
@@ -383,7 +680,8 @@
     } catch (Exception e) {
       Throwables.throwIfUnchecked(e);
       throw e instanceof RestApiException
-          ? (RestApiException) e : new RestApiException("Cannot list comments", e);
+          ? (RestApiException) e
+          : new RestApiException("Cannot list comments", e);
     }
   }
 
@@ -403,7 +701,8 @@
     } catch (Exception e) {
       Throwables.throwIfUnchecked(e);
       throw e instanceof RestApiException
-          ? (RestApiException) e : new RestApiException("Cannot post review", e);
+          ? (RestApiException) e
+          : new RestApiException("Cannot post review", e);
     }
   }
 
@@ -427,13 +726,13 @@
    * Puts the pieces together from a scanner finding to construct a coherent human-reable message.
    *
    * @param project describes the project or repository where the revision was pushed
-   * @param overallPt identifies the calculated
-   *     {@link com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType} for all of
-   *     the findings in the file. e.g. 1p license + 3p owner == 1p, no license + 3p owner == 3p
+   * @param overallPt identifies the calculated {@link
+   *     com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType} for all of the
+   *     findings in the file. e.g. 1p license + 3p owner == 1p, no license + 3p owner == 3p
    * @param pt identifies the {@code PartyType} of the current finding
-   * @param mt identifies the
-   *     {@link com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.MatchType} of the
-   *     current finding. i.e. AUTHOR_OWNER or LICENSE
+   * @param mt identifies the {@link
+   *     com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.MatchType} of the current
+   *     finding. i.e. AUTHOR_OWNER or LICENSE
    * @param tpAllowed is true if {@code project} allows third-party code
    * @param text of the message for the finding
    */
@@ -476,9 +775,9 @@
    * Converts the scanner findings in {@code matches} into human-readable review comments.
    *
    * @param project the project or repository to which the revision was pushed
-   * @param pt the calculated overall
-   *     {@link com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType} for the
-   *     findings e.g. 1p license + 3p owner = 1p, no license + 3p owner = 3p
+   * @param pt the calculated overall {@link
+   *     com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType} for the findings
+   *     e.g. 1p license + 3p owner = 1p, no license + 3p owner = 3p
    * @param tpAllowed is true if {@code project} allows third-party code
    * @param matches describes the location and types of matches found in a file
    * @return a list of {@link com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput} to
@@ -503,9 +802,10 @@
           builder.add(ci);
         }
         ci = new CommentInput();
-        boolean allowed = m.partyType == FIRST_PARTY
-            || (m.partyType == THIRD_PARTY && tpAllowed)
-            || (m.partyType == THIRD_PARTY && m.matchType == AUTHOR_OWNER && pt == FIRST_PARTY);
+        boolean allowed =
+            m.partyType == FIRST_PARTY
+                || (m.partyType == THIRD_PARTY && tpAllowed)
+                || (m.partyType == THIRD_PARTY && m.matchType == AUTHOR_OWNER && pt == FIRST_PARTY);
         ci.unresolved = !allowed;
         ci.range = new Comment.Range();
         ci.line = m.endLine;
@@ -541,9 +841,9 @@
   }
 
   /**
-   * Calculates and returns the overall
-   * {@link com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType} for the
-   * copyright scanner findings in {@code matches}.
+   * Calculates and returns the overall {@link
+   * com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType} for the copyright
+   * scanner findings in {@code matches}.
    */
   @VisibleForTesting
   PartyType partyType(Iterable<Match> matches) {
@@ -564,4 +864,83 @@
     }
     return pt;
   }
+
+  /**
+   * Found {@code main} output in the commit message.
+   *
+   * <p>Each finding identifies the position in the commit message, the validity for the current
+   * scanner pattern, and the large file scan duration if valid.
+   */
+  public static class CommitMessageFinding {
+    private static final Pattern NL = Pattern.compile("\n", Pattern.MULTILINE | Pattern.DOTALL);
+
+    /** The character offset into the commit message where the finding starts. */
+    public final int start;
+    /** The character offset into the commit message where the finding ends. */
+    public final int end;
+    /** The found text apparently matching {@code main} output. */
+    public final String text;
+    /** How long in microseconds it took to scan a large file, or -1 if scan with other pattern. */
+    public final long elapsedMicros;
+
+    /** The line number of the start of the finding in the commit message. */
+    public final int startLine;
+    /** The column (0-based) of the start of the finding in the commit message. */
+    public final int startCol;
+    /** The line number of the end of the finding in the commit message. */
+    public final int endLine;
+    /** The column (0-based) of the end of the finding in the commit message. */
+    public final int endCol;
+
+    /** Returns true when {@code elapsedMicros} reflects the current scanner pattern. */
+    public boolean isValid() {
+      return elapsedMicros >= 0;
+    }
+
+    /** A finding for the current scanner pattern with relevant {@code elapsedMicros}. */
+    CommitMessageFinding(String commitMsg, String text, String elapsedMicros, int start, int end) {
+      this.start = start;
+      this.end = end;
+      this.text = text;
+      this.elapsedMicros = Long.parseLong(elapsedMicros, 16);
+
+      Matcher m = NL.matcher(commitMsg);
+      int line = 1;
+      int lineStart = 0;
+      int startLine = 0;
+      int startCol = -1;
+      int endLine = 0;
+      int endCol = -1;
+      while (m.find()) {
+        if (m.start() > start) {
+          startLine = line;
+          startCol = start - lineStart;
+        }
+        if (m.start() > end) {
+          endLine = line;
+          endCol = end - lineStart;
+          break;
+        }
+        line++;
+        lineStart = m.end();
+      }
+      if (startLine == 0) {
+        startLine = line;
+        startCol = start - lineStart;
+      }
+      if (endLine == 0) {
+        endLine = line;
+        endCol = end - lineStart;
+      }
+      this.startLine = startLine;
+      this.startCol = startCol;
+      this.endLine = endLine;
+      this.endCol = endCol;
+    }
+
+    /** A finding for a different scanner pattern -- {@code elapsedMicros} not relevant. */
+    CommitMessageFinding(String commitMsg, String text, int start, int end) {
+      this(commitMsg, text, "-1", start, end);
+    }
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/copyright/ScannerConfig.java b/src/main/java/com/googlesource/gerrit/plugins/copyright/ScannerConfig.java
new file mode 100644
index 0000000..815b28c
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/copyright/ScannerConfig.java
@@ -0,0 +1,377 @@
+// Copyright (C) 2019 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.copyright;
+
+import static java.util.Objects.requireNonNull;
+
+import com.google.common.base.Strings;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.git.validators.CommitValidationMessage;
+import com.google.gerrit.server.git.validators.ValidationMessage;
+import com.google.gerrit.server.project.ProjectConfig;
+import com.googlesource.gerrit.plugins.copyright.lib.CopyrightPatterns;
+import com.googlesource.gerrit.plugins.copyright.lib.CopyrightPatterns.UnknownPatternName;
+import com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.LinkedHashSet;
+import java.util.Objects;
+import java.util.function.Consumer;
+import java.util.regex.Pattern;
+import java.util.regex.PatternSyntaxException;
+
+/** Configuration state for {@link CopyrightValidator}. */
+class ScannerConfig {
+  private static final FluentLogger logger = FluentLogger.forEnclosingClass();
+
+  public static final String KEY_ENABLE = "enable";
+
+  static final String KEY_TIME_TEST_MAX = "timeTestMax";
+  static final String DEFAULT_REVIEW_LABEL = "Copyright-Review";
+  static final String KEY_REVIEWER = "reviewer";
+  static final String KEY_CC = "cc";
+  static final String KEY_FROM = "fromAccountId";
+  static final String KEY_REVIEW_LABEL = "reviewLabel";
+  static final String KEY_EXCLUDE = "exclude";
+  static final String KEY_FIRST_PARTY = "firstParty";
+  static final String KEY_THIRD_PARTY = "thirdParty";
+  static final String KEY_FORBIDDEN = "forbidden";
+  static final String KEY_EXCLUDE_PATTERN = "excludePattern";
+  static final String KEY_FIRST_PARTY_PATTERN = "firstPartyPattern";
+  static final String KEY_THIRD_PARTY_PATTERN = "thirdPartyPattern";
+  static final String KEY_FORBIDDEN_PATTERN = "forbiddenPattern";
+  static final String KEY_ALWAYS_REVIEW_PATH = "alwaysReviewPath";
+  static final String KEY_MATCH_PROJECTS = "matchProjects";
+  static final String KEY_EXCLUDE_PROJECTS = "excludeProjects";
+  static final String KEY_THIRD_PARTY_ALLOWED_PROJECTS = "thirdPartyAllowedProjects";
+  private static final String OWNER = "owner ";
+  private static final String LICENSE = "license ";
+
+  String pluginName;
+  CopyrightScanner scanner;
+  String patternSignature;
+  final ArrayList<CommitValidationMessage> messages;
+  final LinkedHashSet<Pattern> alwaysReviewPath;
+  final LinkedHashSet<Pattern> matchProjects;
+  final LinkedHashSet<Pattern> excludeProjects;
+  final LinkedHashSet<Pattern> thirdPartyAllowedProjects;
+  final LinkedHashSet<String> reviewers;
+  final LinkedHashSet<String> ccs;
+  String reviewLabel;
+  boolean defaultEnable;
+  int fromAccountId;
+
+  ScannerConfig(String pluginName) {
+    this.pluginName = pluginName;
+    this.messages = new ArrayList<>();
+    this.alwaysReviewPath = new LinkedHashSet<>();
+    this.matchProjects = new LinkedHashSet<>();
+    this.excludeProjects = new LinkedHashSet<>();
+    this.thirdPartyAllowedProjects = new LinkedHashSet<>();
+    this.reviewers = new LinkedHashSet<>();
+    this.ccs = new LinkedHashSet<>();
+    this.reviewLabel = DEFAULT_REVIEW_LABEL;
+    this.defaultEnable = false;
+    this.fromAccountId = 0;
+  }
+
+  @Override
+  public boolean equals(Object other) {
+    if (this == other) {
+      return true;
+    }
+    if (other == null) {
+      return false;
+    }
+    if (other instanceof ScannerConfig) {
+      ScannerConfig otherConfig = (ScannerConfig) other;
+      return defaultEnable == otherConfig.defaultEnable
+          && messages.equals(otherConfig.messages)
+          && alwaysReviewPath.equals(otherConfig.alwaysReviewPath)
+          && matchProjects.equals(otherConfig.matchProjects)
+          && excludeProjects.equals(otherConfig.excludeProjects)
+          && thirdPartyAllowedProjects.equals(otherConfig.thirdPartyAllowedProjects)
+          && reviewers.equals(otherConfig.reviewers)
+          && ccs.equals(otherConfig.ccs)
+          && Objects.equals(reviewLabel, otherConfig.reviewLabel)
+          && Objects.equals(scanner, otherConfig.scanner);
+    }
+    return false;
+  }
+
+  @Override
+  public int hashCode() {
+    return Objects.hash(
+        defaultEnable,
+        messages,
+        alwaysReviewPath,
+        matchProjects,
+        excludeProjects,
+        thirdPartyAllowedProjects,
+        reviewers,
+        ccs,
+        reviewLabel,
+        scanner);
+  }
+
+  /** Formats {@code message} into a message about the plugin configuration. */
+  String pluginMessage(String message) {
+    return " in " + ProjectConfig.PROJECT_CONFIG + "\n[plugin \"" + pluginName + "\"]\n" + message;
+  }
+
+  /** Formats {@code message} into a message about the {@code key = value} line of the config. */
+  String pluginKeyValueMessage(String key, String value, String message) {
+    StringBuilder sb = new StringBuilder();
+    sb.append("  ");
+    sb.append(key);
+    sb.append(" = ");
+    sb.append(value.trim());
+    sb.append("\n");
+    sb.append("                                            ".substring(0, key.length() + 5));
+    sb.append("^\n"); // ^ aligned under start of value in message
+    sb.append(message);
+    return pluginMessage(sb.toString());
+  }
+
+  static CommitValidationMessage errorMessage(String message) {
+    return new CommitValidationMessage(message, ValidationMessage.Type.ERROR);
+  }
+
+  static CommitValidationMessage warningMessage(String message) {
+    return new CommitValidationMessage(message, ValidationMessage.Type.WARNING);
+  }
+
+  static CommitValidationMessage hintMessage(String message) {
+    return new CommitValidationMessage(message, ValidationMessage.Type.HINT);
+  }
+
+  /** Formats {@code message} into a message about a required {@code key} not found in config. */
+  String pluginKeyRequired(String key, String message) {
+    return pluginMessage("\nno \"" + key + " =\" key was found.\n\n" + message);
+  }
+
+  /**
+   * Adjusts {@code scannerConfig} state per {@code cfg}.
+   *
+   * @param builder accumulates the scanner pattern rules used for constructing the scanner
+   * @param scannerConfig records the configuration parameter values
+   * @param cfg is the source of the configuration
+   */
+  void readConfigFile(PluginConfig cfg) {
+    CopyrightPatterns.RuleSet.Builder builder = CopyrightPatterns.RuleSet.builder();
+    // can be disabled in All-Projects and then enabled project-by-project
+    defaultEnable = cfg.getBoolean(KEY_ENABLE, defaultEnable);
+    fromAccountId = cfg.getInt(KEY_FROM, 0);
+    addStringList(cfg, reviewers, KEY_REVIEWER, "reviewer email address");
+    addStringList(cfg, ccs, KEY_CC, "CC email address");
+    String key = cfg.getString(KEY_REVIEW_LABEL);
+    if (!Strings.isNullOrEmpty(key)) {
+      reviewLabel = key;
+    }
+    addPatternList(cfg, alwaysReviewPath, KEY_ALWAYS_REVIEW_PATH, "path pattern");
+    addPatternList(cfg, matchProjects, KEY_MATCH_PROJECTS, "project pattern");
+    addPatternList(cfg, excludeProjects, KEY_EXCLUDE_PROJECTS, "project pattern");
+    addPatternList(
+        cfg, thirdPartyAllowedProjects, KEY_THIRD_PARTY_ALLOWED_PROJECTS, "project pattern");
+    // exclusions
+    addRule(
+        cfg,
+        rule -> {
+          builder.exclude(rule);
+        },
+        KEY_EXCLUDE);
+    addRule(
+        cfg,
+        rule -> {
+          builder.excludePattern(rule);
+        },
+        KEY_EXCLUDE_PATTERN);
+    // first-party
+    addRule(
+        cfg,
+        rule -> {
+          builder.addFirstParty(rule);
+        },
+        KEY_FIRST_PARTY);
+    addRulePattern(
+        cfg,
+        pattern -> {
+          builder.addFirstPartyOwner(pattern);
+        },
+        pattern -> {
+          builder.addFirstPartyLicense(pattern);
+        },
+        KEY_FIRST_PARTY_PATTERN);
+    // third-party
+    addRule(
+        cfg,
+        rule -> {
+          builder.addThirdParty(rule);
+        },
+        KEY_THIRD_PARTY);
+    addRulePattern(
+        cfg,
+        pattern -> {
+          builder.addThirdPartyOwner(pattern);
+        },
+        pattern -> {
+          builder.addThirdPartyLicense(pattern);
+        },
+        KEY_THIRD_PARTY_PATTERN);
+    // forbidden
+    addRule(
+        cfg,
+        rule -> {
+          builder.addForbidden(rule);
+        },
+        KEY_FORBIDDEN);
+    addRulePattern(
+        cfg,
+        pattern -> {
+          builder.addForbiddenOwner(pattern);
+        },
+        pattern -> {
+          builder.addForbiddenLicense(pattern);
+        },
+        KEY_FORBIDDEN_PATTERN);
+    if (hasErrors()) {
+      // don't try to compile scanner if errors in configuration
+      return;
+    }
+    CopyrightPatterns.RuleSet rules = builder.build();
+    patternSignature = rules.signature();
+    scanner =
+        new CopyrightScanner(
+            rules.firstPartyLicenses,
+            rules.thirdPartyLicenses,
+            rules.forbiddenLicenses,
+            rules.firstPartyOwners,
+            rules.thirdPartyOwners,
+            rules.forbiddenOwners,
+            rules.excludePatterns);
+  }
+
+  /** Returns true if {@code project} repository allows third-party code. */
+  boolean isThirdPartyAllowed(String project) {
+    return matchesAny(project, thirdPartyAllowedProjects);
+  }
+
+  /** Returns true if {@code fullPath} matches pattern always requiring review. e.g. PATENT */
+  boolean isAlwaysReviewPath(String fullPath) {
+    return matchesAny(fullPath, alwaysReviewPath);
+  }
+
+  /** Returns true if the configuration triggers any error messages. */
+  boolean hasErrors() {
+    return messages.stream().anyMatch(m -> m.getType().equals(ValidationMessage.Type.ERROR));
+  }
+
+  /** Formats and appends config validation messages to {@code sb}. */
+  void appendMessages(StringBuilder sb) {
+    for (CommitValidationMessage msg : messages) {
+      sb.append("\n\n");
+      sb.append(msg.getType().toString());
+      sb.append(" ");
+      sb.append(msg.getMessage());
+    }
+  }
+
+  /** Returns true if any pattern in {@code regexes} found in {@code text}. */
+  static boolean matchesAny(String text, Collection<Pattern> regexes) {
+    requireNonNull(regexes);
+    for (Pattern pattern : regexes) {
+      if (pattern.matcher(text).find()) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  /** Looks up {@code key} in {@code cfg} adding values to {@code dest} as rule names. */
+  private void addRule(PluginConfig cfg, Consumer<String> dest, String key) {
+    for (String rule : cfg.getStringList(key)) {
+      rule = Strings.nullToEmpty(rule).trim();
+      if (rule.isEmpty()) {
+        messages.add(
+            errorMessage(pluginKeyValueMessage(key, rule, "missing license or owner name")));
+        continue;
+      }
+      try {
+        dest.accept(rule);
+      } catch (UnknownPatternName e) {
+        messages.add(errorMessage(pluginKeyValueMessage(key, rule, e.getMessage())));
+      }
+    }
+  }
+
+  /**
+   * Looks up {@code key} in {@code cfg} adding {@code owner} patterns to {@code ownerDest} and
+   * {@code license} patterns to {@code licenseDest}.
+   */
+  private void addRulePattern(
+      PluginConfig cfg, Consumer<String> ownerDest, Consumer<String> licenseDest, String key) {
+    for (String cfgValue : cfg.getStringList(key)) {
+      cfgValue = Strings.nullToEmpty(cfgValue).trim();
+      if (cfgValue.isEmpty()) {
+        messages.add(
+            errorMessage(pluginKeyValueMessage(key, cfgValue, "missing owner or license pattern")));
+        continue;
+      }
+      if (cfgValue.toLowerCase().startsWith(OWNER)) {
+        String pattern = cfgValue.substring(OWNER.length()).trim();
+        ownerDest.accept(pattern);
+      } else if (cfgValue.toLowerCase().startsWith(LICENSE)) {
+        String pattern = cfgValue.substring(LICENSE.length()).trim();
+        licenseDest.accept(pattern);
+      } else {
+        messages.add(
+            errorMessage(
+                pluginKeyValueMessage(
+                    key, cfgValue, "missing 'owner' or 'license' keyword in '" + cfgValue + "'")));
+      }
+    }
+  }
+
+  /** Looks up {@code key} in {@code cfg} adding values to {@code dest} as strings. */
+  private void addStringList(
+      PluginConfig cfg, Collection<String> dest, String key, String shortDesc) {
+    for (String s : cfg.getStringList(key)) {
+      s = Strings.nullToEmpty(s).trim();
+      if (s.isEmpty()) {
+        messages.add(errorMessage(pluginKeyValueMessage(key, s, "missing " + shortDesc)));
+        continue;
+      }
+      dest.add(s);
+    }
+  }
+
+  /** Looks up {@code key} in {@code cfg} adding values to {@code dest} as regex patterns. */
+  private void addPatternList(
+      PluginConfig cfg, Collection<Pattern> dest, String key, String shortDesc) {
+    for (String pattern : cfg.getStringList(key)) {
+      pattern = Strings.nullToEmpty(pattern).trim();
+      if (pattern.isEmpty()) {
+        messages.add(errorMessage(pluginKeyValueMessage(key, pattern, "missing " + shortDesc)));
+        continue;
+      }
+      try {
+        dest.add(Pattern.compile(pattern));
+      } catch (PatternSyntaxException e) {
+        messages.add(errorMessage(pluginKeyValueMessage(key, pattern, e.getMessage())));
+      }
+    }
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/copyright/lib/CopyrightPatterns.java b/src/main/java/com/googlesource/gerrit/plugins/copyright/lib/CopyrightPatterns.java
index 390ffe2..060dffc 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/copyright/lib/CopyrightPatterns.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/copyright/lib/CopyrightPatterns.java
@@ -14,11 +14,15 @@
 
 package com.googlesource.gerrit.plugins.copyright.lib;
 
+import static java.nio.charset.StandardCharsets.UTF_8;
+
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.Joiner;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
+import com.google.common.hash.Hashing;
 import java.util.NoSuchElementException;
+import org.apache.commons.lang.StringUtils;
 
 /** Constants declaring match patterns for common copyright licenses and owners. */
 public abstract class CopyrightPatterns {
@@ -375,6 +379,25 @@
       return new Builder();
     }
 
+    public String signature() {
+      StringBuilder sb = new StringBuilder();
+      sb.append("1pl:\n");
+      sb.append(Joiner.on("\n").join(firstPartyLicenses));
+      sb.append("\n1po:\n");
+      sb.append(Joiner.on("\n").join(firstPartyOwners));
+      sb.append("\n3pl:\n");
+      sb.append(Joiner.on("\n").join(thirdPartyLicenses));
+      sb.append("\n3po:\n");
+      sb.append(Joiner.on("\n").join(thirdPartyOwners));
+      sb.append("\n!!l:\n");
+      sb.append(Joiner.on("\n").join(forbiddenLicenses));
+      sb.append("\n!!o:\n");
+      sb.append(Joiner.on("\n").join(forbiddenOwners));
+      sb.append("\nxx:\n");
+      sb.append(Joiner.on("\n").join(excludePatterns));
+      return Hashing.farmHashFingerprint64().hashBytes(sb.toString().getBytes(UTF_8)).toString();
+    }
+
     /** Implements the Builder pattern for CopyrightPatterns.RuleSet. */
     public static class Builder {
       private final ImmutableList.Builder<String> firstPartyLicenses =
@@ -408,7 +431,7 @@
       public Builder addFirstParty(String ruleName) {
         Rule pattern = lookup.get(ruleName);
         if (pattern == null) {
-          throw new UnknownPatternName(ruleName);
+          throw unknownPatternName(ruleName);
         }
         if (pattern.licenses != null) {
           firstPartyLicenses.addAll(pattern.licenses);
@@ -438,7 +461,7 @@
       public Builder addThirdParty(String ruleName) {
         Rule pattern = lookup.get(ruleName);
         if (pattern == null) {
-          throw new UnknownPatternName(ruleName);
+          throw unknownPatternName(ruleName);
         }
         if (pattern.licenses != null) {
           thirdPartyLicenses.addAll(pattern.licenses);
@@ -468,7 +491,7 @@
       public Builder addForbidden(String ruleName) {
         Rule pattern = lookup.get(ruleName);
         if (pattern == null) {
-          throw new UnknownPatternName(ruleName);
+          throw unknownPatternName(ruleName);
         }
         if (pattern.licenses != null) {
           forbiddenLicenses.addAll(pattern.licenses);
@@ -498,7 +521,7 @@
       public Builder exclude(String ruleName) {
         Rule pattern = lookup.get(ruleName);
         if (pattern == null) {
-          throw new UnknownPatternName(ruleName);
+          throw unknownPatternName(ruleName);
         }
         if (pattern.licenses != null) {
           excludePatterns.addAll(pattern.licenses);
@@ -535,6 +558,41 @@
       this.forbiddenOwners = forbiddenOwners;
       this.excludePatterns = excludePatterns;
     }
+
+    private static UnknownPatternName unknownPatternName(String ruleName) {
+      int minDist = -1;
+      for (String key : lookup.keySet()) {
+        int dist = StringUtils.getLevenshteinDistance(key, ruleName);
+        if (minDist < 0 || dist < minDist) {
+          minDist = dist;
+        }
+      }
+      ImmutableList.Builder<String> closeMatches = ImmutableList.builder();
+      int numClose = 0;
+      if (minDist < 3) {
+        for (String key : lookup.keySet()) {
+          int dist = StringUtils.getLevenshteinDistance(key, ruleName);
+          if (dist == minDist) {
+            closeMatches.add(key);
+            numClose++;
+          }
+        }
+      }
+      String matches = Joiner.on(", ").join(numClose > 0 ? closeMatches.build() : lookup.keySet());
+      int lastIndex = matches.lastIndexOf(", ");
+      if (lastIndex > 0) {
+        matches = matches.substring(0, lastIndex + 2) + "or " + matches.substring(lastIndex + 2);
+      }
+      String message =
+          "Unknown license or copyright owner name: "
+              + ruleName
+              + "\n\n"
+              + (numClose > 0
+                  ? "Did you mean " + matches + "?"
+                  : "Known names are: " + matches + ".");
+
+      return new UnknownPatternName(message);
+    }
   }
 
   /** Initialize a pattern consisting of only a list of owner patterns. */
@@ -584,12 +642,8 @@
 
   /** Thrown when requesting a pattern by a name that does not appear among the known patterns. */
   public static class UnknownPatternName extends NoSuchElementException {
-    UnknownPatternName(String ruleName) {
-      super(
-          "Unknown pattern name: "
-              + ruleName
-              + "\nKnown pattern names include: "
-              + Joiner.on(", ").join(lookup.keySet()));
+    UnknownPatternName(String message) {
+      super(message);
     }
   }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/copyright/CopyrightConfigIT.java b/src/test/java/com/googlesource/gerrit/plugins/copyright/CopyrightConfigIT.java
new file mode 100644
index 0000000..fb5bcd6
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/copyright/CopyrightConfigIT.java
@@ -0,0 +1,398 @@
+// Copyright (C) 2019 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.copyright;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.googlesource.gerrit.plugins.copyright.ScannerConfig.KEY_CC;
+import static com.googlesource.gerrit.plugins.copyright.ScannerConfig.KEY_FIRST_PARTY;
+import static com.googlesource.gerrit.plugins.copyright.ScannerConfig.KEY_FIRST_PARTY_PATTERN;
+import static com.googlesource.gerrit.plugins.copyright.ScannerConfig.KEY_FROM;
+import static com.googlesource.gerrit.plugins.copyright.ScannerConfig.KEY_REVIEWER;
+import static com.googlesource.gerrit.plugins.copyright.ScannerConfig.KEY_REVIEW_LABEL;
+import static com.googlesource.gerrit.plugins.copyright.ScannerConfig.KEY_THIRD_PARTY_PATTERN;
+import static com.googlesource.gerrit.plugins.copyright.TestConfig.LOCAL_BRANCH;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.truth.Correspondence;
+import com.google.gerrit.acceptance.GerritConfig;
+import com.google.gerrit.acceptance.GerritConfigs;
+import com.google.gerrit.acceptance.GitUtil;
+import com.google.gerrit.acceptance.LightweightPluginDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.acceptance.TestPlugin;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.ServerInitiated;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.group.db.GroupsUpdate;
+import com.google.gerrit.server.group.db.InternalGroupCreation;
+import com.google.gerrit.server.group.db.InternalGroupUpdate;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import java.util.Optional;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.junit.Before;
+import org.junit.Test;
+
+@TestPlugin(name = "copyright", sysModule = "com.googlesource.gerrit.plugins.copyright.Module")
+public class CopyrightConfigIT extends LightweightPluginDaemonTest {
+  @Test
+  @GerritConfigs({
+    @GerritConfig(name = "plugin.copyright.enable", value = "true"),
+    @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+  })
+  public void testCopyrightConfig_pushGoodConfig() throws Exception {
+    PushOneCommit.Result actual = testConfig.push(pushFactory);
+    actual.assertOkStatus();
+    assertThat(actual.getChange().publishedComments()).isEmpty();
+  }
+
+  @Test
+  @GerritConfigs({
+    @GerritConfig(name = "plugin.copyright.enable", value = "true"),
+    @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+  })
+  public void testCopyrightConfig_pushWithoutSender() throws Exception {
+    testConfig.updatePlugin(
+        cfg -> {
+          cfg.setString(KEY_FROM, "");
+        });
+
+    testConfig.push(pushFactory).assertErrorStatus("no \"" + KEY_FROM + " =\" key was found");
+  }
+
+  @Test
+  @GerritConfigs({
+    @GerritConfig(name = "plugin.copyright.enable", value = "false"),
+    @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+  })
+  public void testCopyrightConfig_pushDisabledWithoutSender() throws Exception {
+    testConfig.updatePlugin(
+        cfg -> {
+          cfg.setString(KEY_FROM, "");
+        });
+
+    PushOneCommit.Result actual = testConfig.push(pushFactory);
+    String expected = "no \"" + KEY_FROM + " =\" key was found";
+    actual.assertMessage(expected);
+    actual.assertOkStatus();
+    assertThat(actual.getChange().publishedComments())
+        .comparingElementsUsing(warningContains())
+        .containsExactly(expected);
+  }
+
+  @Test
+  @GerritConfigs({
+    @GerritConfig(name = "plugin.copyright.enable", value = "true"),
+    @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+  })
+  public void testCopyrightConfig_pushWithSenderNoName() throws Exception {
+    TestAccount anonymous =
+        accountCreator.create(
+            "copyright-scan", "", "", "Non-Interactive Users", expertGroup.getName());
+    testConfig.updatePlugin(
+        cfg -> {
+          cfg.setInt(KEY_FROM, anonymous.id().get());
+        });
+
+    PushOneCommit.Result actual = testConfig.push(pushFactory);
+    String expected = "has no full name";
+    actual.assertMessage(expected);
+    actual.assertOkStatus();
+    assertThat(actual.getChange().publishedComments())
+        .comparingElementsUsing(warningContains())
+        .containsExactly(expected);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+  public void testCopyrightConfig_pushWithNonNumericSender() throws Exception {
+    testConfig.updatePlugin(
+        cfg -> {
+          cfg.setString(KEY_FROM, "some random string");
+        });
+
+    testConfig.push(pushFactory).assertErrorStatus();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+  public void testCopyrightConfig_pushWithoutReviewers() throws Exception {
+    testConfig.updatePlugin(
+        cfg -> {
+          cfg.setStringList(KEY_REVIEWER, ImmutableList.of());
+        });
+
+    PushOneCommit.Result actual = testConfig.push(pushFactory);
+    String expected = "no \"" + KEY_REVIEWER + " =\" key was found";
+    actual.assertMessage(expected);
+    actual.assertOkStatus();
+    assertThat(actual.getChange().publishedComments())
+        .comparingElementsUsing(warningContains())
+        .containsExactly(expected);
+  }
+
+  @Test
+  @GerritConfigs({
+    @GerritConfig(name = "plugin.copyright.enable", value = "true"),
+    @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+  })
+  public void testCopyrightConfig_pushWithoutLabelConfig() throws Exception {
+    testConfig.removeLabel("Copyright-Review");
+
+    testConfig
+        .push(pushFactory)
+        .assertErrorStatus("no [label \"Copyright-Review\"] section configured");
+  }
+
+  @Test
+  @GerritConfigs({
+    @GerritConfig(name = "plugin.copyright.enable", value = "true"),
+    @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+  })
+  public void testCopyrightConfig_pushWithoutVoters() throws Exception {
+    testConfig.removeVoters(RefNames.REFS_HEADS + "*", "Copyright-Review");
+
+    testConfig.push(pushFactory).assertErrorStatus("no configured approvers");
+  }
+
+  @Test
+  @GerritConfigs({
+    @GerritConfig(name = "plugin.copyright.enable", value = "true"),
+    @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+  })
+  public void testCopyrightConfig_pushWithoutConfigVoters() throws Exception {
+    testConfig.removeVoters(RefNames.REFS_CONFIG, "Copyright-Review");
+
+    testConfig.push(pushFactory).assertErrorStatus("no configured approvers");
+  }
+
+  @Test
+  @GerritConfigs({
+    @GerritConfig(name = "plugin.copyright.enable", value = "true"),
+    @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+  })
+  public void testCopyrightConfig_pushWithoutReviewLabel() throws Exception {
+    testConfig.updatePlugin(
+        cfg -> {
+          cfg.setString(KEY_REVIEW_LABEL, " ");
+        });
+
+    testConfig
+        .push(pushFactory)
+        .assertErrorStatus("no \"" + KEY_REVIEW_LABEL + " =\" key was found");
+  }
+
+  @Test
+  @GerritConfigs({
+    @GerritConfig(name = "plugin.copyright.enable", value = "false"),
+    @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+  })
+  public void testCopyrightConfig_pushDisabledWithoutReviewLabel() throws Exception {
+    testConfig.updatePlugin(
+        cfg -> {
+          cfg.setString(KEY_REVIEW_LABEL, " ");
+        });
+
+    PushOneCommit.Result actual = testConfig.push(pushFactory);
+    String expected = "no \"" + KEY_REVIEW_LABEL + " =\" key was found";
+    actual.assertMessage(expected);
+    actual.assertOkStatus();
+    assertThat(actual.getChange().publishedComments())
+        .comparingElementsUsing(warningContains())
+        .containsExactly(expected);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+  public void testCopyrightConfig_pushPatternWithCapture() throws Exception {
+    testConfig.updatePlugin(
+        cfg -> {
+          cfg.setString(KEY_FIRST_PARTY_PATTERN, "owner (capture group)");
+        });
+
+    testConfig.push(pushFactory).assertErrorStatus();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+  public void testCopyrightConfig_pushPatternWithNonCapture() throws Exception {
+    testConfig.updatePlugin(
+        cfg -> {
+          cfg.setString(KEY_THIRD_PARTY_PATTERN, "owner (?:non-capture group)");
+        });
+
+    PushOneCommit.Result actual = testConfig.push(pushFactory);
+    actual.assertOkStatus();
+    assertThat(actual.getChange().publishedComments()).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+  public void testCopyrightConfig_pushPatternWithOpenNonCapture() throws Exception {
+    testConfig.updatePlugin(
+        cfg -> {
+          cfg.setString(KEY_THIRD_PARTY_PATTERN, "owner (?:non-capture group");
+        });
+
+    testConfig.push(pushFactory).assertErrorStatus();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+  public void testCopyrightConfig_pushPatternWithUnknownRuleName() throws Exception {
+    testConfig.updatePlugin(
+        cfg -> {
+          cfg.setString(KEY_FIRST_PARTY, "not a valid rule name");
+        });
+
+    testConfig.push(pushFactory).assertErrorStatus("Unknown license or copyright owner name");
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.copyright.timeTestMax", value = "0")
+  public void testCopyrightConfig_pushPatternWithOpenCharacterClass() throws Exception {
+    testConfig.updatePlugin(
+        cfg -> {
+          cfg.setString(KEY_FIRST_PARTY_PATTERN, "license non-terminated [");
+        });
+
+    testConfig.push(pushFactory).assertErrorStatus();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.copyright.timeTestMax", value = "1")
+  public void testCopyrightConfig_pushPatternWithoutTimeSignature() throws Exception {
+    testConfig.push(pushFactory).assertErrorStatus("please run check_new_config tool");
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.copyright.timeTestMax", value = "1")
+  public void testCopyrightConfig_pushPatternWithWrongTimeSignature() throws Exception {
+    testConfig.commitMessage("Copyright-check: b0bb4.3141596634344624");
+    testConfig.push(pushFactory).assertErrorStatus("results for wrong commit");
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.copyright.timeTestMax", value = "8")
+  public void testCopyrightConfig_pushPatternWithRightTimeSignature() throws Exception {
+    testConfig.commitMessage("Copyright-check: b0bb4.40bd43852e4bcf12");
+    testConfig.push(pushFactory).assertOkStatus();
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.copyright.timeTestMax", value = "8")
+  public void testCopyrightConfig_pushPatternWithTooLongTimeSignature() throws Exception {
+    testConfig.commitMessage("Copyright-check: 7a1201.677dcd085b30b4e1");
+    testConfig.push(pushFactory).assertErrorStatus("took longer than");
+  }
+
+  private static int nextId = 123;
+
+  @Inject @ServerInitiated private Provider<GroupsUpdate> groupsUpdateProvider;
+
+  private TestAccount sender;
+  private TestAccount reviewer;
+  private TestAccount observer;
+  private InternalGroup botGroup;
+  private InternalGroup expertGroup;
+  private TestConfig testConfig;
+
+  @Before
+  public void setUp() throws Exception {
+    botGroup = testGroup("Non-Interactive Users");
+    expertGroup = testGroup("Copyright Experts");
+    sender =
+        accountCreator.create(
+            "copyright-scanner",
+            "copyright-scanner@example.com",
+            "Copyright Scanner",
+            "Non-Interactive Users",
+            expertGroup.getName());
+    reviewer =
+        accountCreator.create(
+            "lawyercat", "legal@example.com", "J. Doe J.D. LL.M. Esq.", expertGroup.getName());
+    observer = accountCreator.create("my-team", "my-team@example.com", "My Team");
+    testRepo = getTestRepo(allProjects);
+    testConfig = new TestConfig(allProjects, plugin.getName(), admin, testRepo);
+    testConfig.copyLabel("Code-Review", "Copyright-Review");
+    testConfig.setVoters(
+        RefNames.REFS_HEADS + "*",
+        "Copyright-Review",
+        new TestConfig.Voter("Administrators", -2, +2),
+        new TestConfig.Voter(expertGroup.getNameKey().get(), -2, +2),
+        new TestConfig.Voter("Registered Users", -2, 0));
+    testConfig.addGroups(botGroup, expertGroup);
+    testConfig.updatePlugin(
+        TestConfig.BASE_CONFIG,
+        TestConfig.ENABLE_CONFIG,
+        cfg -> {
+          cfg.setStringList(KEY_REVIEWER, ImmutableList.of(reviewer.username()));
+        },
+        cfg -> {
+          cfg.setStringList(KEY_CC, ImmutableList.of(observer.username()));
+        },
+        cfg -> {
+          cfg.setInt(KEY_FROM, sender.id().get());
+        });
+  }
+
+  private AccountGroup.Id nextGroupId() {
+    return new AccountGroup.Id(nextId++);
+  }
+
+  private TestRepository<InMemoryRepository> getTestRepo(Project.NameKey projectName)
+      throws Exception {
+    TestRepository<InMemoryRepository> testRepo = cloneProject(projectName, admin);
+    GitUtil.fetch(testRepo, RefNames.REFS_CONFIG + ":" + LOCAL_BRANCH);
+    testRepo.reset(LOCAL_BRANCH);
+    return testRepo;
+  }
+
+  private InternalGroup testGroup(String name) throws Exception {
+    AccountGroup.NameKey nameKey = new AccountGroup.NameKey(name);
+    Optional<InternalGroup> g = groupCache.get(nameKey);
+    if (g.isPresent()) {
+      return g.get();
+    }
+    GroupsUpdate groupsUpdate = groupsUpdateProvider.get();
+    InternalGroupCreation gc =
+        InternalGroupCreation.builder()
+            .setGroupUUID(new AccountGroup.UUID("users-" + name.replace(" ", "_")))
+            .setNameKey(nameKey)
+            .setId(nextGroupId())
+            .build();
+    InternalGroupUpdate gu = InternalGroupUpdate.builder().setName(nameKey).build();
+    return groupsUpdate.createGroup(gc, gu);
+  }
+
+  private static Correspondence<Comment, String> warningContains() {
+    return new Correspondence<Comment, String>() {
+      @Override
+      public boolean compare(Comment actual, String expected) {
+        return actual.message.startsWith("WARNING ") && actual.message.contains(expected);
+      }
+
+      @Override
+      public String toString() {
+        return "matches regex";
+      }
+    };
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/copyright/CopyrightReviewApiTest.java b/src/test/java/com/googlesource/gerrit/plugins/copyright/CopyrightReviewApiTest.java
index 4c0b4b6..2c1cda7 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/copyright/CopyrightReviewApiTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/copyright/CopyrightReviewApiTest.java
@@ -18,15 +18,14 @@
 import static com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.MatchType.AUTHOR_OWNER;
 import static com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.MatchType.LICENSE;
 import static com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType.FIRST_PARTY;
-import static com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType.THIRD_PARTY;
 import static com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType.FORBIDDEN;
+import static com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType.THIRD_PARTY;
 import static com.googlesource.gerrit.plugins.copyright.lib.CopyrightScanner.PartyType.UNKNOWN;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.eq;
 import static org.mockito.Mockito.never;
 import static org.mockito.Mockito.times;
 import static org.mockito.Mockito.verify;
-import static org.mockito.Mockito.when;
 
 import com.google.common.collect.ImmutableList;
 import com.google.common.truth.Correspondence;
@@ -57,16 +56,17 @@
 
   @Before
   public void setUp() throws Exception {
-    reviewApi = new CopyrightReviewApi(
-        null,
-        () -> pluginUser,
-        () -> currentUser,
-        identifiedUserFactory,
-        null,
-        null,
-        null,
-        null,
-        null);
+    reviewApi =
+        new CopyrightReviewApi(
+            null,
+            () -> pluginUser,
+            () -> currentUser,
+            identifiedUserFactory,
+            null,
+            null,
+            null,
+            null,
+            null);
   }
 
   @Test
@@ -97,9 +97,7 @@
   public void testAddReviewers_addCC() throws Exception {
     ReviewInput ri = new ReviewInput();
     ri = reviewApi.addReviewers(ri, ImmutableList.of("someone"), ReviewerState.CC);
-    assertThat(ri.reviewers)
-        .comparingElementsUsing(addressedTo())
-        .containsExactly("CC:someone");
+    assertThat(ri.reviewers).comparingElementsUsing(addressedTo()).containsExactly("CC:someone");
   }
 
   @Test
@@ -114,8 +112,9 @@
   @Test
   public void testAddReviewers_addMultiple() throws Exception {
     ReviewInput ri = new ReviewInput();
-    ri = reviewApi.addReviewers(
-        ri, ImmutableList.of("someone", "someone else"), ReviewerState.REVIEWER);
+    ri =
+        reviewApi.addReviewers(
+            ri, ImmutableList.of("someone", "someone else"), ReviewerState.REVIEWER);
     ri = reviewApi.addReviewers(ri, ImmutableList.of("another", "and another"), ReviewerState.CC);
     assertThat(ri.reviewers)
         .comparingElementsUsing(addressedTo())
@@ -164,10 +163,8 @@
 
   @Test
   public void testContainsComment_multipleDoContain() throws Exception {
-    ImmutableList<CommentInput> comments = ImmutableList.of(
-        CI("one", 1, 2),
-        CI("two", 806, 808),
-        CI("three", 3, 14));
+    ImmutableList<CommentInput> comments =
+        ImmutableList.of(CI("one", 1, 2), CI("two", 806, 808), CI("three", 3, 14));
     assertThat(reviewApi.containsComment(comments, CI("three", 3, 14))).isTrue();
     assertThat(reviewApi.containsComment(comments, CI("two", 806, 808))).isTrue();
     assertThat(reviewApi.containsComment(comments, CI("one", 1, 2))).isTrue();
@@ -175,183 +172,157 @@
 
   @Test
   public void testContainsComment_multipleDoNotContain() throws Exception {
-    ImmutableList<CommentInput> comments = ImmutableList.of(
-        CI("one", 1, 2),
-        CI("two", 806, 808),
-        CI("three", 3, 14));
+    ImmutableList<CommentInput> comments =
+        ImmutableList.of(CI("one", 1, 2), CI("two", 806, 808), CI("three", 3, 14));
     assertThat(reviewApi.containsComment(comments, CI("four", 806, 808))).isFalse();
   }
 
   @Test
   public void testReviewComments_firstParty() throws Exception {
     assertThat(
-        reviewApi.reviewComments(
-            "project",
-            FIRST_PARTY, // 1p license with 3p author is 1p license
-            false,
-            ImmutableList.of(lic1p(2), auth1p(3), lic1p(4), lic1p(120), auth3p(121), auth1p(122))
-        ))
-            .comparingElementsUsing(startsWithAndRangesMatch())
-            .containsExactly(
-                CI("First-party license :", 2, 4),
-                CI("First-party license :", 120, 120),
-                CI("Third-party author or owner :", 121, 121),
-                CI("First-party author or owner :", 122, 122));
+            reviewApi.reviewComments(
+                "project",
+                FIRST_PARTY, // 1p license with 3p author is 1p license
+                false,
+                ImmutableList.of(
+                    lic1p(2), auth1p(3), lic1p(4), lic1p(120), auth3p(121), auth1p(122))))
+        .comparingElementsUsing(startsWithAndRangesMatch())
+        .containsExactly(
+            CI("First-party license :", 2, 4),
+            CI("First-party license :", 120, 120),
+            CI("Third-party author or owner :", 121, 121),
+            CI("First-party author or owner :", 122, 122));
   }
 
   @Test
   public void testReviewComments_thirdPartyAllowed() throws Exception {
     assertThat(
-        reviewApi.reviewComments(
-            "project",
-            THIRD_PARTY, // 3p license and 1p license or author is 3p
-            true,
-            ImmutableList.of(lic3p(2), auth3p(3), lic3p(10), auth3p(200), auth3p(210), lic1p(211))
-        ))
-            .comparingElementsUsing(startsWithAndRangesMatch())
-            .containsExactly(
-                CI("Third-party license allowed", 2, 10),
-                CI("Third-party author or owner allowed", 200, 210),
-                CI("First-party license :", 211, 211));
+            reviewApi.reviewComments(
+                "project",
+                THIRD_PARTY, // 3p license and 1p license or author is 3p
+                true,
+                ImmutableList.of(
+                    lic3p(2), auth3p(3), lic3p(10), auth3p(200), auth3p(210), lic1p(211))))
+        .comparingElementsUsing(startsWithAndRangesMatch())
+        .containsExactly(
+            CI("Third-party license allowed", 2, 10),
+            CI("Third-party author or owner allowed", 200, 210),
+            CI("First-party license :", 211, 211));
   }
 
   @Test
   public void testReviewComments_thirdPartyNotAllowed() throws Exception {
     assertThat(
-        reviewApi.reviewComments(
-            "project",
-            THIRD_PARTY, // 3p license and 1p license or author is 3p
-            false,
-            ImmutableList.of(lic3p(2), auth3p(3), lic3p(10), auth3p(200), auth3p(210), auth1p(211))
-        ))
-            .comparingElementsUsing(startsWithAndRangesMatch())
-            .containsExactly(
-                CI("Third-party license disallowed", 2, 10),
-                CI("Third-party author or owner disallowed", 200, 210),
-                CI("First-party author or owner :", 211, 211));
+            reviewApi.reviewComments(
+                "project",
+                THIRD_PARTY, // 3p license and 1p license or author is 3p
+                false,
+                ImmutableList.of(
+                    lic3p(2), auth3p(3), lic3p(10), auth3p(200), auth3p(210), auth1p(211))))
+        .comparingElementsUsing(startsWithAndRangesMatch())
+        .containsExactly(
+            CI("Third-party license disallowed", 2, 10),
+            CI("Third-party author or owner disallowed", 200, 210),
+            CI("First-party author or owner :", 211, 211));
   }
 
   @Test
   public void testReviewComments_forbiddenAuthor() throws Exception {
     assertThat(
-        reviewApi.reviewComments(
-            "project",
-            FORBIDDEN, // forbidden author and anything else is still forbidden
-            false,
-            ImmutableList.of(lic1p(2), auth1p(3), lic1p(4), lic1p(120), authForbidden(121))
-        ))
-            .comparingElementsUsing(startsWithAndRangesMatch())
-            .containsExactly(
-                CI("First-party license :", 2, 4),
-                CI("First-party license :", 120, 120),
-                CI("Disapproved author or owner :", 121, 121));
+            reviewApi.reviewComments(
+                "project",
+                FORBIDDEN, // forbidden author and anything else is still forbidden
+                false,
+                ImmutableList.of(lic1p(2), auth1p(3), lic1p(4), lic1p(120), authForbidden(121))))
+        .comparingElementsUsing(startsWithAndRangesMatch())
+        .containsExactly(
+            CI("First-party license :", 2, 4),
+            CI("First-party license :", 120, 120),
+            CI("Disapproved author or owner :", 121, 121));
   }
 
   @Test
   public void testReviewComments_forbiddenLicense() throws Exception {
     assertThat(
-        reviewApi.reviewComments(
-            "project",
-            FORBIDDEN, // forbidden license and anything else is still forbidden
-            false,
-            ImmutableList.of(lic1p(2), auth1p(3), lic1p(4), lic1p(120), licForbidden(121))
-        ))
-            .comparingElementsUsing(startsWithAndRangesMatch())
-            .containsExactly(
-                CI("First-party license :", 2, 4),
-                CI("First-party license :", 120, 120),
-                CI("Disapproved license :", 121, 121));
+            reviewApi.reviewComments(
+                "project",
+                FORBIDDEN, // forbidden license and anything else is still forbidden
+                false,
+                ImmutableList.of(lic1p(2), auth1p(3), lic1p(4), lic1p(120), licForbidden(121))))
+        .comparingElementsUsing(startsWithAndRangesMatch())
+        .containsExactly(
+            CI("First-party license :", 2, 4),
+            CI("First-party license :", 120, 120),
+            CI("Disapproved license :", 121, 121));
   }
 
   @Test
   public void testReviewComments_unknownLicense() throws Exception {
     assertThat(
-        reviewApi.reviewComments(
-            "project",
-            FORBIDDEN, // an unknown license could be forbidden so always requires review
-            false,
-            ImmutableList.of(lic1p(2), auth1p(3), lic1p(4), lic1p(120), licUnknown(121))
-        ))
-            .comparingElementsUsing(startsWithAndRangesMatch())
-            .containsExactly(
-                CI("First-party license :", 2, 4),
-                CI("First-party license :", 120, 120),
-                CI("Unrecognized license :", 121, 121));
+            reviewApi.reviewComments(
+                "project",
+                FORBIDDEN, // an unknown license could be forbidden so always requires review
+                false,
+                ImmutableList.of(lic1p(2), auth1p(3), lic1p(4), lic1p(120), licUnknown(121))))
+        .comparingElementsUsing(startsWithAndRangesMatch())
+        .containsExactly(
+            CI("First-party license :", 2, 4),
+            CI("First-party license :", 120, 120),
+            CI("Unrecognized license :", 121, 121));
   }
 
   @Test
   public void testPartyType_firstPartyLicense() throws Exception {
     // 1p license with 3p author is 1p in open-source
-    assertThat(
-        reviewApi.partyType(ImmutableList.of(lic1p(1), auth3p(2), lic1p(3)))
-    ).isEqualTo(FIRST_PARTY);
+    assertThat(reviewApi.partyType(ImmutableList.of(lic1p(1), auth3p(2), lic1p(3))))
+        .isEqualTo(FIRST_PARTY);
   }
 
   @Test
   public void testPartyType_firstPartyOwner() throws Exception {
-    assertThat(
-        reviewApi.partyType(ImmutableList.of(auth1p(1), auth1p(2)))
-    ).isEqualTo(FIRST_PARTY);
+    assertThat(reviewApi.partyType(ImmutableList.of(auth1p(1), auth1p(2)))).isEqualTo(FIRST_PARTY);
   }
 
   @Test
   public void testPartyType_thirdPartyLicense() throws Exception {
     // 3p license with 1p license or author is 3p
-    assertThat(
-        reviewApi.partyType(ImmutableList.of(lic3p(1), lic1p(3), auth1p(4)))
-    ).isEqualTo(THIRD_PARTY);
+    assertThat(reviewApi.partyType(ImmutableList.of(lic3p(1), lic1p(3), auth1p(4))))
+        .isEqualTo(THIRD_PARTY);
   }
 
   @Test
   public void testPartyType_thirdPartyOwner() throws Exception {
     // 3p author and 1p author without any license is 3p
-    assertThat(
-        reviewApi.partyType(ImmutableList.of(auth3p(1), auth1p(2)))
-    ).isEqualTo(THIRD_PARTY);
+    assertThat(reviewApi.partyType(ImmutableList.of(auth3p(1), auth1p(2)))).isEqualTo(THIRD_PARTY);
   }
 
   @Test
   public void testPartyType_forbiddenLicense() throws Exception {
     // forbidden anything with anything else in any combination is forbidden
     assertThat(
-        reviewApi.partyType(
-            ImmutableList.of(
-                licForbidden(1),
-                licUnknown(2),
-                lic3p(3),
-                auth3p(4),
-                lic1p(5),
-                auth1p(6)))
-    ).isEqualTo(FORBIDDEN);
+            reviewApi.partyType(
+                ImmutableList.of(
+                    licForbidden(1), licUnknown(2), lic3p(3), auth3p(4), lic1p(5), auth1p(6))))
+        .isEqualTo(FORBIDDEN);
   }
 
   @Test
   public void testPartyType_forbiddenOwner() throws Exception {
     // forbidden anything with anything else in any combination is forbidden
     assertThat(
-        reviewApi.partyType(
-            ImmutableList.of(
-                authForbidden(1),
-                licUnknown(2),
-                lic3p(3),
-                auth3p(4),
-                lic1p(5),
-                auth1p(6)))
-    ).isEqualTo(FORBIDDEN);
+            reviewApi.partyType(
+                ImmutableList.of(
+                    authForbidden(1), licUnknown(2), lic3p(3), auth3p(4), lic1p(5), auth1p(6))))
+        .isEqualTo(FORBIDDEN);
   }
 
   @Test
   public void testPartyType_unknownLicense() throws Exception {
     // unknown license with anything but forbidden is unknown (possibly forbidden)
     assertThat(
-        reviewApi.partyType(
-            ImmutableList.of(
-                licUnknown(2),
-                lic3p(3),
-                auth3p(4),
-                lic1p(5),
-                auth1p(6)))
-    ).isEqualTo(UNKNOWN);
+            reviewApi.partyType(
+                ImmutableList.of(licUnknown(2), lic3p(3), auth3p(4), lic1p(5), auth1p(6))))
+        .isEqualTo(UNKNOWN);
   }
 
   private static class FakePluginUser extends PluginUser {
@@ -365,6 +336,7 @@
     public Object getCacheKey() {
       return "31415966";
     }
+
     @Override
     public GroupMembership getEffectiveGroups() {
       return null;
@@ -373,32 +345,32 @@
 
   private static Correspondence<CommentInput, CommentInput> startsWithAndRangesMatch() {
     return new Correspondence<CommentInput, CommentInput>() {
-        @Override
-        public boolean compare(CommentInput actual, CommentInput expected) {
-          return actual.range.startLine == expected.range.startLine
-              && actual.range.endLine == expected.range.endLine
-              && actual.message.startsWith(expected.message);
-        }
+      @Override
+      public boolean compare(CommentInput actual, CommentInput expected) {
+        return actual.range.startLine == expected.range.startLine
+            && actual.range.endLine == expected.range.endLine
+            && actual.message.startsWith(expected.message);
+      }
 
-        @Override
-        public String toString() {
-          return "starts with and ranges match";
-        }
-      };
+      @Override
+      public String toString() {
+        return "starts with and ranges match";
+      }
+    };
   }
 
   private static Correspondence<AddReviewerInput, String> addressedTo() {
     return new Correspondence<AddReviewerInput, String>() {
-        @Override
-        public boolean compare(AddReviewerInput actual, String expected) {
-          return expected.equals(actual.state().toString() + ":" + actual.reviewer);
-        }
+      @Override
+      public boolean compare(AddReviewerInput actual, String expected) {
+        return expected.equals(actual.state().toString() + ":" + actual.reviewer);
+      }
 
-        @Override
-        public String toString() {
-          return "addressed to";
-        }
-      };
+      @Override
+      public String toString() {
+        return "addressed to";
+      }
+    };
   }
 
   /** Comment input {@code text} from {@code start} line to {@code end} line. */
diff --git a/src/test/java/com/googlesource/gerrit/plugins/copyright/TestConfig.java b/src/test/java/com/googlesource/gerrit/plugins/copyright/TestConfig.java
new file mode 100644
index 0000000..a8fc2c5
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/copyright/TestConfig.java
@@ -0,0 +1,273 @@
+// Copyright (C) 2019 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.copyright;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.googlesource.gerrit.plugins.copyright.ScannerConfig.KEY_ENABLE;
+import static com.googlesource.gerrit.plugins.copyright.ScannerConfig.KEY_EXCLUDE;
+import static com.googlesource.gerrit.plugins.copyright.ScannerConfig.KEY_FIRST_PARTY;
+import static com.googlesource.gerrit.plugins.copyright.ScannerConfig.KEY_FORBIDDEN;
+import static com.googlesource.gerrit.plugins.copyright.ScannerConfig.KEY_FORBIDDEN_PATTERN;
+import static com.googlesource.gerrit.plugins.copyright.ScannerConfig.KEY_REVIEW_LABEL;
+import static com.googlesource.gerrit.plugins.copyright.ScannerConfig.KEY_THIRD_PARTY;
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.common.data.GroupReference;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.server.config.PluginConfig;
+import com.google.gerrit.server.group.InternalGroup;
+import com.google.gerrit.server.project.GroupList;
+import com.google.gerrit.server.project.ProjectConfig;
+import java.util.Arrays;
+import java.util.function.Consumer;
+import org.eclipse.jgit.internal.storage.dfs.InMemoryRepository;
+import org.eclipse.jgit.junit.TestRepository;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.lib.ObjectLoader;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.lib.Ref;
+import org.eclipse.jgit.revwalk.RevBlob;
+import org.eclipse.jgit.revwalk.RevObject;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+/** Test helper for copyright plugin All-Projects configurations. */
+class TestConfig {
+  static final String LOCAL_BRANCH = "config";
+
+  private static final String ACCESS = "access";
+  private static final String LABEL = "label";
+  private static final String PLUGIN = "plugin";
+
+  private Project.NameKey project;
+  private String pluginName;
+  private TestAccount owner;
+  private TestRepository<InMemoryRepository> testRepo;
+  private Config configProject;
+  private Config configPlugin;
+  private PluginConfig pluginConfig;
+  private GroupList groupList;
+  private String commitMsg;
+
+  TestConfig(
+      Project.NameKey project,
+      String pluginName,
+      TestAccount owner,
+      TestRepository<InMemoryRepository> testRepo)
+      throws Exception {
+    this.project = project;
+    this.pluginName = pluginName;
+    this.owner = owner;
+    this.testRepo = testRepo;
+    readConfigProject();
+    extractPlugin();
+    this.commitMsg = "This is a commit.";
+  }
+
+  static final Consumer<PluginConfig> BASE_CONFIG =
+      pCfg -> {
+        pCfg.setString(KEY_REVIEW_LABEL, "Copyright-Review");
+        pCfg.setStringList(KEY_EXCLUDE, ImmutableList.of("EXAMPLES"));
+        pCfg.setStringList(
+            KEY_FIRST_PARTY, ImmutableList.of("APACHE2", "ANDROID", "GOOGLE", "EXAMPLES"));
+        pCfg.setStringList(
+            KEY_THIRD_PARTY, ImmutableList.of("BSD", "MIT", "EPL", "GPL2", "GPL3", "PSFL"));
+        pCfg.setStringList(
+            KEY_FORBIDDEN,
+            ImmutableList.of(
+                "AGPL",
+                "NOT_A_CONTRIBUTION",
+                "WTFPL",
+                "CC_BY_NC",
+                "NON_COMMERCIAL",
+                "COMMONS_CLAUSE"));
+        pCfg.setStringList(
+            KEY_FORBIDDEN_PATTERN,
+            ImmutableList.of("license .*(?:Previously|formerly) licen[cs]ed under.*"));
+      };
+
+  static final Consumer<PluginConfig> ENABLE_CONFIG =
+      pCfg -> {
+        pCfg.setBoolean(KEY_ENABLE, true);
+      };
+  static final Consumer<PluginConfig> DISABLE_CONFIG =
+      pCfg -> {
+        pCfg.setBoolean(KEY_ENABLE, false);
+      };
+
+  /** Adds {@code groups} to the groups file. */
+  void addGroups(InternalGroup... groups) throws Exception {
+    if (groups == null) {
+      return;
+    }
+    for (InternalGroup group : groups) {
+      groupList.put(
+          group.getGroupUUID(), new GroupReference(group.getGroupUUID(), group.getNameKey().get()));
+    }
+  }
+
+  /**
+   * Make a copy of an existing label configuration {@code fromLabel} under a different name, {@code
+   * toLabel}.
+   *
+   * <p>Useful shortcut for configuring a Copyright-Review label based on the default Code-Review
+   * label.
+   */
+  void copyLabel(String fromLabel, String toLabel) throws Exception {
+    for (String name : configProject.getNames(LABEL, fromLabel)) {
+      configProject.setStringList(
+          LABEL, toLabel, name, Arrays.asList(configProject.getStringList(LABEL, fromLabel, name)));
+    }
+    for (String sub : configProject.getSubsections(ACCESS)) {
+      for (String name : configProject.getNames(ACCESS, sub)) {
+        if (!name.endsWith("-" + fromLabel)) {
+          continue;
+        }
+        String[] values = configProject.getStringList(ACCESS, sub, name);
+        if (values == null || values.length == 0) {
+          continue;
+        }
+        configProject.setStringList(
+            ACCESS,
+            sub,
+            name.substring(0, name.length() - fromLabel.length()) + toLabel,
+            Arrays.asList(values));
+      }
+    }
+  }
+
+  /** Creates a commit for the current configuration and pushes the commit to trigger validation. */
+  PushOneCommit.Result push(PushOneCommit.Factory pushFactory) throws Exception {
+    savePlugin();
+    String subject = "Change All-Projects project.config\n\n" + commitMsg;
+    PersonIdent author = owner.newIdent();
+    PushOneCommit pushCommit =
+        pushFactory.create(
+            author,
+            testRepo,
+            subject,
+            ImmutableMap.<String, String>of(
+                ProjectConfig.PROJECT_CONFIG,
+                configProject.toText(),
+                GroupList.FILE_NAME,
+                groupList.asText()));
+    return pushCommit.to("refs/for/" + RefNames.REFS_CONFIG);
+  }
+
+  /** Adds {@code message} to the /COMMIT_MSG text for the push. */
+  void commitMessage(String message) throws Exception {
+    commitMsg = commitMsg + message;
+  }
+
+  /** Deletes the label section and voter configurations for {@code label}. */
+  void removeLabel(String label) throws Exception {
+    for (String sub : configProject.getSubsections(ACCESS)) {
+      configProject.setStringList(ACCESS, sub, "label-" + label, ImmutableList.of());
+      configProject.setStringList(ACCESS, sub, "labelAs-" + label, ImmutableList.of());
+    }
+    configProject.unsetSection(LABEL, label);
+  }
+
+  /** Deletes the voter configurations for {@code label} from the access section for {@code ref}. */
+  void removeVoters(String ref, String label) throws Exception {
+    configProject.setStringList(ACCESS, ref, "label-" + label, ImmutableList.of());
+  }
+
+  /**
+   * Replaces the voter configuration for {@code label} in the access section for {@code ref} with
+   * {@code voters}.
+   */
+  void setVoters(String ref, String label, Voter... voters) throws Exception {
+    ImmutableList.Builder<String> builder = ImmutableList.builder();
+    for (Voter v : voters) {
+      builder.add(vString(v.minVote) + ".." + vString(v.maxVote) + " group " + v.groupName);
+    }
+    configProject.setStringList(ACCESS, ref, "label-" + label, builder.build());
+  }
+
+  /** Apply {@code updates} to {@code this.pluginConfig}. */
+  void updatePlugin(Consumer<PluginConfig>... updates) throws Exception {
+    for (Consumer<PluginConfig> update : updates) {
+      update.accept(pluginConfig);
+    }
+  }
+
+  /** Extracts the plugin section from the project config and creates a PluginConfig view of it. */
+  private void extractPlugin() throws Exception {
+    configPlugin = new Config();
+    for (String name : configProject.getNames(PLUGIN, pluginName)) {
+      configPlugin.setStringList(
+          PLUGIN,
+          pluginName,
+          name,
+          Arrays.asList(configProject.getStringList(PLUGIN, pluginName, name)));
+    }
+    pluginConfig = new PluginConfig(pluginName, configPlugin);
+  }
+
+  /** Reads the project.config and groups files from {@code this.testRepo}. */
+  private void readConfigProject() throws Exception {
+    Ref ref = testRepo.getRepository().exactRef(LOCAL_BRANCH);
+    configProject = new Config();
+    configProject.fromText(readFileContents(ref, ProjectConfig.PROJECT_CONFIG));
+    groupList = GroupList.parse(project, readFileContents(ref, GroupList.FILE_NAME), e -> {});
+  }
+
+  /** Reads the contents of {@code ref}/{@code path} from {@code this.restRepo}. */
+  private String readFileContents(Ref ref, String path) throws Exception {
+    RevWalk rw = testRepo.getRevWalk();
+    RevObject obj = testRepo.get(rw.parseTree(ref.getObjectId()), path);
+    assertThat(obj).isInstanceOf(RevBlob.class);
+    ObjectLoader loader = rw.getObjectReader().open(obj);
+    return new String(loader.getCachedBytes(), UTF_8);
+  }
+
+  /**
+   * Replaces the plugin section in {@code this.configProject} with the contents of {@code
+   * this.configPlugin}.
+   */
+  private void savePlugin() throws Exception {
+    configProject.unsetSection(PLUGIN, pluginName);
+    for (String name : configPlugin.getNames(PLUGIN, pluginName)) {
+      configProject.setStringList(
+          PLUGIN,
+          pluginName,
+          name,
+          Arrays.asList(configPlugin.getStringList(PLUGIN, pluginName, name)));
+    }
+  }
+
+  /** Formats integers with an explicit sign for both positive and negative values. */
+  private String vString(int vote) throws Exception {
+    return (vote > 0 ? "+" : "") + Integer.toString(vote);
+  }
+
+  /** A group name and allowed vote range. */
+  static class Voter {
+    String groupName;
+    int minVote;
+    int maxVote;
+
+    Voter(String groupName, int minVote, int maxVote) {
+      this.groupName = groupName;
+      this.minVote = minVote;
+      this.maxVote = maxVote;
+    }
+  }
+}
diff --git a/tools/check_new_config.sh b/tools/check_new_config.sh
new file mode 100755
index 0000000..01cb7a4
--- /dev/null
+++ b/tools/check_new_config.sh
@@ -0,0 +1,78 @@
+#!/bin/sh
+
+# Copyright (C) 2019 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.
+
+readonly me=$(basename $0)
+readonly root=$(realpath $(dirname $0)/..)
+readonly build_flags="-c opt"
+
+function usage() {
+  echo
+  echo "Usage:"
+  echo
+  echo "\$ ${me} <plugin-name> <project.config>"
+  echo
+  echo "where:"
+  echo " <plugin-name> is the name of the copyright scanner plugin"
+  echo " <project.config> is the full pathname of the project.config file you want to push"
+  echo
+  if test $# -eq 2; then
+    if $(basename "${2}") = "project.config"; then
+      echo "${2} is not a regular file."
+      echo
+    else
+      echo "The project.config full path must end with project.config -- not $(basename $2)"
+      ehco
+    fi
+  fi
+}
+
+if test $# -ne 2; then
+  usage >&2
+  exit 1
+fi
+
+if test $(basename "${2}") != "project.config" -o ! -f "${2}"; then
+  usage >&2
+  exit 1
+fi
+
+readonly build_out=$(
+  cd "${root}" >&2
+  bazel build ${build_flags} :check_new_config >&2 2>/dev/null
+  echo -n $? " "
+  echo $(bazel info ${build_flags} bazel-bin 2>/dev/null)
+)
+rc=$(echo ${build_out} | cut -d\  -f1)
+if test $rc -ne 0; then
+  (
+    cd "${root}" >&2
+    bazel build ${build_flags} :check_new_config >&2
+  )
+  echo >&2
+  echo "Could not build check_new_config binary." >&2
+  echo >&2
+  exit 1
+fi
+
+readonly bazel_bin=$(echo ${build_out} | cut -d\  -f2-)
+
+"${bazel_bin}/check_new_config" "${1}" "${2}"
+rc=$?
+if test ${rc} -ne 0; then
+  echo >&2
+  echo "Please correct the errors above and run this tool again." >&2
+fi
+exit ${rc}