Merge "Merge branch 'stable-3.5' into stable-3.6" into stable-3.6
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index ca72f8b..11f85dd 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -2342,6 +2342,25 @@
 
 will cause the metrics to be recorded under `my-metrics/${metric-name}`.
 
+Note that metric name can be expressed through a limited set of characters
+`[a-zA-Z0-9_-]+(/[a-zA-Z0-9_-]+)*` therefore one should either ensure it
+or call
+link:https://gerrit.googlesource.com/gerrit/+/master/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java[
+sanitizeMetricName,role=external,window=_blank] to have it enforced.
+
+The
+link:https://gerrit.googlesource.com/gerrit/+/master/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java[
+sanitizeMetricName,role=external,window=_blank] performs the following
+modifications:
+
+* leading `/` is replaced with `_`
+* doubled (or repeated more times) `/` are reduced to a single `/`
+* ending `/` is removed
+* all characters that are not `/a-zA-Z0-9_-` are replaced with
+  `\_0x[HEX CODE]_` (code is capitalized)
+* if the replacement prefix (`_0x`) is found then it is prepended with another
+  replacement prefix
+
 See the replication metrics in the
 link:https://gerrit.googlesource.com/plugins/replication/+/master/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationMetrics.java[
 replication plugin,role=external,window=_blank] for an example of usage.
diff --git a/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java b/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java
index 32be18d..0821e86 100644
--- a/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java
+++ b/java/com/google/gerrit/metrics/dropwizard/DropWizardMetricMaker.java
@@ -60,6 +60,7 @@
 import java.util.Set;
 import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.TimeUnit;
