| // Copyright (C) 2017 The Android Open Source Project |
| // |
| // Licensed under the Apache License, Version 2.0 (the "License"); |
| // you may not use this file except in compliance with the License. |
| // You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, software |
| // distributed under the License is distributed on an "AS IS" BASIS, |
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| // See the License for the specific language governing permissions and |
| // limitations under the License. |
| |
| package com.googlesource.gerrit.plugins.findowners; |
| |
| import static com.google.common.base.Strings.isNullOrEmpty; |
| import static com.googlesource.gerrit.plugins.findowners.Config.REJECT_ERROR_IN_OWNERS; |
| |
| import com.google.common.annotations.VisibleForTesting; |
| import com.google.common.collect.Multimap; |
| import com.google.common.flogger.FluentLogger; |
| import com.google.gerrit.extensions.annotations.Exports; |
| import com.google.gerrit.extensions.api.projects.ProjectConfigEntryType; |
| import com.google.gerrit.extensions.registration.DynamicSet; |
| import com.google.gerrit.reviewdb.client.Account; |
| import com.google.gerrit.reviewdb.client.Project; |
| import com.google.gerrit.server.account.AccountCache; |
| import com.google.gerrit.server.account.Emails; |
| import com.google.gerrit.server.config.PluginConfig; |
| import com.google.gerrit.server.config.PluginConfigFactory; |
| import com.google.gerrit.server.config.ProjectConfigEntry; |
| import com.google.gerrit.server.events.CommitReceivedEvent; |
| import com.google.gerrit.server.git.GitRepositoryManager; |
| import com.google.gerrit.server.git.validators.CommitValidationException; |
| import com.google.gerrit.server.git.validators.CommitValidationListener; |
| import com.google.gerrit.server.git.validators.CommitValidationMessage; |
| import com.google.gerrit.server.patch.PatchListCache; |
| import com.google.inject.AbstractModule; |
| import com.google.inject.Inject; |
| import java.io.BufferedReader; |
| import java.io.File; |
| import java.io.IOException; |
| import java.io.InputStream; |
| import java.io.InputStreamReader; |
| import java.nio.charset.StandardCharsets; |
| import java.util.ArrayList; |
| import java.util.Collection; |
| import java.util.HashMap; |
| import java.util.HashSet; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Set; |
| import java.util.stream.Collectors; |
| import org.eclipse.jgit.diff.RawText; |
| import org.eclipse.jgit.lib.FileMode; |
| import org.eclipse.jgit.lib.ObjectId; |
| import org.eclipse.jgit.lib.ObjectLoader; |
| import org.eclipse.jgit.revwalk.RevCommit; |
| import org.eclipse.jgit.revwalk.RevWalk; |
| import org.eclipse.jgit.treewalk.TreeWalk; |
| import org.eclipse.jgit.treewalk.filter.TreeFilter; |
| |
| /** Check syntax of changed OWNERS files. */ |
| public class OwnersValidator implements CommitValidationListener { |
| private static final FluentLogger logger = FluentLogger.forEnclosingClass(); |
| |
| private interface TreeWalkVisitor { |
| void onVisit(TreeWalk tw); |
| } |
| |
| public static AbstractModule module() { |
| return new AbstractModule() { |
| @Override |
| protected void configure() { |
| DynamicSet.bind(binder(), CommitValidationListener.class).to(OwnersValidator.class); |
| bind(ProjectConfigEntry.class) |
| .annotatedWith(Exports.named(REJECT_ERROR_IN_OWNERS)) |
| .toInstance( |
| new ProjectConfigEntry( |
| "Reject OWNERS Files With Errors", |
| null, |
| ProjectConfigEntryType.BOOLEAN, |
| null, |
| false, |
| "Pushes of commits with errors in OWNERS files will be rejected.")); |
| } |
| }; |
| } |
| |
| private final Config config; |
| private final GitRepositoryManager repoManager; |
| private final Emails emails; |
| |
| @Inject |
| OwnersValidator( |
| PluginConfigFactory cfgFactory, |
| AccountCache accountCache, |
| PatchListCache patchListCache, |
| GitRepositoryManager repoManager, |
| Emails emails) { |
| this(cfgFactory, null, accountCache, patchListCache, repoManager, emails); |
| } |
| |
| @VisibleForTesting |
| OwnersValidator( |
| PluginConfig config, |
| AccountCache accountCache, |
| PatchListCache patchListCache, |
| GitRepositoryManager repoManager, |
| Emails emails) { |
| this(null, config, accountCache, patchListCache, repoManager, emails); |
| } |
| |
| private OwnersValidator( |
| PluginConfigFactory cfgFactory, |
| PluginConfig config, |
| AccountCache accountCache, |
| PatchListCache patchListCache, |
| GitRepositoryManager repoManager, |
| Emails emails) { |
| this.config = new Config(cfgFactory, config, accountCache, patchListCache, emails); |
| this.repoManager = repoManager; |
| this.emails = emails; |
| } |
| |
| @VisibleForTesting |
| String getOwnersFileName() { |
| return config.getOwnersFileName(); |
| } |
| |
| @VisibleForTesting |
| public String getOwnersFileName(Project project) { |
| return config.getOwnersFileName(project); |
| } |
| |
| @VisibleForTesting |
| boolean isActive(Project project) { |
| return config.getRejectErrorInOwners(project); |
| } |
| |
| @VisibleForTesting |
| boolean isActive() { |
| return config.getRejectErrorInOwners(); |
| } |
| |
| @Override |
| public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent event) |
| throws CommitValidationException { |
| if (!isActive(event.project)) { |
| return new ArrayList<>(); |
| } |
| Checker checker = new Checker(event, false); |
| try { |
| checker.check(getOwnersFileName(event.project)); |
| if (checker.hasError()) { |
| checker.addError( |
| "See OWNERS file syntax document at " |
| + "https://gerrit.googlesource.com/plugins/find-owners/+/" |
| + "master/src/main/resources/Documentation/syntax.md"); |
| throw new CommitValidationException("found invalid owners file", checker.messages); |
| } |
| return checker.messages; |
| } catch (IOException e) { |
| logger.atSevere().withCause(e).log("Failed in onCommitReceived"); |
| return new ArrayList<>(); |
| } |
| } |
| |
| class Checker { |
| // An inner class to keep needed data specific to one commit event. |
| CommitReceivedEvent event; |
| boolean verbose; |
| List<CommitValidationMessage> messages; |
| Map<String, ObjectId> allFiles; // changedFilePath => ObjectId |
| Map<String, String> readFiles; // project:file => content |
| Set<String> checkedFiles; // project:file |
| // Collect all email addresses from all files and check each address only once. |
| Map<String, Set<String>> email2lines; |
| |
| Checker(CommitReceivedEvent event, boolean verbose) { |
| this.event = event; |
| this.verbose = verbose; |
| messages = new ArrayList<>(); |
| readFiles = new HashMap<>(); |
| checkedFiles = new HashSet<>(); |
| email2lines = new HashMap<>(); |
| try { |
| allFiles = getChangedFiles(event.commit, event.revWalk); |
| } catch (Exception e) { |
| allFiles = new HashMap<>(); |
| addError("getChangedFiles failed."); |
| } |
| } |
| |
| @VisibleForTesting |
| void check(String ownersFileName) throws IOException { |
| Map<String, ObjectId> ownerFiles = |
| allFiles.entrySet().stream() |
| .filter(e -> ownersFileName.equals(new File(e.getKey()).getName())) |
| .collect(Collectors.toMap(e -> e.getKey(), e -> e.getValue())); |
| String projectName = event.project.getName(); |
| for (String path : ownerFiles.keySet()) { |
| String key = projectName + ":" + path; |
| ObjectLoader ol = event.revWalk.getObjectReader().open(ownerFiles.get(path)); |
| try (InputStream in = ol.openStream()) { |
| if (RawText.isBinary(in)) { |
| addError(path + " is a binary file"); // OWNERS files cannot be binary |
| continue; |
| } |
| } |
| checkedFiles.add(key); |
| checkFile(projectName, path, ol); |
| } |
| checkEmails(emails); |
| } |
| |
| void checkEmails(Emails emails) { |
| List<String> owners = new ArrayList<>(email2lines.keySet()); |
| if (owners.isEmpty()) { |
| return; |
| } |
| if (verbose) { |
| for (String owner : owners) { |
| addMsg("owner: " + owner); |
| } |
| } |
| if (emails == null) { |
| addError("cannot check owner emails with null Emails cache."); |
| } |
| String[] ownerEmailsAsArray = new String[owners.size()]; |
| owners.toArray(ownerEmailsAsArray); |
| try { |
| Multimap<String, Account.Id> email2ids = emails.getAccountsFor(ownerEmailsAsArray); |
| for (String owner : ownerEmailsAsArray) { |
| boolean wrongEmail = (email2ids == null); |
| if (!wrongEmail) { |
| try { |
| Collection<Account.Id> ids = email2ids.get(owner); |
| wrongEmail = (ids == null || ids.isEmpty()); |
| } catch (Exception e) { |
| wrongEmail = true; |
| } |
| } |
| if (wrongEmail) { |
| String locations = String.join(" ", email2lines.get(owner)); |
| addError("unknown: " + owner + " at " + locations); |
| } |
| } |
| } catch (Exception e) { |
| addError("checkEmails failed."); |
| } |
| } |
| |
| void checkFile(String project, String path, String[] lines) { |
| addVerboseMsg("checking " + path); |
| int num = 0; |
| for (String line : lines) { |
| checkLine(project, path, ++num, line); |
| } |
| } |
| |
| void checkFile(String project, String path, String content) { |
| checkFile(project, path, content.split("\\R")); |
| } |
| |
| void checkFile(String project, String path, ObjectLoader ol) { |
| try { |
| BufferedReader reader = |
| new BufferedReader(new InputStreamReader(ol.openStream(), StandardCharsets.UTF_8)); |
| checkFile(project, path, reader.lines().toArray(String[]::new)); |
| } catch (Exception e) { |
| addError("cannot open file: " + path); |
| } |
| } |
| |
| private void collectEmail(String email, String project, String file, int lineNumber) { |
| if (!email.equals("*")) { |
| email2lines.computeIfAbsent(email, (String k) -> new HashSet<>()); |
| email2lines.get(email).add(qualifiedPath(project, file) + ":" + lineNumber); |
| } |
| } |
| |
| private boolean hasError() { |
| for (CommitValidationMessage m : messages) { |
| if (m.isError()) { |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| void addError(String msg) { |
| messages.add(new CommitValidationMessage(msg, true)); |
| } |
| |
| String qualifiedPath(String project, String path) { |
| return event.project.getName().equals(project) ? path : (project + ":" + path); |
| } |
| |
| void addSyntaxError(String path, int lineNumber, String line) { |
| addError("syntax: " + path + ":" + lineNumber + ": " + line); |
| } |
| |
| void addMsg(String msg) { |
| messages.add(new CommitValidationMessage(msg, false)); |
| } |
| |
| void addVerboseMsg(String msg) { |
| if (verbose) { |
| addMsg(msg); |
| } |
| } |
| |
| String normalizeChangedFilePath(String dir, String file) { |
| try { |
| if (file.startsWith("/")) { |
| file = new File(file).getCanonicalPath(); |
| } else { |
| file = new File("/" + dir + "/" + file).getCanonicalPath(); |
| } |
| } catch (IOException e) { |
| addError("cannot build file path " + dir + ":" + file); |
| } |
| return file.startsWith("/") ? file.substring(1) : file; |
| } |
| |
| /** |
| * Check if an included file exists and with valid syntax. An included file could be (1) in the |
| * current CL, (2) in the same repository, (3) in a different repository, (4) in another CL. |
| * Case (4) is not checked yet. |
| */ |
| void checkIncludeOrFile(String project, String path, int num, String line) { |
| // project is the including file's project, not necessarily the same as CL event's. |
| String directive = Parser.getIncludeOrFile(line); |
| String[] KPF = Parser.parseInclude(project, directive); |
| if (KPF == null || KPF[1] == null || KPF[2] == null) { |
| addSyntaxError(qualifiedPath(project, path), num, line); |
| } |
| String file = KPF[2]; |
| String curDir = Util.getParentDir(path); |
| String repoFile = normalizeChangedFilePath(curDir, file); |
| // Check each file only once. |
| String key = KPF[1] + ":" + repoFile; |
| if (checkedFiles.contains(key)) { |
| addVerboseMsg("skip repeated include of " + key); |
| return; |
| } |
| checkedFiles.add(key); |
| if (KPF[1].equals(event.project.getName())) { |
| if (allFiles.get(repoFile) != null) { |
| // Case (1): included file is in current CL. |
| addVerboseMsg("check changed file " + key); |
| try { |
| ObjectLoader ol = event.revWalk.getObjectReader().open(allFiles.get(repoFile)); |
| try (InputStream in = ol.openStream()) { |
| if (RawText.isBinary(in)) { |
| addError(path + " is a binary file"); // OWNERS files cannot be binary |
| return; |
| } |
| } |
| checkFile(KPF[1], repoFile, ol); |
| } catch (Exception e) { |
| addError("cannot open changed file: " + path); |
| } |
| return; |
| } |
| } |
| // Included file is in repository or other CL. |
| addVerboseMsg("check repo file " + key); |
| String content = |
| OwnersDb.getRepoFile( |
| null, /* permissionBackend */ |
| readFiles, |
| repoManager, |
| null, |
| null, |
| KPF[1], |
| event.refName, |
| repoFile, |
| new ArrayList<>()); |
| if (isNullOrEmpty(content)) { // file not found or not readable. |
| addVerboseMsg("cannot find file: " + key); |
| // unchecked: including-file-path : line number : source line |
| addMsg("unchecked: " + qualifiedPath(project, path) + ":" + num + ": " + directive); |
| } else { |
| checkFile(KPF[1], repoFile, content); |
| } |
| } |
| |
| void checkLine(String project, String path, int lineNumber, String line) { |
| String email; |
| String[] owners; |
| if (Parser.isComment(line) || Parser.isNoParent(line)) { |
| // no email address to check |
| } else if ((email = Parser.parseEmail(line)) != null) { |
| collectEmail(email, project, path, lineNumber); |
| } else if ((owners = Parser.parsePerFileOwners(line)) != null) { |
| for (String owner : owners) { |
| if (owner.startsWith("file:")) { |
| // Pass the whole line, not just owner, to report any syntax error., |
| checkIncludeOrFile(project, path, lineNumber, line); |
| } else if (!owner.equals(Parser.TOK_SET_NOPARENT)) { |
| collectEmail(owner, project, path, lineNumber); |
| } |
| } |
| } else if (Parser.isInclude(line)) { |
| checkIncludeOrFile(project, path, lineNumber, line); |
| } else { |
| addSyntaxError(qualifiedPath(project, path), lineNumber, line); |
| } |
| } |
| } // end of inner class Checker |
| |
| /** Return a map from "Path to changed file" to "ObjectId of the file". */ |
| private static Map<String, ObjectId> getChangedFiles(RevCommit c, RevWalk revWalk) |
| throws IOException { |
| final Map<String, ObjectId> content = new HashMap<>(); |
| visitChangedEntries( |
| c, |
| revWalk, |
| new TreeWalkVisitor() { |
| @Override |
| public void onVisit(TreeWalk tw) { |
| // getPathString() returns path names without leading "/" |
| if (isFile(tw)) { |
| content.put(tw.getPathString(), tw.getObjectId(0)); |
| } |
| } |
| }); |
| return content; |
| } |
| |
| private static boolean isFile(TreeWalk tw) { |
| return FileMode.EXECUTABLE_FILE.equals(tw.getRawMode(0)) |
| || FileMode.REGULAR_FILE.equals(tw.getRawMode(0)); |
| } |
| |
| /** |
| * Find all TreeWalk entries which differ between the commit and its parents. If a TreeWalk entry |
| * is found this method calls the onVisit() method of the class TreeWalkVisitor. |
| */ |
| private static void visitChangedEntries(RevCommit c, RevWalk revWalk, TreeWalkVisitor visitor) |
| throws IOException { |
| try (TreeWalk tw = new TreeWalk(revWalk.getObjectReader())) { |
| tw.setRecursive(true); |
| tw.setFilter(TreeFilter.ANY_DIFF); |
| tw.addTree(c.getTree()); |
| if (c.getParentCount() > 0) { |
| for (RevCommit p : c.getParents()) { |
| if (p.getTree() == null) { |
| revWalk.parseHeaders(p); |
| } |
| tw.addTree(p.getTree()); |
| } |
| while (tw.next()) { |
| if (isDifferentToAllParents(c, tw)) { |
| visitor.onVisit(tw); |
| } |
| } |
| } else { |
| while (tw.next()) { |
| visitor.onVisit(tw); |
| } |
| } |
| } |
| } |
| |
| private static boolean isDifferentToAllParents(RevCommit c, TreeWalk tw) { |
| if (c.getParentCount() > 1) { |
| for (int p = 1; p <= c.getParentCount(); p++) { |
| if (tw.getObjectId(0).equals(tw.getObjectId(p))) { |
| return false; |
| } |
| } |
| } |
| return true; |
| } |
| } |