Line data Source code
1 : // Copyright (C) 2012 The Android Open Source Project
2 : //
3 : // Licensed under the Apache License, Version 2.0 (the "License");
4 : // you may not use this file except in compliance with the License.
5 : // You may obtain a copy of the License at
6 : //
7 : // http://www.apache.org/licenses/LICENSE-2.0
8 : //
9 : // Unless required by applicable law or agreed to in writing, software
10 : // distributed under the License is distributed on an "AS IS" BASIS,
11 : // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12 : // See the License for the specific language governing permissions and
13 : // limitations under the License.
14 :
15 : package com.google.gerrit.server.git.validators;
16 :
17 : import static com.google.common.base.Preconditions.checkState;
18 : import static com.google.gerrit.entities.Change.CHANGE_ID_PATTERN;
19 : import static com.google.gerrit.entities.RefNames.REFS_CHANGES;
20 : import static com.google.gerrit.entities.RefNames.REFS_CONFIG;
21 : import static com.google.gerrit.server.project.ProjectCache.illegalState;
22 : import static java.util.stream.Collectors.toList;
23 :
24 : import com.google.common.annotations.VisibleForTesting;
25 : import com.google.common.base.Strings;
26 : import com.google.common.collect.ImmutableList;
27 : import com.google.common.flogger.FluentLogger;
28 : import com.google.gerrit.common.FooterConstants;
29 : import com.google.gerrit.common.Nullable;
30 : import com.google.gerrit.entities.Account;
31 : import com.google.gerrit.entities.BooleanProjectConfig;
32 : import com.google.gerrit.entities.BranchNameKey;
33 : import com.google.gerrit.entities.Change;
34 : import com.google.gerrit.entities.RefNames;
35 : import com.google.gerrit.extensions.api.config.ConsistencyCheckInfo.ConsistencyProblemInfo;
36 : import com.google.gerrit.extensions.registration.DynamicItem;
37 : import com.google.gerrit.extensions.restapi.AuthException;
38 : import com.google.gerrit.server.ChangeUtil;
39 : import com.google.gerrit.server.GerritPersonIdent;
40 : import com.google.gerrit.server.IdentifiedUser;
41 : import com.google.gerrit.server.account.externalids.ExternalIdsConsistencyChecker;
42 : import com.google.gerrit.server.config.AllProjectsName;
43 : import com.google.gerrit.server.config.AllUsersName;
44 : import com.google.gerrit.server.config.GerritServerConfig;
45 : import com.google.gerrit.server.config.UrlFormatter;
46 : import com.google.gerrit.server.events.CommitReceivedEvent;
47 : import com.google.gerrit.server.git.GitRepositoryManager;
48 : import com.google.gerrit.server.git.ValidationError;
49 : import com.google.gerrit.server.logging.Metadata;
50 : import com.google.gerrit.server.logging.TraceContext;
51 : import com.google.gerrit.server.logging.TraceContext.TraceTimer;
52 : import com.google.gerrit.server.patch.DiffOperations;
53 : import com.google.gerrit.server.permissions.PermissionBackend;
54 : import com.google.gerrit.server.permissions.PermissionBackendException;
55 : import com.google.gerrit.server.permissions.RefPermission;
56 : import com.google.gerrit.server.plugincontext.PluginSetContext;
57 : import com.google.gerrit.server.project.LabelConfigValidator;
58 : import com.google.gerrit.server.project.ProjectCache;
59 : import com.google.gerrit.server.project.ProjectConfig;
60 : import com.google.gerrit.server.project.ProjectState;
61 : import com.google.gerrit.server.ssh.HostKey;
62 : import com.google.gerrit.server.ssh.SshInfo;
63 : import com.google.gerrit.server.util.MagicBranch;
64 : import com.google.inject.Inject;
65 : import com.google.inject.Singleton;
66 : import java.io.IOException;
67 : import java.net.MalformedURLException;
68 : import java.net.URL;
69 : import java.util.ArrayList;
70 : import java.util.Collections;
71 : import java.util.List;
72 : import java.util.Optional;
73 : import java.util.regex.Pattern;
74 : import org.eclipse.jgit.diff.DiffEntry;
75 : import org.eclipse.jgit.diff.DiffFormatter;
76 : import org.eclipse.jgit.errors.ConfigInvalidException;
77 : import org.eclipse.jgit.lib.Config;
78 : import org.eclipse.jgit.lib.PersonIdent;
79 : import org.eclipse.jgit.lib.Repository;
80 : import org.eclipse.jgit.notes.NoteMap;
81 : import org.eclipse.jgit.revwalk.FooterKey;
82 : import org.eclipse.jgit.revwalk.FooterLine;
83 : import org.eclipse.jgit.revwalk.RevCommit;
84 : import org.eclipse.jgit.revwalk.RevWalk;
85 : import org.eclipse.jgit.util.SystemReader;
86 : import org.eclipse.jgit.util.io.DisabledOutputStream;
87 :
88 : /**
89 : * Represents a list of {@link CommitValidationListener}s to run for a push to one branch of one
90 : * project.
91 : */
92 : public class CommitValidators {
93 110 : private static final FluentLogger logger = FluentLogger.forEnclosingClass();
94 :
95 110 : public static final Pattern NEW_PATCHSET_PATTERN =
96 110 : Pattern.compile("^" + REFS_CHANGES + "(?:[0-9][0-9]/)?([1-9][0-9]*)(?:/[1-9][0-9]*)?$");
97 :
98 : @Singleton
99 : public static class Factory {
100 : private final PersonIdent gerritIdent;
101 : private final DynamicItem<UrlFormatter> urlFormatter;
102 : private final PluginSetContext<CommitValidationListener> pluginValidators;
103 : private final GitRepositoryManager repoManager;
104 : private final AllUsersName allUsers;
105 : private final AllProjectsName allProjects;
106 : private final ExternalIdsConsistencyChecker externalIdsConsistencyChecker;
107 : private final AccountValidator accountValidator;
108 : private final ProjectCache projectCache;
109 : private final ProjectConfig.Factory projectConfigFactory;
110 : private final DiffOperations diffOperations;
111 : private final Config config;
112 :
113 : @Inject
114 : Factory(
115 : @GerritPersonIdent PersonIdent gerritIdent,
116 : DynamicItem<UrlFormatter> urlFormatter,
117 : @GerritServerConfig Config config,
118 : PluginSetContext<CommitValidationListener> pluginValidators,
119 : GitRepositoryManager repoManager,
120 : AllUsersName allUsers,
121 : AllProjectsName allProjects,
122 : ExternalIdsConsistencyChecker externalIdsConsistencyChecker,
123 : AccountValidator accountValidator,
124 : ProjectCache projectCache,
125 : ProjectConfig.Factory projectConfigFactory,
126 146 : DiffOperations diffOperations) {
127 146 : this.gerritIdent = gerritIdent;
128 146 : this.urlFormatter = urlFormatter;
129 146 : this.config = config;
130 146 : this.pluginValidators = pluginValidators;
131 146 : this.repoManager = repoManager;
132 146 : this.allUsers = allUsers;
133 146 : this.allProjects = allProjects;
134 146 : this.externalIdsConsistencyChecker = externalIdsConsistencyChecker;
135 146 : this.accountValidator = accountValidator;
136 146 : this.projectCache = projectCache;
137 146 : this.projectConfigFactory = projectConfigFactory;
138 146 : this.diffOperations = diffOperations;
139 146 : }
140 :
141 : public CommitValidators forReceiveCommits(
142 : PermissionBackend.ForProject forProject,
143 : BranchNameKey branch,
144 : IdentifiedUser user,
145 : SshInfo sshInfo,
146 : NoteMap rejectCommits,
147 : RevWalk rw,
148 : @Nullable Change change,
149 : boolean skipValidation) {
150 96 : PermissionBackend.ForRef perm = forProject.ref(branch.branch());
151 96 : ProjectState projectState =
152 96 : projectCache.get(branch.project()).orElseThrow(illegalState(branch.project()));
153 96 : ImmutableList.Builder<CommitValidationListener> validators = ImmutableList.builder();
154 96 : validators
155 96 : .add(new UploadMergesPermissionValidator(perm))
156 96 : .add(new ProjectStateValidationListener(projectState))
157 96 : .add(new AmendedGerritMergeCommitValidationListener(perm, gerritIdent))
158 96 : .add(new AuthorUploaderValidator(user, perm, urlFormatter.get()))
159 96 : .add(new FileCountValidator(repoManager, config))
160 96 : .add(new CommitterUploaderValidator(user, perm, urlFormatter.get()))
161 96 : .add(new SignedOffByValidator(user, perm, projectState))
162 96 : .add(
163 : new ChangeIdValidator(
164 96 : projectState, user, urlFormatter.get(), config, sshInfo, change))
165 96 : .add(new ConfigValidator(projectConfigFactory, branch, user, rw, allUsers, allProjects))
166 96 : .add(new BannedCommitsValidator(rejectCommits))
167 96 : .add(new PluginCommitValidationListener(pluginValidators, skipValidation))
168 96 : .add(new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker))
169 96 : .add(new AccountCommitValidator(repoManager, allUsers, accountValidator))
170 96 : .add(new GroupCommitValidator(allUsers))
171 96 : .add(new LabelConfigValidator(diffOperations));
172 96 : return new CommitValidators(validators.build());
173 : }
174 :
175 : public CommitValidators forGerritCommits(
176 : PermissionBackend.ForProject forProject,
177 : BranchNameKey branch,
178 : IdentifiedUser user,
179 : SshInfo sshInfo,
180 : RevWalk rw,
181 : @Nullable Change change) {
182 55 : PermissionBackend.ForRef perm = forProject.ref(branch.branch());
183 55 : ProjectState projectState =
184 55 : projectCache.get(branch.project()).orElseThrow(illegalState(branch.project()));
185 55 : ImmutableList.Builder<CommitValidationListener> validators = ImmutableList.builder();
186 55 : validators
187 55 : .add(new UploadMergesPermissionValidator(perm))
188 55 : .add(new ProjectStateValidationListener(projectState))
189 55 : .add(new AmendedGerritMergeCommitValidationListener(perm, gerritIdent))
190 55 : .add(new AuthorUploaderValidator(user, perm, urlFormatter.get()))
191 55 : .add(new FileCountValidator(repoManager, config))
192 55 : .add(new SignedOffByValidator(user, perm, projectState))
193 55 : .add(
194 : new ChangeIdValidator(
195 55 : projectState, user, urlFormatter.get(), config, sshInfo, change))
196 55 : .add(new ConfigValidator(projectConfigFactory, branch, user, rw, allUsers, allProjects))
197 55 : .add(new PluginCommitValidationListener(pluginValidators))
198 55 : .add(new ExternalIdUpdateListener(allUsers, externalIdsConsistencyChecker))
199 55 : .add(new AccountCommitValidator(repoManager, allUsers, accountValidator))
200 55 : .add(new GroupCommitValidator(allUsers))
201 55 : .add(new LabelConfigValidator(diffOperations));
202 55 : return new CommitValidators(validators.build());
203 : }
204 :
205 : public CommitValidators forMergedCommits(
206 : PermissionBackend.ForProject forProject, BranchNameKey branch, IdentifiedUser user) {
207 : // Generally only include validators that are based on permissions of the
208 : // user creating a change for a merged commit; generally exclude
209 : // validators that would require amending the change in order to correct.
210 : //
211 : // Examples:
212 : // - Change-Id and Signed-off-by can't be added to an already-merged
213 : // commit.
214 : // - If the commit is banned, we can't ban it here. In fact, creating a
215 : // review of a previously merged and recently-banned commit is a use
216 : // case for post-commit code review: so reviewers have a place to
217 : // discuss what to do about it.
218 : // - Plugin validators may do things like require certain commit message
219 : // formats, so we play it safe and exclude them.
220 3 : PermissionBackend.ForRef perm = forProject.ref(branch.branch());
221 3 : ProjectState projectState =
222 3 : projectCache.get(branch.project()).orElseThrow(illegalState(branch.project()));
223 3 : ImmutableList.Builder<CommitValidationListener> validators = ImmutableList.builder();
224 3 : validators
225 3 : .add(new UploadMergesPermissionValidator(perm))
226 3 : .add(new ProjectStateValidationListener(projectState))
227 3 : .add(new AuthorUploaderValidator(user, perm, urlFormatter.get()))
228 3 : .add(new CommitterUploaderValidator(user, perm, urlFormatter.get()));
229 3 : return new CommitValidators(validators.build());
230 : }
231 : }
232 :
233 : private final List<CommitValidationListener> validators;
234 :
235 110 : CommitValidators(List<CommitValidationListener> validators) {
236 110 : this.validators = validators;
237 110 : }
238 :
239 : public List<CommitValidationMessage> validate(CommitReceivedEvent receiveEvent)
240 : throws CommitValidationException {
241 110 : List<CommitValidationMessage> messages = new ArrayList<>();
242 : try {
243 110 : for (CommitValidationListener commitValidator : validators) {
244 110 : try (TraceTimer ignored =
245 110 : TraceContext.newTimer(
246 : "Running CommitValidationListener",
247 110 : Metadata.builder()
248 110 : .className(commitValidator.getClass().getSimpleName())
249 110 : .projectName(receiveEvent.getProjectNameKey().get())
250 110 : .branchName(receiveEvent.getBranchNameKey().branch())
251 110 : .commit(receiveEvent.commit.name())
252 110 : .build())) {
253 110 : messages.addAll(commitValidator.onCommitReceived(receiveEvent));
254 : }
255 110 : }
256 15 : } catch (CommitValidationException e) {
257 15 : logger.atFine().withCause(e).log(
258 15 : "CommitValidationException occurred: %s", e.getFullMessage());
259 : // Keep the old messages (and their order) in case of an exception
260 15 : messages.addAll(e.getMessages());
261 15 : throw new CommitValidationException(e.getMessage(), messages);
262 109 : }
263 109 : return messages;
264 : }
265 :
266 : public static class ChangeIdValidator implements CommitValidationListener {
267 110 : private static final String CHANGE_ID_PREFIX = FooterConstants.CHANGE_ID.getName() + ":";
268 : private static final String MISSING_CHANGE_ID_MSG = "missing Change-Id in message footer";
269 : private static final String MISSING_SUBJECT_MSG =
270 : "missing subject; Change-Id must be in message footer";
271 : private static final String CHANGE_ID_ABOVE_FOOTER_MSG = "Change-Id must be in message footer";
272 : private static final String MULTIPLE_CHANGE_ID_MSG =
273 : "multiple Change-Id lines in message footer";
274 : private static final String INVALID_CHANGE_ID_MSG =
275 : "invalid Change-Id line format in message footer";
276 :
277 : @VisibleForTesting
278 : public static final String CHANGE_ID_MISMATCH_MSG =
279 : "Change-Id in message footer does not match Change-Id of target change";
280 :
281 110 : private static final Pattern CHANGE_ID = Pattern.compile(CHANGE_ID_PATTERN);
282 :
283 : private final ProjectState projectState;
284 : private final UrlFormatter urlFormatter;
285 : private final String installCommitMsgHookCommand;
286 : private final SshInfo sshInfo;
287 : private final IdentifiedUser user;
288 : private final Change change;
289 :
290 : public ChangeIdValidator(
291 : ProjectState projectState,
292 : IdentifiedUser user,
293 : UrlFormatter urlFormatter,
294 : Config config,
295 : SshInfo sshInfo,
296 110 : Change change) {
297 110 : this.projectState = projectState;
298 110 : this.user = user;
299 110 : this.urlFormatter = urlFormatter;
300 110 : installCommitMsgHookCommand = config.getString("gerrit", null, "installCommitMsgHookCommand");
301 110 : this.sshInfo = sshInfo;
302 110 : this.change = change;
303 110 : }
304 :
305 : @Override
306 : public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
307 : throws CommitValidationException {
308 110 : if (!shouldValidateChangeId(receiveEvent)) {
309 68 : return Collections.emptyList();
310 : }
311 103 : RevCommit commit = receiveEvent.commit;
312 103 : List<CommitValidationMessage> messages = new ArrayList<>();
313 103 : List<String> idList = ChangeUtil.getChangeIdsFromFooter(commit, urlFormatter);
314 :
315 103 : if (idList.isEmpty()) {
316 4 : String shortMsg = commit.getShortMessage();
317 4 : if (shortMsg.startsWith(CHANGE_ID_PREFIX)
318 4 : && CHANGE_ID.matcher(shortMsg.substring(CHANGE_ID_PREFIX.length()).trim()).matches()) {
319 4 : throw new CommitValidationException(MISSING_SUBJECT_MSG);
320 : }
321 3 : if (commit.getFullMessage().contains("\n" + CHANGE_ID_PREFIX)) {
322 3 : messages.add(
323 : new CommitValidationMessage(
324 : CHANGE_ID_ABOVE_FOOTER_MSG
325 : + "\n"
326 : + "\n"
327 : + "Hint: run\n"
328 : + " git commit --amend\n"
329 : + "and move 'Change-Id: Ixxx..' to the bottom on a separate line\n",
330 : ValidationMessage.Type.ERROR));
331 3 : throw new CommitValidationException(CHANGE_ID_ABOVE_FOOTER_MSG, messages);
332 : }
333 3 : if (projectState.is(BooleanProjectConfig.REQUIRE_CHANGE_ID)) {
334 3 : messages.add(getMissingChangeIdErrorMsg(MISSING_CHANGE_ID_MSG));
335 3 : throw new CommitValidationException(MISSING_CHANGE_ID_MSG, messages);
336 : }
337 103 : } else if (idList.size() > 1) {
338 3 : throw new CommitValidationException(MULTIPLE_CHANGE_ID_MSG, messages);
339 : } else {
340 103 : String v = idList.get(0).trim();
341 : // Reject Change-Ids with wrong format and invalid placeholder ID from
342 : // Egit (I0000000000000000000000000000000000000000).
343 103 : if (!CHANGE_ID.matcher(v).matches() || v.matches("^I00*$")) {
344 4 : messages.add(getMissingChangeIdErrorMsg(INVALID_CHANGE_ID_MSG));
345 4 : throw new CommitValidationException(INVALID_CHANGE_ID_MSG, messages);
346 : }
347 103 : if (change != null && !v.equals(change.getKey().get())) {
348 0 : throw new CommitValidationException(CHANGE_ID_MISMATCH_MSG);
349 : }
350 : }
351 :
352 103 : return Collections.emptyList();
353 : }
354 :
355 : private static boolean shouldValidateChangeId(CommitReceivedEvent event) {
356 110 : return MagicBranch.isMagicBranch(event.command.getRefName())
357 110 : || NEW_PATCHSET_PATTERN.matcher(event.command.getRefName()).matches();
358 : }
359 :
360 : private CommitValidationMessage getMissingChangeIdErrorMsg(String errMsg) {
361 4 : return new CommitValidationMessage(
362 : errMsg
363 : + "\n"
364 : + "\nHint: to automatically insert a Change-Id, install the hook:\n"
365 4 : + getCommitMessageHookInstallationHint()
366 : + "\n"
367 : + "and then amend the commit:\n"
368 : + " git commit --amend --no-edit\n"
369 : + "Finally, push your changes again\n",
370 : ValidationMessage.Type.ERROR);
371 : }
372 :
373 : private String getCommitMessageHookInstallationHint() {
374 4 : if (installCommitMsgHookCommand != null) {
375 0 : return installCommitMsgHookCommand;
376 : }
377 4 : final List<HostKey> hostKeys = sshInfo.getHostKeys();
378 :
379 : // If there are no SSH keys, the commit-msg hook must be installed via
380 : // HTTP(S)
381 4 : Optional<String> webUrl = urlFormatter.getWebUrl();
382 :
383 4 : String httpHook =
384 4 : String.format(
385 : "f=\"$(git rev-parse --git-dir)/hooks/commit-msg\"; curl -o \"$f\" %stools/hooks/commit-msg ; chmod +x \"$f\"",
386 4 : webUrl.get());
387 :
388 4 : if (hostKeys.isEmpty()) {
389 2 : checkState(webUrl.isPresent());
390 2 : return httpHook;
391 : }
392 :
393 : // SSH keys exist, so the hook might be able to be installed with scp.
394 : String sshHost;
395 : int sshPort;
396 2 : String host = hostKeys.get(0).getHost();
397 2 : int c = host.lastIndexOf(':');
398 2 : if (0 <= c) {
399 2 : if (host.startsWith("*:")) {
400 0 : checkState(webUrl.isPresent());
401 0 : sshHost = getGerritHost(webUrl.get());
402 : } else {
403 2 : sshHost = host.substring(0, c);
404 : }
405 2 : sshPort = Integer.parseInt(host.substring(c + 1));
406 : } else {
407 0 : sshHost = host;
408 0 : sshPort = 22;
409 : }
410 :
411 : // TODO(15944): Remove once both SFTP/SCP protocol are supported.
412 : //
413 : // In newer versions of OpenSSH, the default hook installation command will fail with a
414 : // cryptic error because the scp binary defaults to a different protocol.
415 2 : String scpFlagHint = "(for OpenSSH >= 9.0 you need to add the flag '-O' to the scp command)";
416 :
417 2 : String sshHook =
418 2 : String.format(
419 : "gitdir=$(git rev-parse --git-dir); scp -p -P %d %s@%s:hooks/commit-msg ${gitdir}/hooks/",
420 2 : sshPort, user.getUserName().orElse("<USERNAME>"), sshHost);
421 2 : return String.format(" %s\n%s\nor, for http(s):\n %s", sshHook, scpFlagHint, httpHook);
422 : }
423 : }
424 :
425 : /** Limits the number of files per change. */
426 : private static class FileCountValidator implements CommitValidationListener {
427 :
428 : private final GitRepositoryManager repoManager;
429 : private final int maxFileCount;
430 :
431 110 : FileCountValidator(GitRepositoryManager repoManager, Config config) {
432 110 : this.repoManager = repoManager;
433 110 : maxFileCount = config.getInt("change", null, "maxFiles", 100_000);
434 110 : }
435 :
436 : @Override
437 : public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
438 : throws CommitValidationException {
439 : // TODO(zieren): Refactor interface to signal the intent of the event instead of hard-coding
440 : // it here. Due to interface limitations, this method is called from both receive commits
441 : // and from main Gerrit (e.g. when publishing a change edit). This is why we need to gate the
442 : // early return on REFS_CHANGES (though pushes to refs/changes are not possible).
443 110 : String refName = receiveEvent.command.getRefName();
444 110 : if (!refName.startsWith("refs/for/") && !refName.startsWith(RefNames.REFS_CHANGES)) {
445 : // This is a direct push bypassing review. We don't need to enforce any file-count limits
446 : // here.
447 47 : return Collections.emptyList();
448 : }
449 :
450 : // Use DiffFormatter to compute the number of files in the change. This should be faster than
451 : // the previous approach of using the PatchListCache.
452 : try {
453 104 : long changedFiles = countChangedFiles(receiveEvent);
454 104 : if (changedFiles > maxFileCount) {
455 3 : throw new CommitValidationException(
456 3 : String.format(
457 : "Exceeding maximum number of files per change (%d > %d)",
458 3 : changedFiles, maxFileCount));
459 : }
460 0 : } catch (IOException e) {
461 : // This happens e.g. for cherrypicks.
462 0 : if (!receiveEvent.command.getRefName().startsWith(REFS_CHANGES)) {
463 0 : logger.atWarning().withCause(e).log(
464 : "Failed to validate file count for commit: %s", receiveEvent.commit);
465 : }
466 103 : }
467 103 : return Collections.emptyList();
468 : }
469 :
470 : private long countChangedFiles(CommitReceivedEvent receiveEvent) throws IOException {
471 104 : try (Repository repository = repoManager.openRepository(receiveEvent.project.getNameKey());
472 104 : DiffFormatter diffFormatter = new DiffFormatter(DisabledOutputStream.INSTANCE)) {
473 104 : diffFormatter.setRepository(repository);
474 : // Do not detect renames; that would require reading file contents, which is slow for large
475 : // files.
476 104 : diffFormatter.setDetectRenames(false);
477 : // For merge commits, i.e. >1 parents, we use parent #0 by convention.
478 104 : List<DiffEntry> diffEntries =
479 104 : diffFormatter.scan(
480 104 : receiveEvent.commit.getParentCount() > 0 ? receiveEvent.commit.getParent(0) : null,
481 : receiveEvent.commit);
482 104 : return diffEntries.stream().map(DiffEntry::getNewPath).distinct().count();
483 : }
484 : }
485 : }
486 :
487 : /** If this is the special project configuration branch, validate the config. */
488 : public static class ConfigValidator implements CommitValidationListener {
489 : private final ProjectConfig.Factory projectConfigFactory;
490 : private final BranchNameKey branch;
491 : private final IdentifiedUser user;
492 : private final RevWalk rw;
493 : private final AllUsersName allUsers;
494 : private final AllProjectsName allProjects;
495 :
496 : public ConfigValidator(
497 : ProjectConfig.Factory projectConfigFactory,
498 : BranchNameKey branch,
499 : IdentifiedUser user,
500 : RevWalk rw,
501 : AllUsersName allUsers,
502 110 : AllProjectsName allProjects) {
503 110 : this.projectConfigFactory = projectConfigFactory;
504 110 : this.branch = branch;
505 110 : this.user = user;
506 110 : this.rw = rw;
507 110 : this.allProjects = allProjects;
508 110 : this.allUsers = allUsers;
509 110 : }
510 :
511 : @Override
512 : public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
513 : throws CommitValidationException {
514 110 : if (REFS_CONFIG.equals(branch.branch())) {
515 14 : List<CommitValidationMessage> messages = new ArrayList<>();
516 :
517 : try {
518 14 : ProjectConfig cfg = projectConfigFactory.create(receiveEvent.project.getNameKey());
519 14 : cfg.load(rw, receiveEvent.command.getNewId());
520 14 : if (!cfg.getValidationErrors().isEmpty()) {
521 3 : addError("Invalid project configuration:", messages);
522 3 : for (ValidationError err : cfg.getValidationErrors()) {
523 3 : addError(" " + err.getMessage(), messages);
524 3 : }
525 3 : throw new CommitValidationException("invalid project configuration", messages);
526 : }
527 14 : if (allUsers.equals(receiveEvent.project.getNameKey())
528 1 : && !allProjects.equals(cfg.getProject().getParent(allProjects))) {
529 1 : addError("Invalid project configuration:", messages);
530 1 : addError(
531 1 : String.format(" %s must inherit from %s", allUsers.get(), allProjects.get()),
532 : messages);
533 1 : throw new CommitValidationException("invalid project configuration", messages);
534 : }
535 1 : } catch (ConfigInvalidException | IOException e) {
536 1 : if (e instanceof ConfigInvalidException && !Strings.isNullOrEmpty(e.getMessage())) {
537 1 : addError(e.getMessage(), messages);
538 : }
539 1 : logger.atSevere().withCause(e).log(
540 : "User %s tried to push an invalid project configuration %s for project %s",
541 1 : user.getLoggableName(),
542 1 : receiveEvent.command.getNewId().name(),
543 1 : receiveEvent.project.getName());
544 1 : throw new CommitValidationException("invalid project configuration", messages);
545 13 : }
546 : }
547 :
548 110 : return Collections.emptyList();
549 : }
550 : }
551 :
552 : /** Require permission to upload merge commits. */
553 : public static class UploadMergesPermissionValidator implements CommitValidationListener {
554 : private final PermissionBackend.ForRef perm;
555 :
556 110 : public UploadMergesPermissionValidator(PermissionBackend.ForRef perm) {
557 110 : this.perm = perm;
558 110 : }
559 :
560 : @Override
561 : public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
562 : throws CommitValidationException {
563 110 : if (receiveEvent.commit.getParentCount() <= 1) {
564 110 : return Collections.emptyList();
565 : }
566 : try {
567 26 : if (perm.test(RefPermission.MERGE)) {
568 26 : return Collections.emptyList();
569 : }
570 0 : throw new CommitValidationException("you are not allowed to upload merges");
571 0 : } catch (PermissionBackendException e) {
572 0 : logger.atSevere().withCause(e).log("cannot check MERGE");
573 0 : throw new CommitValidationException("internal auth error");
574 : }
575 : }
576 : }
577 :
578 : /** Execute commit validation plug-ins */
579 : public static class PluginCommitValidationListener implements CommitValidationListener {
580 : private final boolean skipValidation;
581 : private final PluginSetContext<CommitValidationListener> commitValidationListeners;
582 :
583 : public PluginCommitValidationListener(
584 : final PluginSetContext<CommitValidationListener> commitValidationListeners) {
585 55 : this(commitValidationListeners, false);
586 55 : }
587 :
588 : public PluginCommitValidationListener(
589 : final PluginSetContext<CommitValidationListener> commitValidationListeners,
590 110 : boolean skipValidation) {
591 110 : this.skipValidation = skipValidation;
592 110 : this.commitValidationListeners = commitValidationListeners;
593 110 : }
594 :
595 : private void runValidator(
596 : CommitValidationListener validator,
597 : List<CommitValidationMessage> messages,
598 : CommitReceivedEvent receiveEvent)
599 : throws CommitValidationException {
600 109 : if (skipValidation && !validator.shouldValidateAllCommits()) {
601 3 : return;
602 : }
603 109 : messages.addAll(validator.onCommitReceived(receiveEvent));
604 109 : }
605 :
606 : @Override
607 : public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
608 : throws CommitValidationException {
609 109 : List<CommitValidationMessage> messages = new ArrayList<>();
610 : try {
611 109 : commitValidationListeners.runEach(
612 109 : l -> runValidator(l, messages, receiveEvent), CommitValidationException.class);
613 1 : } catch (CommitValidationException e) {
614 1 : messages.addAll(e.getMessages());
615 1 : throw new CommitValidationException(e.getMessage(), messages);
616 109 : }
617 109 : return messages;
618 : }
619 :
620 : @Override
621 : public boolean shouldValidateAllCommits() {
622 0 : return commitValidationListeners.stream()
623 0 : .anyMatch(CommitValidationListener::shouldValidateAllCommits);
624 : }
625 : }
626 :
627 : public static class SignedOffByValidator implements CommitValidationListener {
628 : private final IdentifiedUser user;
629 : private final PermissionBackend.ForRef perm;
630 : private final ProjectState state;
631 :
632 : public SignedOffByValidator(
633 110 : IdentifiedUser user, PermissionBackend.ForRef perm, ProjectState state) {
634 110 : this.user = user;
635 110 : this.perm = perm;
636 110 : this.state = state;
637 110 : }
638 :
639 : @Override
640 : public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
641 : throws CommitValidationException {
642 110 : if (!state.is(BooleanProjectConfig.USE_SIGNED_OFF_BY)) {
643 110 : return Collections.emptyList();
644 : }
645 :
646 3 : RevCommit commit = receiveEvent.commit;
647 3 : PersonIdent committer = commit.getCommitterIdent();
648 3 : PersonIdent author = commit.getAuthorIdent();
649 :
650 3 : boolean sboAuthor = false;
651 3 : boolean sboCommitter = false;
652 3 : boolean sboMe = false;
653 3 : for (FooterLine footer : commit.getFooterLines()) {
654 3 : if (footer.matches(FooterKey.SIGNED_OFF_BY)) {
655 3 : String e = footer.getEmailAddress();
656 3 : if (e != null) {
657 3 : sboAuthor |= author.getEmailAddress().equals(e);
658 3 : sboCommitter |= committer.getEmailAddress().equals(e);
659 3 : sboMe |= user.hasEmailAddress(e);
660 : }
661 : }
662 3 : }
663 3 : if (!sboAuthor && !sboCommitter && !sboMe) {
664 : try {
665 3 : if (!perm.test(RefPermission.FORGE_COMMITTER)) {
666 3 : throw new CommitValidationException(
667 : "not Signed-off-by author/committer/uploader in message footer");
668 : }
669 0 : } catch (PermissionBackendException e) {
670 0 : logger.atSevere().withCause(e).log("cannot check FORGE_COMMITTER");
671 0 : throw new CommitValidationException("internal auth error");
672 0 : }
673 : }
674 3 : return Collections.emptyList();
675 : }
676 : }
677 :
678 : /** Require that author matches the uploader. */
679 : public static class AuthorUploaderValidator implements CommitValidationListener {
680 : private final IdentifiedUser user;
681 : private final PermissionBackend.ForRef perm;
682 : private final UrlFormatter urlFormatter;
683 :
684 : public AuthorUploaderValidator(
685 110 : IdentifiedUser user, PermissionBackend.ForRef perm, UrlFormatter urlFormatter) {
686 110 : this.user = user;
687 110 : this.perm = perm;
688 110 : this.urlFormatter = urlFormatter;
689 110 : }
690 :
691 : @Override
692 : public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
693 : throws CommitValidationException {
694 110 : PersonIdent author = receiveEvent.commit.getAuthorIdent();
695 110 : if (user.hasEmailAddress(author.getEmailAddress())) {
696 106 : return Collections.emptyList();
697 : }
698 : try {
699 36 : if (!perm.test(RefPermission.FORGE_AUTHOR)) {
700 1 : throw new CommitValidationException(
701 1 : "invalid author", invalidEmail("author", author, user, urlFormatter));
702 : }
703 36 : return Collections.emptyList();
704 0 : } catch (PermissionBackendException e) {
705 0 : logger.atSevere().withCause(e).log("cannot check FORGE_AUTHOR");
706 0 : throw new CommitValidationException("internal auth error");
707 : }
708 : }
709 : }
710 :
711 : /** Require that committer matches the uploader. */
712 : public static class CommitterUploaderValidator implements CommitValidationListener {
713 : private final IdentifiedUser user;
714 : private final PermissionBackend.ForRef perm;
715 : private final UrlFormatter urlFormatter;
716 :
717 : public CommitterUploaderValidator(
718 96 : IdentifiedUser user, PermissionBackend.ForRef perm, UrlFormatter urlFormatter) {
719 96 : this.user = user;
720 96 : this.perm = perm;
721 96 : this.urlFormatter = urlFormatter;
722 96 : }
723 :
724 : @Override
725 : public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
726 : throws CommitValidationException {
727 96 : PersonIdent committer = receiveEvent.commit.getCommitterIdent();
728 96 : if (user.hasEmailAddress(committer.getEmailAddress())) {
729 93 : return Collections.emptyList();
730 : }
731 : try {
732 30 : if (!perm.test(RefPermission.FORGE_COMMITTER)) {
733 0 : throw new CommitValidationException(
734 0 : "invalid committer", invalidEmail("committer", committer, user, urlFormatter));
735 : }
736 30 : return Collections.emptyList();
737 0 : } catch (PermissionBackendException e) {
738 0 : logger.atSevere().withCause(e).log("cannot check FORGE_COMMITTER");
739 0 : throw new CommitValidationException("internal auth error");
740 : }
741 : }
742 : }
743 :
744 : /**
745 : * Don't allow the user to amend a merge created by Gerrit Code Review. This seems to happen all
746 : * too often, due to users not paying any attention to what they are doing.
747 : */
748 : public static class AmendedGerritMergeCommitValidationListener
749 : implements CommitValidationListener {
750 : private final PermissionBackend.ForRef perm;
751 : private final PersonIdent gerritIdent;
752 :
753 : public AmendedGerritMergeCommitValidationListener(
754 110 : PermissionBackend.ForRef perm, PersonIdent gerritIdent) {
755 110 : this.perm = perm;
756 110 : this.gerritIdent = gerritIdent;
757 110 : }
758 :
759 : @Override
760 : public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
761 : throws CommitValidationException {
762 110 : PersonIdent author = receiveEvent.commit.getAuthorIdent();
763 110 : if (receiveEvent.commit.getParentCount() > 1
764 26 : && author.getName().equals(gerritIdent.getName())
765 0 : && author.getEmailAddress().equals(gerritIdent.getEmailAddress())) {
766 : try {
767 : // Stop authors from amending the merge commits that Gerrit itself creates.
768 0 : perm.check(RefPermission.FORGE_SERVER);
769 0 : } catch (AuthException denied) {
770 0 : throw new CommitValidationException(
771 0 : String.format(
772 : "pushing merge commit %s by %s requires '%s' permission",
773 0 : receiveEvent.commit.getId(),
774 0 : gerritIdent.getEmailAddress(),
775 0 : RefPermission.FORGE_SERVER.name()),
776 : denied);
777 0 : } catch (PermissionBackendException e) {
778 0 : logger.atSevere().withCause(e).log("cannot check FORGE_SERVER");
779 0 : throw new CommitValidationException("internal auth error");
780 0 : }
781 : }
782 110 : return Collections.emptyList();
783 : }
784 : }
785 :
786 : /** Reject banned commits. */
787 : public static class BannedCommitsValidator implements CommitValidationListener {
788 : private final NoteMap rejectCommits;
789 :
790 96 : public BannedCommitsValidator(NoteMap rejectCommits) {
791 96 : this.rejectCommits = rejectCommits;
792 96 : }
793 :
794 : @Override
795 : public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
796 : throws CommitValidationException {
797 : try {
798 96 : if (rejectCommits.contains(receiveEvent.commit)) {
799 2 : throw new CommitValidationException(
800 2 : "contains banned commit " + receiveEvent.commit.getName());
801 : }
802 95 : return Collections.emptyList();
803 0 : } catch (IOException e) {
804 0 : throw new CommitValidationException("error checking banned commits", e);
805 : }
806 : }
807 : }
808 :
809 : /** Validates updates to refs/meta/external-ids. */
810 : public static class ExternalIdUpdateListener implements CommitValidationListener {
811 : private final AllUsersName allUsers;
812 : private final ExternalIdsConsistencyChecker externalIdsConsistencyChecker;
813 :
814 : public ExternalIdUpdateListener(
815 110 : AllUsersName allUsers, ExternalIdsConsistencyChecker externalIdsConsistencyChecker) {
816 110 : this.externalIdsConsistencyChecker = externalIdsConsistencyChecker;
817 110 : this.allUsers = allUsers;
818 110 : }
819 :
820 : @Override
821 : public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
822 : throws CommitValidationException {
823 109 : if (allUsers.equals(receiveEvent.project.getNameKey())
824 6 : && RefNames.REFS_EXTERNAL_IDS.equals(receiveEvent.refName)) {
825 : try {
826 1 : List<ConsistencyProblemInfo> problems =
827 1 : externalIdsConsistencyChecker.check(receiveEvent.commit);
828 1 : List<CommitValidationMessage> msgs =
829 1 : problems.stream()
830 1 : .map(
831 : p ->
832 1 : new CommitValidationMessage(
833 : p.message,
834 1 : p.status == ConsistencyProblemInfo.Status.ERROR
835 1 : ? ValidationMessage.Type.ERROR
836 1 : : ValidationMessage.Type.OTHER))
837 1 : .collect(toList());
838 1 : if (msgs.stream().anyMatch(ValidationMessage::isError)) {
839 1 : throw new CommitValidationException("invalid external IDs", msgs);
840 : }
841 1 : return msgs;
842 0 : } catch (IOException | ConfigInvalidException e) {
843 0 : throw new CommitValidationException("error validating external IDs", e);
844 : }
845 : }
846 109 : return Collections.emptyList();
847 : }
848 : }
849 :
850 : public static class AccountCommitValidator implements CommitValidationListener {
851 : private final GitRepositoryManager repoManager;
852 : private final AllUsersName allUsers;
853 : private final AccountValidator accountValidator;
854 :
855 : public AccountCommitValidator(
856 : GitRepositoryManager repoManager,
857 : AllUsersName allUsers,
858 110 : AccountValidator accountValidator) {
859 110 : this.repoManager = repoManager;
860 110 : this.allUsers = allUsers;
861 110 : this.accountValidator = accountValidator;
862 110 : }
863 :
864 : @Override
865 : public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
866 : throws CommitValidationException {
867 109 : if (!allUsers.equals(receiveEvent.project.getNameKey())) {
868 107 : return Collections.emptyList();
869 : }
870 :
871 6 : if (receiveEvent.command.getRefName().startsWith(MagicBranch.NEW_CHANGE)) {
872 : // no validation on push for review, will be checked on submit by
873 : // MergeValidators.AccountMergeValidator
874 3 : return Collections.emptyList();
875 : }
876 :
877 5 : Account.Id accountId = Account.Id.fromRef(receiveEvent.refName);
878 5 : if (accountId == null) {
879 3 : return Collections.emptyList();
880 : }
881 :
882 2 : try (Repository repo = repoManager.openRepository(allUsers)) {
883 2 : List<String> errorMessages =
884 2 : accountValidator.validate(
885 : accountId,
886 : repo,
887 : receiveEvent.revWalk,
888 2 : receiveEvent.command.getOldId(),
889 : receiveEvent.commit);
890 2 : if (!errorMessages.isEmpty()) {
891 1 : throw new CommitValidationException(
892 : "invalid account configuration",
893 1 : errorMessages.stream()
894 1 : .map(m -> new CommitValidationMessage(m, ValidationMessage.Type.ERROR))
895 1 : .collect(toList()));
896 : }
897 0 : } catch (IOException e) {
898 0 : throw new CommitValidationException(
899 0 : String.format("Validating update for account %s failed", accountId.get()), e);
900 2 : }
901 2 : return Collections.emptyList();
902 : }
903 : }
904 :
905 : /** Rejects updates to group branches. */
906 : public static class GroupCommitValidator implements CommitValidationListener {
907 : private final AllUsersName allUsers;
908 :
909 110 : public GroupCommitValidator(AllUsersName allUsers) {
910 110 : this.allUsers = allUsers;
911 110 : }
912 :
913 : @Override
914 : public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
915 : throws CommitValidationException {
916 : // Groups are stored inside the 'All-Users' repository.
917 109 : if (!allUsers.equals(receiveEvent.project.getNameKey())) {
918 107 : return Collections.emptyList();
919 : }
920 :
921 6 : if (receiveEvent.command.getRefName().startsWith(MagicBranch.NEW_CHANGE)) {
922 : // no validation on push for review, will be checked on submit by
923 : // MergeValidators.GroupMergeValidator
924 3 : return Collections.emptyList();
925 : }
926 :
927 5 : if (RefNames.isGroupRef(receiveEvent.command.getRefName())) {
928 1 : throw new CommitValidationException("group update not allowed");
929 : }
930 4 : return Collections.emptyList();
931 : }
932 : }
933 :
934 : /** Rejects updates to projects that don't allow writes. */
935 : public static class ProjectStateValidationListener implements CommitValidationListener {
936 : private final ProjectState projectState;
937 :
938 110 : public ProjectStateValidationListener(ProjectState projectState) {
939 110 : this.projectState = projectState;
940 110 : }
941 :
942 : @Override
943 : public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
944 : throws CommitValidationException {
945 110 : if (projectState.statePermitsWrite()) {
946 110 : return Collections.emptyList();
947 : }
948 0 : throw new CommitValidationException("project state does not permit write");
949 : }
950 : }
951 :
952 : private static CommitValidationMessage invalidEmail(
953 : String type, PersonIdent who, IdentifiedUser currentUser, UrlFormatter urlFormatter) {
954 1 : StringBuilder sb = new StringBuilder();
955 :
956 1 : sb.append("email address ")
957 1 : .append(who.getEmailAddress())
958 1 : .append(" is not registered in your account, and you lack 'forge ")
959 1 : .append(type)
960 1 : .append("' permission.\n");
961 :
962 1 : if (currentUser.getEmailAddresses().isEmpty()) {
963 0 : sb.append("You have not registered any email addresses.\n");
964 : } else {
965 1 : sb.append("The following addresses are currently registered:\n");
966 1 : for (String address : currentUser.getEmailAddresses()) {
967 1 : sb.append(" ").append(address).append("\n");
968 1 : }
969 : }
970 :
971 1 : if (urlFormatter.getSettingsUrl("").isPresent()) {
972 1 : sb.append("To register an email address, visit:\n")
973 1 : .append(urlFormatter.getSettingsUrl("EmailAddresses").get())
974 1 : .append("\n\n");
975 : }
976 1 : return new CommitValidationMessage(sb.toString(), ValidationMessage.Type.ERROR);
977 : }
978 :
979 : /**
980 : * Get the Gerrit hostname.
981 : *
982 : * @return the hostname from the canonical URL if it is configured, otherwise whatever the OS says
983 : * the hostname is.
984 : */
985 : private static String getGerritHost(String canonicalWebUrl) {
986 0 : if (canonicalWebUrl != null) {
987 : try {
988 0 : return new URL(canonicalWebUrl).getHost();
989 0 : } catch (MalformedURLException ignored) {
990 0 : logger.atWarning().log(
991 : "configured canonical web URL is invalid, using system default: %s",
992 0 : ignored.getMessage());
993 : }
994 : }
995 :
996 0 : return SystemReader.getInstance().getHostname();
997 : }
998 :
999 : private static void addError(String error, List<CommitValidationMessage> messages) {
1000 4 : messages.add(new CommitValidationMessage(error, ValidationMessage.Type.ERROR));
1001 4 : }
1002 : }
|