Add comment validator for cumulative size

Even though we have validators for comment count and individual comment
size, storage systems fail before both are maxed out.

Change-Id: I93a6374baa9bad4d82f4e51f9593315c1ca68d48
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index d5af678..7dcd97e 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -1221,7 +1221,15 @@
 be off by roughly 1%. Common unit suffixes of 'k', 'm', or 'g' are supported.
 The value must be positive.
 +
-The default limit is 16kB.
+The default limit is 16kiB.
+
+[[change.cumulativeCommentSizeLimit]]change.cumulativeCommentSizeLimit::
++
+Maximum allowed size in characters of all comments (including robot comments)
+and change messages. Size computation is approximate and may be off by roughly
+1%. Common unit suffixes of 'k', 'm', or 'g' are supported.
++
+The default limit is 3MiB.
 
 [[change.disablePrivateChanges]]change.disablePrivateChanges::
 +
@@ -1348,7 +1356,7 @@
 and may be off by roughly 1%. Common unit suffixes of 'k', 'm', or 'g' are
 supported. Zero or negative values allow robot comments of unlimited size.
 +
-The default limit is 1024kB.
+The default limit is 1MiB.
 
 [[change.showAssigneeInChangesTable]]change.showAssigneeInChangesTable::
 +
diff --git a/java/com/google/gerrit/server/config/GerritGlobalModule.java b/java/com/google/gerrit/server/config/GerritGlobalModule.java
index 5c52db3..1f8075a 100644
--- a/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -129,6 +129,7 @@
 import com.google.gerrit.server.git.TransferConfig;
 import com.google.gerrit.server.git.receive.ReceiveCommitsModule;
 import com.google.gerrit.server.git.validators.CommentCountValidator;
+import com.google.gerrit.server.git.validators.CommentCumulativeSizeValidator;
 import com.google.gerrit.server.git.validators.CommentSizeValidator;
 import com.google.gerrit.server.git.validators.CommitValidationListener;
 import com.google.gerrit.server.git.validators.MergeValidationListener;
@@ -419,6 +420,9 @@
     bind(CommentValidator.class)
         .annotatedWith(Exports.named(CommentSizeValidator.class.getSimpleName()))
         .to(CommentSizeValidator.class);
+    bind(CommentValidator.class)
+        .annotatedWith(Exports.named(CommentCumulativeSizeValidator.class.getSimpleName()))
+        .to(CommentCumulativeSizeValidator.class);
 
     DynamicMap.mapOf(binder(), DynamicOptions.DynamicBean.class);
     DynamicMap.mapOf(binder(), ChangeQueryBuilder.ChangeOperatorFactory.class);
diff --git a/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java b/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
new file mode 100644
index 0000000..d9a1420
--- /dev/null
+++ b/java/com/google/gerrit/server/git/validators/CommentCumulativeSizeValidator.java
@@ -0,0 +1,76 @@
+// Copyright (C) 2020 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.google.gerrit.server.git.validators;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.entities.Change;
+import com.google.gerrit.entities.Comment;
+import com.google.gerrit.entities.Project;
+import com.google.gerrit.extensions.validators.CommentForValidation;
+import com.google.gerrit.extensions.validators.CommentValidationContext;
+import com.google.gerrit.extensions.validators.CommentValidationFailure;
+import com.google.gerrit.extensions.validators.CommentValidator;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.inject.Inject;
+import java.util.stream.Stream;
+import org.eclipse.jgit.lib.Config;
+
+/**
+ * Limits the total size of all comments and change messages to prevent space/time complexity
+ * issues. Note that autogenerated change messages are not subject to validation.
+ */
+public class CommentCumulativeSizeValidator implements CommentValidator {
+  private final int maxCumulativeSize;
+  private final ChangeNotes.Factory notesFactory;
+
+  @Inject
+  CommentCumulativeSizeValidator(
+      @GerritServerConfig Config serverConfig, ChangeNotes.Factory notesFactory) {
+    this.notesFactory = notesFactory;
+    maxCumulativeSize = serverConfig.getInt("change", "cumulativeCommentSizeLimit", 3 << 20);
+  }
+
+  @Override
+  public ImmutableList<CommentValidationFailure> validateComments(
+      CommentValidationContext ctx, ImmutableList<CommentForValidation> comments) {
+    ChangeNotes notes =
+        notesFactory.createChecked(Project.nameKey(ctx.getProject()), Change.id(ctx.getChangeId()));
+    int existingCumulativeSize =
+        Stream.concat(
+                    notes.getComments().values().stream(),
+                    notes.getRobotComments().values().stream())
+                .mapToInt(Comment::getApproximateSize)
+                .sum()
+            + notes.getChangeMessages().stream().mapToInt(cm -> cm.getMessage().length()).sum();
+    int newCumulativeSize =
+        comments.stream().mapToInt(CommentForValidation::getApproximateSize).sum();
+    ImmutableList.Builder<CommentValidationFailure> failures = ImmutableList.builder();
+    if (!comments.isEmpty() && existingCumulativeSize + newCumulativeSize > maxCumulativeSize) {
+      // This warning really applies to the set of all comments, but we need to pick one to attach
+      // the message to.
+      CommentForValidation commentForFailureMessage = Iterables.getLast(comments);
+
+      failures.add(
+          commentForFailureMessage.failValidation(
+              String.format(
+                  "Exceeding maximum cumulative size of comments and change messages:"
+                      + " %d (existing) + %d (new) > %d",
+                  existingCumulativeSize, newCumulativeSize, maxCumulativeSize)));
+    }
+    return failures.build();
+  }
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
index 9a71da6..be0cc04 100644
--- a/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/change/PostReviewIT.java
@@ -352,6 +352,36 @@
     assertThat(getRobotComments(r.getChangeId())).hasSize(1);
   }
 
