blob: af457032bdcacf0a025b281fa2ce9f1ee47a50e4 [file] [log] [blame]
// 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.base.Strings;
import com.google.common.collect.ImmutableList;
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.annotations.PluginName;
import com.google.gerrit.extensions.api.changes.AddReviewerResult;
import com.google.gerrit.extensions.api.changes.ReviewResult;
import com.google.gerrit.extensions.events.GitReferenceUpdatedListener;
import com.google.gerrit.extensions.events.LifecycleListener;
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.Timer0;
import com.google.gerrit.reviewdb.client.Project;
import com.google.gerrit.reviewdb.client.RefNames;
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.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 java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
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
implements CommitValidationListener,
RevisionCreatedListener,
GitReferenceUpdatedListener,
LifecycleListener {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
/** Default value of timeTestMax configuration parameter for avoiding excessive backtracking. */
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 PluginConfigFactory pluginConfigFactory;
private final CopyrightReviewApi reviewApi;
@Nullable private PluginConfig gerritConfig;
@Nullable private CheckConfig checkConfig;
static AbstractModule module() {
return new AbstractModule() {
@Override
protected void configure() {
DynamicSet.bind(binder(), CommitValidationListener.class).to(CopyrightConfig.class);
DynamicSet.bind(binder(), LifecycleListener.class).to(CopyrightConfig.class);
DynamicSet.bind(binder(), RevisionCreatedListener.class).to(CopyrightConfig.class);
DynamicSet.bind(binder(), GitReferenceUpdatedListener.class).to(CopyrightConfig.class);
}
};
}
@Inject
CopyrightConfig(
Metrics metrics,
AllProjectsName allProjectsName,
@PluginName String pluginName,
GitRepositoryManager repoManager,
ProjectCache projectCache,
PluginConfigFactory pluginConfigFactory,
CopyrightReviewApi reviewApi) {
this.metrics = metrics;
this.allProjectsName = allProjectsName;
this.pluginName = pluginName;
this.repoManager = repoManager;
this.projectCache = projectCache;
this.pluginConfigFactory = pluginConfigFactory;
this.reviewApi = reviewApi;
this.checkConfig = null;
}
ScannerConfig getScannerConfig() {
return checkConfig == null ? null : checkConfig.scannerConfig;
}
@Override
public void start() {
try (Timer0.Context ctx = metrics.readConfigTimer.start()) {
checkConfig = readConfig(projectCache.getAllProjects().getProject().getConfigRefState());
} catch (IOException | ConfigInvalidException e) {
logger.atSevere().withCause(e).log("unable to load configuration");
metrics.configurationErrors.increment(allProjectsName.get());
metrics.errors.increment();
return;
}
}
@Override
public void stop() {}
/** 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;
}
if (!event.getProjectName().equals(allProjectsName.get())) {
return;
}
try (Timer0.Context ctx = metrics.readConfigTimer.start()) {
checkConfig = readConfig(event.getNewObjectId());
} catch (IOException | ConfigInvalidException e) {
logger.atSevere().withCause(e).log("unable to load configuration");
metrics.configurationErrors.increment(allProjectsName.get());
metrics.errors.increment();
return;
}
}
/** Blocks upload of bad plugin configurations to /refs/meta/config on All-Projects. */
@Override
public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent event)
throws CommitValidationException {
if (!event.getRefName().equals(RefNames.REFS_CONFIG)) {
return Collections.emptyList();
}
if (!event.getProjectNameKey().equals(allProjectsName)) {
return Collections.emptyList();
}
CheckConfig trialConfig = null;
try {
try (Timer0.Context ctx = metrics.readConfigTimer.start()) {
trialConfig = readConfig(event.commit.getName());
}
if (Objects.equals(
trialConfig.scannerConfig, checkConfig == null ? null : checkConfig.scannerConfig)) {
return Collections.emptyList();
}
try (Timer0.Context ctx = metrics.checkConfigTimer.start()) {
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);
List<CommitValidationMessage> messages =
trialConfig == null || trialConfig.scannerConfig == null
? Collections.emptyList()
: trialConfig.scannerConfig.messages;
checkForConfigErrors(trialConfig);
return messages;
}
} catch (IOException e) {
logger.atSevere().withCause(e).log("failed to read new project.config");
throw new CommitValidationException(
pluginName + "plugin failed to read new project.config", e);
} catch (ConfigInvalidException e) {
logger.atSevere().withCause(e).log("unable to parse plugin config");
if (trialConfig != null && trialConfig.scannerConfig != null) {
trialConfig.scannerConfig.messages.add(ScannerConfig.errorMessage(e.getMessage()));
metrics.configurationErrors.increment(allProjectsName.get());
metrics.errors.increment();
checkForConfigErrors(trialConfig);
return trialConfig.scannerConfig.messages;
} else {
throw new CommitValidationException(
pluginName + "plugin unable to parse new project.config", e);
}
}
}
/** 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
CheckConfig trialConfig = null;
try {
try (Timer0.Context ctx = metrics.readConfigTimer.start()) {
trialConfig = readConfig(event.getChange().currentRevision);
}
if (Objects.equals(
trialConfig.scannerConfig, checkConfig == null ? null : checkConfig.scannerConfig)) {
return;
}
try (Timer0.Context ctx = metrics.checkConfigTimer.start()) {
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);
}
}
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("unable to read new configuration");
metrics.configurationErrors.increment(project);
metrics.errors.increment();
return;
} finally {
if (trialConfig != null
&& trialConfig.scannerConfig != null
&& !trialConfig.scannerConfig.messages.isEmpty()
&& !trialConfig.scannerConfig.hasErrors()) {
try {
ReviewResult result =
reviewApi.reportConfigMessages(
pluginName,
project,
ProjectConfig.PROJECT_CONFIG,
checkConfig == null ? null : checkConfig.scannerConfig,
trialConfig.scannerConfig,
event);
logReviewResultErrors(event, result);
} catch (RestApiException e) {
logger.atSevere().withCause(e).log("unable to report configuration findings");
metrics.postReviewErrors.increment(project);
metrics.errors.increment();
return;
}
}
}
}
/** 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;
}
// 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(Project.nameKey(project));
} catch (IOException e) {
logger.atSevere().withCause(e).log("error getting project state of %s", project);
metrics.projectStateErrors.increment(project);
metrics.errors.increment();
return scannerConfig.defaultEnable;
}
if (projectState == null) {
logger.atSevere().log("error getting project state of %s", project);
metrics.projectStateErrors.increment(project);
metrics.errors.increment();
return scannerConfig.defaultEnable;
}
ProjectConfig projectConfig = projectState.getConfig();
if (projectConfig == null) {
logger.atWarning().log("error getting project config of %s", project);
metrics.projectConfigErrors.increment(project);
metrics.errors.increment();
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);
}
private void checkForConfigErrors(CheckConfig trialConfig) throws CommitValidationException {
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());
}
}
/**
* 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
*/
@Nullable
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) {
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;
}
private void logReviewResultErrors(RevisionCreatedListener.Event event, ReviewResult result) {
if (!Strings.isNullOrEmpty(result.error)) {
logger.atSevere().log(
"revision %s: error posting review: %s", event.getChange().currentRevision, result.error);
metrics.postReviewErrors.increment(event.getChange().project);
metrics.errors.increment();
}
if (result.reviewers != null) {
for (Map.Entry<String, AddReviewerResult> entry : result.reviewers.entrySet()) {
AddReviewerResult arr = entry.getValue();
if (!Strings.isNullOrEmpty(arr.error)) {
logger.atSevere().log(
"revision %s: error adding reviewer %s: %s",
event.getChange().currentRevision, entry.getKey(), arr.error);
metrics.addReviewerErrors.increment(event.getChange().project);
metrics.errors.increment();
}
}
}
}
private String readFileContents(Repository repo, ObjectId objectId, String filename)
throws IOException {
try (RevWalk rw = new RevWalk(repo);
TreeWalk tw = TreeWalk.forPath(rw.getObjectReader(), filename, rw.parseTree(objectId))) {
ObjectLoader loader = repo.open(tw.getObjectId(0), Constants.OBJ_BLOB);
return new String(loader.getCachedBytes(), UTF_8);
}
}
}