Line data Source code
1 : // Copyright (C) 2022 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.project;
16 :
17 : import com.google.common.annotations.VisibleForTesting;
18 : import com.google.common.collect.ImmutableList;
19 : import com.google.common.collect.ImmutableMap;
20 : import com.google.common.collect.ImmutableSet;
21 : import com.google.common.collect.Sets;
22 : import com.google.common.flogger.FluentLogger;
23 : import com.google.gerrit.common.Nullable;
24 : import com.google.gerrit.entities.LabelFunction;
25 : import com.google.gerrit.entities.RefNames;
26 : import com.google.gerrit.extensions.client.ChangeKind;
27 : import com.google.gerrit.server.events.CommitReceivedEvent;
28 : import com.google.gerrit.server.git.validators.CommitValidationException;
29 : import com.google.gerrit.server.git.validators.CommitValidationListener;
30 : import com.google.gerrit.server.git.validators.CommitValidationMessage;
31 : import com.google.gerrit.server.git.validators.ValidationMessage;
32 : import com.google.gerrit.server.patch.DiffNotAvailableException;
33 : import com.google.gerrit.server.patch.DiffOperations;
34 : import com.google.gerrit.server.patch.DiffOptions;
35 : import com.google.gerrit.server.patch.filediff.FileDiffOutput;
36 : import com.google.inject.Inject;
37 : import com.google.inject.Singleton;
38 : import java.io.IOException;
39 : import java.util.List;
40 : import java.util.Map;
41 : import java.util.Optional;
42 : import org.eclipse.jgit.errors.ConfigInvalidException;
43 : import org.eclipse.jgit.lib.Config;
44 :
45 : /**
46 : * Validates modifications to label configurations in the {@code project.config} file that is stored
47 : * in {@code refs/meta/config}.
48 : *
49 : * <p>Rejects setting/changing deprecated fields that are no longer supported (fields {@code
50 : * copyAnyScore}, {@code copyMinScore}, {@code copyMaxScore}, {@code copyAllScoresIfNoChange},
51 : * {@code copyAllScoresIfNoCodeChange}, {@code copyAllScoresOnMergeFirstParentUpdate}, {@code
52 : * copyAllScoresOnTrivialRebase}, {@code copyAllScoresIfListOfFilesDidNotChange}, {@code
53 : * copyValue}).
54 : *
55 : * <p>Updates that unset the deprecated fields or that don't touch them are allowed.
56 : */
57 : @Singleton
58 : public class LabelConfigValidator implements CommitValidationListener {
59 110 : private static final FluentLogger logger = FluentLogger.forEnclosingClass();
60 :
61 : @VisibleForTesting public static final String KEY_COPY_ANY_SCORE = "copyAnyScore";
62 :
63 : @VisibleForTesting public static final String KEY_COPY_MIN_SCORE = "copyMinScore";
64 :
65 : @VisibleForTesting public static final String KEY_COPY_MAX_SCORE = "copyMaxScore";
66 :
67 : @VisibleForTesting public static final String KEY_COPY_VALUE = "copyValue";
68 :
69 : @VisibleForTesting
70 : public static final String KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE =
71 : "copyAllScoresOnMergeFirstParentUpdate";
72 :
73 : @VisibleForTesting
74 : public static final String KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE = "copyAllScoresOnTrivialRebase";
75 :
76 : @VisibleForTesting
77 : public static final String KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE = "copyAllScoresIfNoCodeChange";
78 :
79 : @VisibleForTesting
80 : public static final String KEY_COPY_ALL_SCORES_IF_NO_CHANGE = "copyAllScoresIfNoChange";
81 :
82 : @VisibleForTesting
83 : public static final String KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE =
84 : "copyAllScoresIfListOfFilesDidNotChange";
85 :
86 : // Map of deprecated boolean flags to the predicates that should be used in the copy condition
87 : // instead.
88 110 : private static final ImmutableMap<String, String> DEPRECATED_FLAGS =
89 110 : ImmutableMap.<String, String>builder()
90 110 : .put(KEY_COPY_ANY_SCORE, "is:ANY")
91 110 : .put(KEY_COPY_MIN_SCORE, "is:MIN")
92 110 : .put(KEY_COPY_MAX_SCORE, "is:MAX")
93 110 : .put(KEY_COPY_ALL_SCORES_IF_NO_CHANGE, "changekind:" + ChangeKind.NO_CHANGE.name())
94 110 : .put(
95 : KEY_COPY_ALL_SCORES_IF_NO_CODE_CHANGE,
96 110 : "changekind:" + ChangeKind.NO_CODE_CHANGE.name())
97 110 : .put(
98 : KEY_COPY_ALL_SCORES_ON_MERGE_FIRST_PARENT_UPDATE,
99 110 : "changekind:" + ChangeKind.MERGE_FIRST_PARENT_UPDATE.name())
100 110 : .put(
101 : KEY_COPY_ALL_SCORES_ON_TRIVIAL_REBASE,
102 110 : "changekind:" + ChangeKind.TRIVIAL_REBASE.name())
103 110 : .put(KEY_COPY_ALL_SCORES_IF_LIST_OF_FILES_DID_NOT_CHANGE, "has:unchanged-files")
104 110 : .build();
105 :
106 : private final DiffOperations diffOperations;
107 :
108 : @Inject
109 110 : public LabelConfigValidator(DiffOperations diffOperations) {
110 110 : this.diffOperations = diffOperations;
111 110 : }
112 :
113 : @Override
114 : public List<CommitValidationMessage> onCommitReceived(CommitReceivedEvent receiveEvent)
115 : throws CommitValidationException {
116 : try {
117 109 : if (!receiveEvent.refName.equals(RefNames.REFS_CONFIG)
118 13 : || !isFileChanged(receiveEvent, ProjectConfig.PROJECT_CONFIG)) {
119 : // The project.config file in refs/meta/config was not modified, hence we do not need to do
120 : // any validation and can return early.
121 109 : return ImmutableList.of();
122 : }
123 :
124 : ImmutableList.Builder<CommitValidationMessage> validationMessageBuilder =
125 6 : ImmutableList.builder();
126 :
127 : // Load the new config
128 : Config newConfig;
129 : try {
130 6 : newConfig = loadNewConfig(receiveEvent);
131 0 : } catch (ConfigInvalidException e) {
132 : // The current config is invalid, hence we cannot inspect the delta.
133 : // Rejecting invalid configs is not the responsibility of this validator, hence ignore this
134 : // exception here.
135 0 : logger.atWarning().log(
136 : "cannot inspect the project config, because parsing %s from revision %s"
137 : + " in project %s failed: %s",
138 : ProjectConfig.PROJECT_CONFIG,
139 0 : receiveEvent.commit.name(),
140 0 : receiveEvent.getProjectNameKey(),
141 0 : e.getMessage());
142 0 : return ImmutableList.of();
143 6 : }
144 :
145 : // Load the old config
146 6 : Optional<Config> oldConfig = loadOldConfig(receiveEvent);
147 :
148 6 : for (String labelName : newConfig.getSubsections(ProjectConfig.LABEL)) {
149 1 : for (String deprecatedFlag : DEPRECATED_FLAGS.keySet()) {
150 1 : if (flagChangedOrNewlySet(newConfig, oldConfig.orElse(null), labelName, deprecatedFlag)) {
151 1 : validationMessageBuilder.add(
152 : new CommitValidationMessage(
153 1 : String.format(
154 : "Parameter '%s.%s.%s' is deprecated and cannot be set,"
155 : + " use '%s' in '%s.%s.%s' instead.",
156 : ProjectConfig.LABEL,
157 : labelName,
158 : deprecatedFlag,
159 1 : DEPRECATED_FLAGS.get(deprecatedFlag),
160 : ProjectConfig.LABEL,
161 : labelName,
162 : ProjectConfig.KEY_COPY_CONDITION),
163 : ValidationMessage.Type.ERROR));
164 : }
165 1 : }
166 :
167 1 : if (copyValuesChangedOrNewlySet(newConfig, oldConfig.orElse(null), labelName)) {
168 1 : validationMessageBuilder.add(
169 : new CommitValidationMessage(
170 1 : String.format(
171 : "Parameter '%s.%s.%s' is deprecated and cannot be set,"
172 : + " use 'is:<copy-value>' in '%s.%s.%s' instead.",
173 : ProjectConfig.LABEL,
174 : labelName,
175 : KEY_COPY_VALUE,
176 : ProjectConfig.LABEL,
177 : labelName,
178 : ProjectConfig.KEY_COPY_CONDITION),
179 : ValidationMessage.Type.ERROR));
180 : }
181 :
182 : // Ban modifying label functions to any blocking function value
183 1 : if (flagChangedOrNewlySet(
184 1 : newConfig, oldConfig.orElse(null), labelName, ProjectConfig.KEY_FUNCTION)) {
185 1 : String fnName =
186 1 : newConfig.getString(ProjectConfig.LABEL, labelName, ProjectConfig.KEY_FUNCTION);
187 1 : Optional<LabelFunction> labelFn = LabelFunction.parse(fnName);
188 1 : if (labelFn.isPresent() && !isLabelFunctionAllowed(labelFn.get())) {
189 1 : validationMessageBuilder.add(
190 : new CommitValidationMessage(
191 1 : String.format(
192 : "Value '%s' of '%s.%s.%s' is not allowed and cannot be set."
193 : + " Label functions can only be set to {%s, %s, %s}."
194 : + " Use submit requirements instead of label functions.",
195 : fnName,
196 : ProjectConfig.LABEL,
197 : labelName,
198 : ProjectConfig.KEY_FUNCTION,
199 : LabelFunction.NO_BLOCK,
200 : LabelFunction.NO_OP,
201 : LabelFunction.PATCH_SET_LOCK),
202 : ValidationMessage.Type.ERROR));
203 : }
204 : }
205 :
206 : // Ban deletions of label functions as well since the default is MaxWithBlock
207 1 : if (flagDeleted(newConfig, oldConfig.orElse(null), labelName, ProjectConfig.KEY_FUNCTION)) {
208 1 : validationMessageBuilder.add(
209 : new CommitValidationMessage(
210 1 : String.format(
211 : "Cannot delete '%s.%s.%s'."
212 : + " Label functions can only be set to {%s, %s, %s}."
213 : + " Use submit requirements instead of label functions.",
214 : ProjectConfig.LABEL,
215 : labelName,
216 : ProjectConfig.KEY_FUNCTION,
217 : LabelFunction.NO_BLOCK,
218 : LabelFunction.NO_OP,
219 : LabelFunction.PATCH_SET_LOCK),
220 : ValidationMessage.Type.ERROR));
221 : }
222 1 : }
223 :
224 6 : ImmutableList<CommitValidationMessage> validationMessages = validationMessageBuilder.build();
225 6 : if (!validationMessages.isEmpty()) {
226 1 : throw new CommitValidationException(
227 1 : String.format(
228 : "invalid %s file in revision %s",
229 1 : ProjectConfig.PROJECT_CONFIG, receiveEvent.commit.getName()),
230 : validationMessages);
231 : }
232 6 : return ImmutableList.of();
233 0 : } catch (IOException | DiffNotAvailableException e) {
234 0 : String errorMessage =
235 0 : String.format(
236 : "failed to validate file %s for revision %s in ref %s of project %s",
237 : ProjectConfig.PROJECT_CONFIG,
238 0 : receiveEvent.commit.getName(),
239 : RefNames.REFS_CONFIG,
240 0 : receiveEvent.getProjectNameKey());
241 0 : logger.atSevere().withCause(e).log("%s", errorMessage);
242 0 : throw new CommitValidationException(errorMessage, e);
243 : }
244 : }
245 :
246 : /**
247 : * Whether the given file was changed in the given revision.
248 : *
249 : * @param receiveEvent the receive event
250 : * @param fileName the name of the file
251 : */
252 : private boolean isFileChanged(CommitReceivedEvent receiveEvent, String fileName)
253 : throws DiffNotAvailableException {
254 : Map<String, FileDiffOutput> fileDiffOutputs;
255 13 : if (receiveEvent.commit.getParentCount() > 0) {
256 : // normal commit with one parent: use listModifiedFilesAgainstParent with parentNum = 1 to
257 : // compare against the only parent (using parentNum = 0 to compare against the default parent
258 : // would also work)
259 : // merge commit with 2 or more parents: must use listModifiedFilesAgainstParent with parentNum
260 : // = 1 to compare against the first parent (using parentNum = 0 would compare against the
261 : // auto-merge)
262 10 : fileDiffOutputs =
263 10 : diffOperations.listModifiedFilesAgainstParent(
264 10 : receiveEvent.getProjectNameKey(), receiveEvent.commit, 1, DiffOptions.DEFAULTS);
265 : } else {
266 : // initial commit: must use listModifiedFilesAgainstParent with parentNum = 0
267 4 : fileDiffOutputs =
268 4 : diffOperations.listModifiedFilesAgainstParent(
269 4 : receiveEvent.getProjectNameKey(),
270 : receiveEvent.commit,
271 : /* parentNum=*/ 0,
272 : DiffOptions.DEFAULTS);
273 : }
274 13 : return fileDiffOutputs.keySet().contains(fileName);
275 : }
276 :
277 : private Config loadNewConfig(CommitReceivedEvent receiveEvent)
278 : throws IOException, ConfigInvalidException {
279 6 : ProjectLevelConfig.Bare bareConfig = new ProjectLevelConfig.Bare(ProjectConfig.PROJECT_CONFIG);
280 6 : bareConfig.load(receiveEvent.project.getNameKey(), receiveEvent.revWalk, receiveEvent.commit);
281 6 : return bareConfig.getConfig();
282 : }
283 :
284 : private Optional<Config> loadOldConfig(CommitReceivedEvent receiveEvent) throws IOException {
285 6 : if (receiveEvent.commit.getParentCount() == 0) {
286 : // initial commit, an old config doesn't exist
287 1 : return Optional.empty();
288 : }
289 :
290 : try {
291 6 : ProjectLevelConfig.Bare bareConfig =
292 : new ProjectLevelConfig.Bare(ProjectConfig.PROJECT_CONFIG);
293 6 : bareConfig.load(
294 6 : receiveEvent.project.getNameKey(),
295 : receiveEvent.revWalk,
296 6 : receiveEvent.commit.getParent(0));
297 6 : return Optional.of(bareConfig.getConfig());
298 0 : } catch (ConfigInvalidException e) {
299 : // the old config is not parseable, treat this the same way as if an old config didn't exist
300 : // so that all parameters in the new config are validated
301 0 : logger.atWarning().log(
302 : "cannot inspect the old project config, because parsing %s from parent revision %s"
303 : + " in project %s failed: %s",
304 : ProjectConfig.PROJECT_CONFIG,
305 0 : receiveEvent.commit.name(),
306 0 : receiveEvent.getProjectNameKey(),
307 0 : e.getMessage());
308 0 : return Optional.empty();
309 : }
310 : }
311 :
312 : private static boolean flagChangedOrNewlySet(
313 : Config newConfig, @Nullable Config oldConfig, String labelName, String key) {
314 1 : if (oldConfig == null) {
315 1 : return newConfig.getNames(ProjectConfig.LABEL, labelName).contains(key);
316 : }
317 :
318 : // Use getString rather than getBoolean so that we do not have to deal with values that cannot
319 : // be parsed as a boolean.
320 1 : String oldValue = oldConfig.getString(ProjectConfig.LABEL, labelName, key);
321 1 : String newValue = newConfig.getString(ProjectConfig.LABEL, labelName, key);
322 1 : return newValue != null && !newValue.equals(oldValue);
323 : }
324 :
325 : private static boolean flagDeleted(
326 : Config newConfig, @Nullable Config oldConfig, String labelName, String key) {
327 1 : if (oldConfig == null) {
328 1 : return false;
329 : }
330 1 : String oldValue = oldConfig.getString(ProjectConfig.LABEL, labelName, key);
331 1 : String newValue = newConfig.getString(ProjectConfig.LABEL, labelName, key);
332 1 : return oldValue != null && newValue == null;
333 : }
334 :
335 : private static boolean copyValuesChangedOrNewlySet(
336 : Config newConfig, @Nullable Config oldConfig, String labelName) {
337 1 : if (oldConfig == null) {
338 1 : return newConfig.getNames(ProjectConfig.LABEL, labelName).contains(KEY_COPY_VALUE);
339 : }
340 :
341 : // Ignore the order in which the copy values are defined in the new and old config, since the
342 : // order doesn't matter for this parameter.
343 1 : ImmutableSet<String> oldValues =
344 1 : ImmutableSet.copyOf(
345 1 : oldConfig.getStringList(ProjectConfig.LABEL, labelName, KEY_COPY_VALUE));
346 1 : ImmutableSet<String> newValues =
347 1 : ImmutableSet.copyOf(
348 1 : newConfig.getStringList(ProjectConfig.LABEL, labelName, KEY_COPY_VALUE));
349 1 : return !newValues.isEmpty() && !Sets.difference(newValues, oldValues).isEmpty();
350 : }
351 :
352 : private static boolean isLabelFunctionAllowed(LabelFunction labelFunction) {
353 1 : return labelFunction.equals(LabelFunction.NO_BLOCK)
354 1 : || labelFunction.equals(LabelFunction.NO_OP)
355 1 : || labelFunction.equals(LabelFunction.PATCH_SET_LOCK);
356 : }
357 : }
|