+import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
 /**
@@ -357,6 +358,8 @@
 
   private static final Pattern METRIC_NAME_PATTERN =
       Pattern.compile("[a-zA-Z0-9_-]+(/[a-zA-Z0-9_-]+)*");
+  private static final Pattern INVALID_CHAR_PATTERN = Pattern.compile("[^\\w-/]");
+  private static final String REPLACEMENT_PREFIX = "_0x";
 
   private static void checkMetricName(String name) {
     checkArgument(
@@ -366,24 +369,54 @@
         METRIC_NAME_PATTERN.pattern());
   }
 
+  /**
+   * Ensures that the sanitized metric name doesn't contain invalid characters and
+   * removes the risk of collision (between the sanitized metric names).
+   * Modifications to the input metric name:
+   * <ul>
+   *   <li/> leading <code>/</code> is replaced with <code>_</code>
+   *   <li/> doubled (or repeated more times) <code>/</code> are reduced to a single <code>/<code>
+   *   <li/> ending <code>/</code> is removed
+   *   <li/> all characters that are not <code>/a-zA-Z0-9_-</code> are replaced with <code>_0x[HEX CODE]_</code> (code is capitalized)
+   *   <li/> the replacement prefix <code>_0x</code> is prepended with another replacement prefix
+   * </ul>
+   *
+   * @param name name of the metric to sanitize
+   * @return sanitized metric name
+   */
   @Override
   public String sanitizeMetricName(String name) {
-    if (METRIC_NAME_PATTERN.matcher(name).matches()) {
+    if (METRIC_NAME_PATTERN.matcher(name).matches() && !name.contains(REPLACEMENT_PREFIX)) {
       return name;
     }
 
-    String first = name.substring(0, 1).replaceFirst("[^\\w-]", "_");
+    StringBuilder sanitizedName =
+        new StringBuilder(name.substring(0, 1).replaceFirst("[^\\w-]", "_"));
     if (name.length() == 1) {
-      return first;
+      return sanitizedName.toString();
     }
 
-    String result = first + name.substring(1).replaceAll("/[/]+", "/").replaceAll("[^\\w-/]", "_");
-
-    if (result.endsWith("/")) {
-      result = result.substring(0, result.length() - 1);
+    String slashSanitizedName = name.substring(1).replaceAll("/[/]+", "/");
+    if (slashSanitizedName.endsWith("/")) {
+      slashSanitizedName = slashSanitizedName.substring(0, slashSanitizedName.length() - 1);
     }
 
-    return result;
+    String replacementPrefixSanitizedName =
+        slashSanitizedName.replaceAll(REPLACEMENT_PREFIX, REPLACEMENT_PREFIX + REPLACEMENT_PREFIX);
+
+    for (int i = 0; i < replacementPrefixSanitizedName.length(); i++) {
+      Character c = replacementPrefixSanitizedName.charAt(i);
+      Matcher matcher = INVALID_CHAR_PATTERN.matcher(c.toString());
+      if (matcher.matches()) {
+        sanitizedName.append(REPLACEMENT_PREFIX);
+        sanitizedName.append(Integer.toHexString(c).toUpperCase());
+        sanitizedName.append('_');
+      } else {
+        sanitizedName.append(c);
+      }
+    }
+
+    return sanitizedName.toString();
   }
 
   static String name(Description.FieldOrdering ordering, String codeName, String fieldValues) {
diff --git a/java/com/google/gerrit/server/restapi/project/ListProjects.java b/java/com/google/gerrit/server/restapi/project/ListProjects.java
index cd68a2f..7c431da 100644
--- a/java/com/google/gerrit/server/restapi/project/ListProjects.java
+++ b/java/com/google/gerrit/server/restapi/project/ListProjects.java
@@ -338,7 +338,8 @@
             && state != HIDDEN
             && isNullOrEmpty(matchPrefix)
             && isNullOrEmpty(matchRegex)
-            && isNullOrEmpty(matchSubstring) // TODO: see Issue 10446
+            && isNullOrEmpty(
+                matchSubstring) // TODO: see https://issues.gerritcodereview.com/issues/40010295
             && type == FilterType.ALL
             && showBranch.isEmpty()
             && !showTree
diff --git a/javatests/com/google/gerrit/metrics/dropwizard/DropWizardMetricMakerTest.java b/javatests/com/google/gerrit/metrics/dropwizard/DropWizardMetricMakerTest.java
index 5777779..a50520ac 100644
--- a/javatests/com/google/gerrit/metrics/dropwizard/DropWizardMetricMakerTest.java
+++ b/javatests/com/google/gerrit/metrics/dropwizard/DropWizardMetricMakerTest.java
@@ -44,15 +44,33 @@
   }
 
   @Test
+  public void shouldNotSanitizeSafeName() throws Exception {
+    assertThat(metrics.sanitizeMetricName("metric/submetric1/submetric2/submetric3"))
+        .isEqualTo("metric/submetric1/submetric2/submetric3");
+  }
+
+  @Test
+  public void shouldNotCreateSimpleCollisions() throws Exception {
+    String sanitizedCase1 = metrics.sanitizeMetricName("foo_bar");
+    String sanitizedCase2 = metrics.sanitizeMetricName("foo+bar");
+    assertThat(sanitizedCase1).isNotEqualTo(sanitizedCase2);
+  }
+
+  @Test
+  public void shouldDoubleTheReplacementPrefix() throws Exception {
+    assertThat(metrics.sanitizeMetricName("foo_0x_bar")).isEqualTo("foo_0x_0x_bar");
+  }
+
+  @Test
   public void shouldSanitizeUnwantedChars() throws Exception {
     assertThat(metrics.sanitizeMetricName("very+confusing$long#metric@net/name^1"))
-        .isEqualTo("very_confusing_long_metric_net/name_1");
+        .isEqualTo("very_0x2B_confusing_0x24_long_0x23_metric_0x40_net/name_0x5E_1");
     assertThat(metrics.sanitizeMetricName("/metric/submetric")).isEqualTo("_metric/submetric");
   }
 
   @Test
   public void shouldReduceConsecutiveSlashesToOne() throws Exception {
-    assertThat(metrics.sanitizeMetricName("/metric//submetric1///submetric2/submetric3"))
+    assertThat(metrics.sanitizeMetricName("/metric///submetric1///submetric2/submetric3"))
         .isEqualTo("_metric/submetric1/submetric2/submetric3");
   }
 
diff --git a/tools/release_noter/release_noter.py b/tools/release_noter/release_noter.py
index 167f68a..184e089 100755
--- a/tools/release_noter/release_noter.py
+++ b/tools/release_noter/release_noter.py
@@ -96,7 +96,8 @@
 CHANGE_URL = "/c/gerrit/+/"
 COMMIT_URL = "/changes/?q=commit%3A"
 GERRIT_URL = "https://gerrit-review.googlesource.com"
-ISSUE_URL = "https://bugs.chromium.org/p/gerrit/issues/detail?id="
+ISSUE_URL_MONORAIL = "https://bugs.chromium.org/p/gerrit/issues/detail?id="
+ISSUE_URL_TRACKER = "https://issues.gerritcodereview.com/issues/"
 
 MARKDOWN = "release_noter"
 GIT_COMMAND = "git"
@@ -301,7 +302,10 @@
 def print_from(this_change, md):
     md.write("\n*")
     for issue in sorted(this_change.issues):
-        md.write(f" [Issue {issue}]({ISSUE_URL}{issue});\n ")
+      if len(issue) > 5:
+        md.write(f" [Issue {issue}]({ISSUE_URL_TRACKER}{issue});\n ")
+      else:
+        md.write(f" [Issue {issue}]({ISSUE_URL_MONORAIL}{issue});\n ")
     md.write(f" {this_change.subject}\n")