+  @Test
+  @GerritConfig(name = "change.cumulativeCommentSizeLimit", value = "7k")
+  public void validateCumulativeCommentSize() throws Exception {
+    PushOneCommit.Result r = createChange();
+    when(mockCommentValidator.validateComments(eq(contextFor(r)), any()))
+        .thenReturn(ImmutableList.of());
+
+    // Use large sizes because autogenerated messages already have O(100) bytes.
+    String commentText2000Bytes = new String(new char[2000]).replace("\0", "x");
+    String filePath = r.getChange().currentFilePaths().get(0);
+    ReviewInput reviewInput = new ReviewInput().message(commentText2000Bytes);
+    CommentInput commentInput = new CommentInput();
+    commentInput.line = 1;
+    commentInput.message = commentText2000Bytes;
+    commentInput.path = filePath;
+    reviewInput.comments = ImmutableMap.of(filePath, ImmutableList.of(commentInput));
+
+    // Use up ~4000 bytes.
+    gApi.changes().id(r.getChangeId()).current().review(reviewInput);
+
+    // Hit the limit when trying that again.
+    BadRequestException exception =
+        assertThrows(
+            BadRequestException.class,
+            () -> gApi.changes().id(r.getChangeId()).current().review(reviewInput));
+    assertThat(exception)
+        .hasMessageThat()
+        .contains("Exceeding maximum cumulative size of comments");
+  }
+
   private List<RobotCommentInfo> getRobotComments(String changeId) throws RestApiException {
     return gApi.changes().id(changeId).robotComments().values().stream()
         .flatMap(Collection::stream)
diff --git a/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
index 471172a..d68cada 100644
--- a/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/git/receive/ReceiveCommitsCommentValidationIT.java
@@ -205,4 +205,26 @@
     assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).hasSize(1);
     amendResult.assertMessage("exceeding maximum number of comments");
   }
+
+  @Test
+  @GerritConfig(name = "change.cumulativeCommentSizeLimit", value = "500")
+  public void limitCumulativeCommentSize() throws Exception {
+    when(mockCommentValidator.validateComments(any(), any())).thenReturn(ImmutableList.of());
+    PushOneCommit.Result result = createChange();
+    String changeId = result.getChangeId();
+    String revId = result.getCommit().getName();
+    String filePath = result.getChange().currentFilePaths().get(0);
+    String commentText400Bytes = new String(new char[400]).replace("\0", "x");
+    DraftInput draftInline =
+        testCommentHelper.newDraft(filePath, Side.REVISION, 1, commentText400Bytes);
+    testCommentHelper.addDraft(changeId, revId, draftInline);
+    amendChange(changeId, "refs/for/master%publish-comments", admin, testRepo);
+    assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).hasSize(1);
+
+    draftInline = testCommentHelper.newDraft(filePath, Side.REVISION, 1, commentText400Bytes);
+    testCommentHelper.addDraft(changeId, revId, draftInline);
+    Result amendResult = amendChange(changeId, "refs/for/master%publish-comments", admin, testRepo);
+    assertThat(testCommentHelper.getPublishedComments(result.getChangeId())).hasSize(1);
+    amendResult.assertMessage("exceeding maximum cumulative size of comments");
+  }
 }
diff --git a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
index bdfe0e8..8cb2be4 100644
--- a/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/mail/MailProcessorIT.java
@@ -389,6 +389,47 @@
     assertThat(message.body()).contains("rejected one or more comments");
   }
 
+  @Test
+  @GerritConfig(name = "change.cumulativeCommentSizeLimit", value = "7k")
+  public void limitCumulativeCommentSize() throws Exception {
+    // Use large sizes because autogenerated messages already have O(100) bytes.
+    String commentText2000Bytes = new String(new char[2000]).replace("\0", "x");
+    String changeId = createChangeWithReview();
+    CommentInput commentInput = new CommentInput();
+    commentInput.line = 1;
+    commentInput.message = commentText2000Bytes;
+    commentInput.path = FILE_NAME;
+    ReviewInput reviewInput = new ReviewInput().message(commentText2000Bytes);
+    reviewInput.comments = ImmutableMap.of(FILE_NAME, ImmutableList.of(commentInput));
+    // Use up ~4000 bytes.
+    gApi.changes().id(changeId).current().review(reviewInput);
+
+    ChangeInfo changeInfo = gApi.changes().id(changeId).get();
+    List<CommentInfo> comments = gApi.changes().id(changeId).current().commentsAsList();
+    String ts =
+        MailProcessingUtil.rfcDateformatter.format(
+            ZonedDateTime.ofInstant(comments.get(0).updated.toInstant(), ZoneId.of("UTC")));
+
+    // Hit the limit when trying that again.
+    MailMessage.Builder mailMessage = messageBuilderWithDefaultFields();
+    String txt =
+        newPlaintextBody(
+            getChangeUrl(changeInfo) + "/1",
+            "change message: " + commentText2000Bytes,
+            "reply to comment: " + commentText2000Bytes,
+            null,
+            null);
+    mailMessage.textContent(txt + textFooterForChange(changeInfo._number, ts));
+
+    Collection<CommentInfo> commentsBefore = testCommentHelper.getPublishedComments(changeId);
+    mailProcessor.process(mailMessage.build());
+    assertThat(testCommentHelper.getPublishedComments(changeId)).isEqualTo(commentsBefore);
+
+    assertNotifyTo(user);
+    Message message = sender.nextMessage();
+    assertThat(message.body()).contains("rejected one or more comments");
+  }
+
   private String getChangeUrl(ChangeInfo changeInfo) {
     return canonicalWebUrl.get() + "c/" + changeInfo.project + "/+/" + changeInfo._number;
   }