blob: 9ef18938890835dc642fc7a9af4f0938d36c1648 [file] [log] [blame]
// 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;
}
}