| // 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.Stopwatch; |
| 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.concurrent.TimeUnit; |
| 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 String pluginName; |
| /** All-Projects project.config contents. */ |
| private Config configProject; |
| /** Plugin config from All-Projects project.config file. */ |
| 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); |
| } |
| |
| /** |
| * 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) { |
| // Warn without blocking project.config pushes when plugin disabled across entire server. |
| ValidationMessage.Type errorWhenActive = |
| pluginEnabled ? ValidationMessage.Type.ERROR : ValidationMessage.Type.WARNING; |
| 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"), |
| errorWhenActive)); |
| } 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."), |
| errorWhenActive)); |
| } |
| |
| // Enforce at least 1 approver exists for the copyright review label for content changes. |
| 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 |
| + "*"), |
| errorWhenActive)); |
| } |
| |
| // Enforce an approver exists for the copyright review label for configuration changes. |
| 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), |
| errorWhenActive)); |
| } |
| } |
| 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 |
| + "'"), |
| errorWhenActive)); |
| // 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 signature, 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())); |
| } |
| 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 { |
| Stopwatch sw = Stopwatch.createStarted(); |
| try { |
| IndexedLineReader file = largeFile(); |
| scannerConfig.scanner.findMatches("file", -1, file); |
| } finally { |
| sw.stop(); |
| logger.atFine().log("timeLargeFile %dms", sw.elapsed(TimeUnit.MILLISECONDS)); |
| return sw.elapsed(TimeUnit.MICROSECONDS); |
| } |
| } |
| |
| /** 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(new char[BUFFER_SIZE]); |
| 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(); |
| } |
| |
| // TODO: move check from command-line tool to background thread with timeout. |
| /** 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); |
| } |
| } |
| } |