Merge "Refuse convert-ref-storage to reftable when usePerRequestRefCache is set"
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 6efa1db..6521e3b 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -60,8 +60,15 @@
---
[accountPatchReviewDb]
- url = jdbc:h2:file:/path/to/db
+ url = jdbc:h2:async:/path/to/db
---
++
+For legacy configurations, Gerrit also accepts the previous
+`jdbc:h2:file:/path/to/db` form as an alias for `jdbc:h2:async:/path/to/db`.
+H2 documents the `async:` file system as a workaround for applications that
+may interrupt embedded H2 operations, but not as a complete safety guarantee.
+Use a client/server database for the strongest isolation from interrupted
+request threads.
[[accountPatchReviewDb.poolLimit]]accountPatchReviewDb.poolLimit::
@@ -3645,7 +3652,8 @@
`X-Forwarded-Server`. These headers are typically set by Apache's
link:https://httpd.apache.org/docs/2.4/mod/mod_proxy.html#x-headers[mod_proxy,role=external,window=_blank].
+
-[NOTE]
+[[httpd.listenUrl.proxyHttpSecurity]]
+[WARNING]
--
For security reasons, make sure to only allow connections from a
trusted reverse proxy in your network, as clients could otherwise
@@ -3718,6 +3726,27 @@
By default, `\http://*:8080`.
--
+[[httpd.enableProxyProtocol]]httpd.enableProxyProtocol::
++
+If `true`, enables parsing PROXY protocol headers on incoming HTTP connections
+configured with `proxy-http://` or `proxy-https://`. This allows Gerrit to
+access the original client address and port instead of the reverse proxy
+address. A PROXY protocol header is optional; connections without one are
+handled normally.
+If both a PROXY protocol header and `X-Forwarded-For` are present, the
+`X-Forwarded-For` header takes precedence for the client address.
++
+[WARNING]
+This should only be enabled when *all* HTTP traffic on the listener comes
+from trusted proxies. A client that can connect directly to the HTTP
+listener can send a forged PROXY protocol header and spoof its source
+address.
+
+See also <<httpd.listenUrl.proxyHttpSecurity, the proxy-http security warning>>
+for related reverse-proxy security considerations.
++
+By default, `false`.
+
[[httpd.reuseAddress]]httpd.reuseAddress::
+
If `true`, permits the daemon to bind to the port even if the port
diff --git a/java/com/google/gerrit/acceptance/GerritServer.java b/java/com/google/gerrit/acceptance/GerritServer.java
index 31a0584..2364f05 100644
--- a/java/com/google/gerrit/acceptance/GerritServer.java
+++ b/java/com/google/gerrit/acceptance/GerritServer.java
@@ -123,6 +123,11 @@
@BindingAnnotation
public @interface TestSshServerAddress {}
+ /** Marker on {@link InetSocketAddress} for test HTTP server. */
+ @Retention(RUNTIME)
+ @BindingAnnotation
+ public @interface TestHttpServerAddress {}
+
@AutoValue
public abstract static class Description {
public static Description forTestClass(
@@ -655,6 +660,14 @@
? SocketUtil.resolve(cfg.getString("sshd", null, "listenAddress"), 0)
: null;
}
+
+ @Provides
+ @Singleton
+ @TestHttpServerAddress
+ InetSocketAddress getHttpAddress(@GerritServerConfig Config cfg) {
+ URI uri = URI.create(cfg.getString("httpd", null, "listenUrl"));
+ return new InetSocketAddress(uri.getHost(), uri.getPort());
+ }
};
return sysInjector.createChildInjector(module);
}
diff --git a/java/com/google/gerrit/entities/Permission.java b/java/com/google/gerrit/entities/Permission.java
index c212f0c..885edc6 100644
--- a/java/com/google/gerrit/entities/Permission.java
+++ b/java/com/google/gerrit/entities/Permission.java
@@ -60,7 +60,6 @@
public static final String SUBMIT = "submit";
public static final String SUBMIT_AS = "submitAs";
public static final String TOGGLE_WORK_IN_PROGRESS_STATE = "toggleWipState";
- public static final String POST_REVIEW_COMMENT = "postReviewComment";
public static final String VIEW_PRIVATE_CHANGES = "viewPrivateChanges";
public static final String AI_REVIEW = "aiReview";
@@ -100,7 +99,6 @@
NAMES_LC.add(SUBMIT.toLowerCase(Locale.US));
NAMES_LC.add(SUBMIT_AS.toLowerCase(Locale.US));
NAMES_LC.add(TOGGLE_WORK_IN_PROGRESS_STATE.toLowerCase(Locale.US));
- NAMES_LC.add(POST_REVIEW_COMMENT.toLowerCase(Locale.US));
NAMES_LC.add(VIEW_PRIVATE_CHANGES.toLowerCase(Locale.US));
NAMES_LC.add(AI_REVIEW.toLowerCase(Locale.US));
diff --git a/java/com/google/gerrit/pgm/http/jetty/JettyServer.java b/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
index 09cad57..faf9f16 100644
--- a/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
+++ b/java/com/google/gerrit/pgm/http/jetty/JettyServer.java
@@ -70,6 +70,8 @@
import org.eclipse.jetty.server.Handler;
import org.eclipse.jetty.server.HttpConfiguration;
import org.eclipse.jetty.server.HttpConnectionFactory;
+import org.eclipse.jetty.server.ProxyConnectionFactory;
+import org.eclipse.jetty.server.ProxyCustomizer;
import org.eclipse.jetty.server.Request;
import org.eclipse.jetty.server.SecureRequestCustomizer;
import org.eclipse.jetty.server.Server;
@@ -104,17 +106,24 @@
* }
* </code>
*
- * What we want to achieve here is to remember what it was the original proxy address before
- * calling super.customize() and give the possibility to fetch it later down the chain.
+ * What we want to achieve here is to remember the original proxy address before calling
+ * super.customize() and give the possibility to fetch it later down the chain. When
+ * PROXY protocol is enabled, ProxyCustomizer has already exposed the underlying socket
+ * peer address through its request attribute.
*/
- request.setAttribute(
- RemoteUserUtil.PROXY_REMOTE_ADDRESS_ATTR,
- ((InetSocketAddress) request.getConnectionMetaData().getRemoteSocketAddress())
- .getAddress()
- .getHostAddress());
+ String proxyRemoteAddress =
+ (String) request.getAttribute(ProxyCustomizer.REMOTE_ADDRESS_ATTRIBUTE_NAME);
+ if (proxyRemoteAddress == null) {
+ proxyRemoteAddress =
+ ((InetSocketAddress) request.getConnectionMetaData().getRemoteSocketAddress())
+ .getAddress()
+ .getHostAddress();
+ }
+ request.setAttribute(RemoteUserUtil.PROXY_REMOTE_ADDRESS_ATTR, proxyRemoteAddress);
return super.customize(request, responseHeaders);
}
};
+ private static final ProxyCustomizer PROXY_CUSTOMIZER = new ProxyCustomizer();
static class Lifecycle implements LifecycleListener {
private final JettyServer server;
@@ -335,11 +344,13 @@
final int acceptors = cfg.getInt("httpd", "acceptorThreads", 0);
final int selectors = cfg.getInt("httpd", "selectorThreads", 2);
final AuthType authType = cfg.getEnum("auth", null, "type", AuthType.OPENID);
+ final boolean enableProxyProtocol = cfg.getBoolean("httpd", null, "enableProxyProtocol", false);
- reverseProxy = isReverseProxied(listenUrls);
+ boolean allReverseProxy = true;
final Connector[] connectors = new Connector[listenUrls.length];
for (int idx = 0; idx < listenUrls.length; idx++) {
final URI u = listenUrls[idx];
+ final String scheme = u.getScheme();
final int defaultPort;
final ServerConnector c;
HttpConfiguration config = defaultConfig(requestHeaderSize);
@@ -371,10 +382,10 @@
UriCompliance.Violation.AMBIGUOUS_PATH_ENCODING,
UriCompliance.Violation.SUSPICIOUS_PATH_CHARACTERS)));
- if (AuthType.CLIENT_SSL_CERT_LDAP.equals(authType) && !"https".equals(u.getScheme())) {
+ if (AuthType.CLIENT_SSL_CERT_LDAP.equals(authType) && !"https".equals(scheme)) {
throw new IllegalArgumentException(
"Protocol '"
- + u.getScheme()
+ + scheme
+ "' "
+ " not supported in httpd.listenurl '"
+ u
@@ -383,11 +394,13 @@
+ "'; only 'https' is supported");
}
- if ("http".equals(u.getScheme())) {
+ if ("http".equals(scheme)) {
+ allReverseProxy = false;
defaultPort = 80;
- c = newServerConnector(server, acceptors, selectors, config);
+ c = newServerConnector(server, acceptors, selectors, config, false);
- } else if ("https".equals(u.getScheme())) {
+ } else if ("https".equals(scheme)) {
+ allReverseProxy = false;
SslContextFactory.Server ssl = new SslContextFactory.Server();
final Path keystore = getFile(cfg, "sslkeystore", "etc/keystore");
String password = cfg.getString("httpd", null, "sslkeypassword");
@@ -423,41 +436,43 @@
new SslConnectionFactory(ssl, "http/1.1"),
new HttpConnectionFactory(config));
- } else if ("proxy-http".equals(u.getScheme())) {
+ } else if ("proxy-http".equals(scheme) || "proxy-https".equals(scheme)) {
defaultPort = 8080;
+ // Add the proxy customizer first so the forwarding customizer can record the underlying
+ // peer address used for authentication before applying X-Forwarded-For to the request.
+ if (enableProxyProtocol) {
+ config.addCustomizer(PROXY_CUSTOMIZER);
+ }
config.addCustomizer(FORWARDED_REQUEST_CUSTOMIZER);
- c = newServerConnector(server, acceptors, selectors, config);
+ if ("proxy-https".equals(scheme)) {
+ // For a proxy that terminates TLS, mark every request as HTTPS
+ // unconditionally. ForwardedRequestCustomizer alone only sets
+ // isSecure() when the proxy sends X-Forwarded-Proto=https or
+ // X-Proxied-Https=on; this wrapper covers proxies that don't.
+ // Jetty 12's HttpConfiguration.Customizer returns a (possibly
+ // wrapped) Request, so wrap the URI's scheme and override isSecure().
+ config.addCustomizer(
+ (request, responseHeaders) ->
+ new Request.Wrapper(request) {
+ @Override
+ public HttpURI getHttpURI() {
+ return HttpURI.build(super.getHttpURI())
+ .scheme(HttpScheme.HTTPS.asString())
+ .asImmutable();
+ }
- } else if ("proxy-https".equals(u.getScheme())) {
- defaultPort = 8080;
- config.addCustomizer(FORWARDED_REQUEST_CUSTOMIZER);
- // For a proxy that terminates TLS, mark every request as HTTPS
- // unconditionally. ForwardedRequestCustomizer alone only sets
- // isSecure() when the proxy sends X-Forwarded-Proto=https or
- // X-Proxied-Https=on; this wrapper covers proxies that don't.
- // Jetty 12's HttpConfiguration.Customizer returns a (possibly
- // wrapped) Request, so wrap the URI's scheme and override isSecure().
- config.addCustomizer(
- (request, responseHeaders) ->
- new Request.Wrapper(request) {
- @Override
- public HttpURI getHttpURI() {
- return HttpURI.build(super.getHttpURI())
- .scheme(HttpScheme.HTTPS.asString())
- .asImmutable();
- }
-
- @Override
- public boolean isSecure() {
- return true;
- }
- });
- c = newServerConnector(server, acceptors, selectors, config);
+ @Override
+ public boolean isSecure() {
+ return true;
+ }
+ });
+ }
+ c = newServerConnector(server, acceptors, selectors, config, enableProxyProtocol);
} else {
throw new IllegalArgumentException(
"Protocol '"
- + u.getScheme()
+ + scheme
+ "' "
+ " not supported in httpd.listenurl '"
+ u
@@ -490,13 +505,30 @@
c.setIdleTimeout(cfg.getTimeUnit("httpd", null, "idleTimeout", 30000L, MILLISECONDS));
connectors[idx] = c;
}
+ reverseProxy = allReverseProxy;
return connectors;
}
- private static ServerConnector newServerConnector(
- Server server, int acceptors, int selectors, HttpConfiguration config) {
- return new ServerConnector(
- server, null, null, null, acceptors, selectors, new HttpConnectionFactory(config));
+ @VisibleForTesting
+ static ServerConnector newServerConnector(
+ Server server,
+ int acceptors,
+ int selectors,
+ HttpConfiguration config,
+ boolean enableProxyProtocol) {
+ HttpConnectionFactory http = new HttpConnectionFactory(config);
+ if (enableProxyProtocol) {
+ return new ServerConnector(
+ server,
+ null,
+ null,
+ null,
+ acceptors,
+ selectors,
+ new ProxyConnectionFactory(http.getProtocol()),
+ http);
+ }
+ return new ServerConnector(server, null, null, null, acceptors, selectors, http);
}
private HttpConfiguration defaultConfig(int requestHeaderSize) {
@@ -520,15 +552,6 @@
return config;
}
- static boolean isReverseProxied(URI[] listenUrls) {
- for (URI u : listenUrls) {
- if ("http".equals(u.getScheme()) || "https".equals(u.getScheme())) {
- return false;
- }
- }
- return true;
- }
-
static URI[] listenURLs(Config cfg) {
String[] urls = cfg.getStringList("httpd", null, "listenurl");
if (urls.length == 0) {
diff --git a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
index 83878fe..a12a2f6 100644
--- a/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
+++ b/java/com/google/gerrit/server/cache/h2/H2CacheFactory.java
@@ -34,6 +34,7 @@
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.config.ScheduleConfig;
import com.google.gerrit.server.config.ScheduleConfig.Schedule;
+import com.google.gerrit.server.util.H2Url;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
@@ -229,7 +230,7 @@
private <V, K> SqlStore<K, V> newSqlStore(PersistentCacheDef<K, V> def, long maxSize) {
String cacheName = def.name() + "-v" + COMPATIBILITY_VERSION;
StringBuilder url = new StringBuilder();
- url.append("jdbc:h2:file:")
+ url.append(H2Url.ASYNC_PREFIX)
.append(cacheDir.resolve(cacheName).toAbsolutePath().toString().replace(";", "\\;"));
if (h2CacheSize >= 0) {
url.append(";CACHE_SIZE=");
diff --git a/java/com/google/gerrit/server/schema/AllProjectsCreator.java b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
index b721717..0ea2e5c 100644
--- a/java/com/google/gerrit/server/schema/AllProjectsCreator.java
+++ b/java/com/google/gerrit/server/schema/AllProjectsCreator.java
@@ -227,10 +227,6 @@
grant(config, refsFor, Permission.PUSH, defaultUsersGroup);
grant(config, refsFor, Permission.PUSH_MERGE, defaultUsersGroup);
});
-
- config.upsertAccessSection(
- AccessSection.ALL,
- all -> grant(config, all, Permission.POST_REVIEW_COMMENT, defaultUsersGroup));
}
private void initDefaultAclsForServiceUsers(
diff --git a/java/com/google/gerrit/server/schema/GrantPostReviewCommentPermission.java b/java/com/google/gerrit/server/schema/GrantPostReviewCommentPermission.java
deleted file mode 100644
index e9f34fd..0000000
--- a/java/com/google/gerrit/server/schema/GrantPostReviewCommentPermission.java
+++ /dev/null
@@ -1,97 +0,0 @@
-// Copyright (C) 2025 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.schema;
-
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
-import static com.google.gerrit.server.schema.AclUtil.grant;
-
-import com.google.gerrit.entities.AccessSection;
-import com.google.gerrit.entities.GroupReference;
-import com.google.gerrit.entities.Permission;
-import com.google.gerrit.entities.Project;
-import com.google.gerrit.server.GerritPersonIdent;
-import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
-import com.google.gerrit.server.git.GitRepositoryManager;
-import com.google.gerrit.server.git.meta.MetaDataUpdate;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.project.ProjectConfig;
-import com.google.inject.Inject;
-import java.io.IOException;
-import java.util.concurrent.atomic.AtomicBoolean;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.PersonIdent;
-import org.eclipse.jgit.lib.Repository;
-
-/**
- * This class adds the "postReviewComment" permission to all hosts that call this method with the
- * relevant projectName. This class should be called with AllProjects as the project, by all hosts
- * before enabling the "postReviewComment" permission.
- */
-public class GrantPostReviewCommentPermission {
-
- private final GitRepositoryManager repoManager;
- private final ProjectConfig.Factory projectConfigFactory;
- private final SystemGroupBackend systemGroupBackend;
- private final PersonIdent serverUser;
-
- @Inject
- public GrantPostReviewCommentPermission(
- GitRepositoryManager repoManager,
- ProjectConfig.Factory projectConfigFactory,
- SystemGroupBackend systemGroupBackend,
- @GerritPersonIdent PersonIdent serverUser) {
- this.repoManager = repoManager;
- this.projectConfigFactory = projectConfigFactory;
- this.systemGroupBackend = systemGroupBackend;
- this.serverUser = serverUser;
- }
-
- public void execute(Project.NameKey projectName) throws IOException, ConfigInvalidException {
- GroupReference registeredUsers = systemGroupBackend.getGroup(REGISTERED_USERS);
- try (Repository repo = repoManager.openRepository(projectName)) {
- MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, projectName, repo);
- ProjectConfig projectConfig = projectConfigFactory.read(md);
-
- AtomicBoolean shouldExit = new AtomicBoolean(false);
- projectConfig.upsertAccessSection(
- AccessSection.ALL,
- all -> {
- Permission permissionOnRefsStar =
- all.build().getPermission(Permission.POST_REVIEW_COMMENT);
- if (permissionOnRefsStar != null) {
- if (permissionOnRefsStar.getRule(registeredUsers) == null) {
- // If admins already changed the permission, don't do anything.
- shouldExit.set(true);
- return;
- }
- // permission already exists on refs/* for Registered Users, don't do anything.
- return;
- }
- // If the permission doesn't exist on refs/* for Registered Users, grant it.
- grant(projectConfig, all, Permission.POST_REVIEW_COMMENT, registeredUsers);
- });
-
- if (shouldExit.get()) {
- return;
- }
-
- md.getCommitBuilder().setAuthor(serverUser);
- md.getCommitBuilder().setCommitter(serverUser);
- md.setMessage("Add Post Review Comment permission for all registered users\n");
-
- projectConfig.commit(md);
- }
- }
-}
diff --git a/java/com/google/gerrit/server/schema/H2JGitLockAccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/H2JGitLockAccountPatchReviewStore.java
index c66b3b4..5975474 100644
--- a/java/com/google/gerrit/server/schema/H2JGitLockAccountPatchReviewStore.java
+++ b/java/com/google/gerrit/server/schema/H2JGitLockAccountPatchReviewStore.java
@@ -20,6 +20,7 @@
import com.google.common.flogger.FluentLogger;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.util.H2Url;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import java.io.File;
@@ -41,7 +42,6 @@
@Singleton
public class H2JGitLockAccountPatchReviewStore extends H2CustomLockAccountPatchReviewStore {
private static final FluentLogger logger = FluentLogger.forEnclosingClass();
- private static final String H2_DB_URL_PREFIX = "jdbc:h2:file:";
private final File lockTarget;
@Inject
@@ -52,13 +52,15 @@
@VisibleForTesting
static File lockTargetFromUrl(String h2Url) {
- if (!h2Url.startsWith(H2_DB_URL_PREFIX)) {
+ String path;
+ if (h2Url.startsWith(H2Url.ASYNC_PREFIX)) {
+ path = h2Url.substring(H2Url.ASYNC_PREFIX.length());
+ } else if (h2Url.startsWith(H2Url.FILE_PREFIX)) {
+ path = h2Url.substring(H2Url.FILE_PREFIX.length());
+ } else {
throw new IllegalArgumentException("Not a valid H2 file URL: " + h2Url);
}
- // URL format: "jdbc:h2:file:/path/to/db" - where ";" in the path is escaped as "\;"
- String path = h2Url.substring(H2_DB_URL_PREFIX.length());
-
// Split on first unescaped ";" to drop options, then unescape "\;" in the path
return new File(
Iterables.get(Splitter.on(Pattern.compile("(?<!\\\\);")).split(path), 0)
diff --git a/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java b/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
index e1842f6..102d1ec 100644
--- a/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
+++ b/java/com/google/gerrit/server/schema/JdbcAccountPatchReviewStore.java
@@ -37,6 +37,7 @@
import com.google.gerrit.server.logging.Metadata;
import com.google.gerrit.server.logging.TraceContext;
import com.google.gerrit.server.logging.TraceContext.TraceTimer;
+import com.google.gerrit.server.util.H2Url;
import java.nio.file.Path;
import java.sql.Connection;
import java.sql.PreparedStatement;
@@ -153,6 +154,15 @@
if (url == null) {
return createH2Url(sitePaths.db_dir.resolve("account_patch_reviews"));
}
+ url = normalizeH2Url(url);
+ return url;
+ }
+
+ @VisibleForTesting
+ static String normalizeH2Url(String url) {
+ if (url.startsWith(H2Url.FILE_PREFIX)) {
+ return H2Url.ASYNC_PREFIX + url.substring(H2Url.FILE_PREFIX.length());
+ }
return url;
}
@@ -455,7 +465,7 @@
@VisibleForTesting
static String createH2Url(Path path) {
return new StringBuilder()
- .append("jdbc:h2:file:")
+ .append(H2Url.ASYNC_PREFIX)
.append(path.toAbsolutePath().toString())
.toString()
.replace(";", "\\;");
diff --git a/java/com/google/gerrit/server/schema/Schema_186.java b/java/com/google/gerrit/server/schema/Schema_186.java
index de1e315..7b68fb0 100644
--- a/java/com/google/gerrit/server/schema/Schema_186.java
+++ b/java/com/google/gerrit/server/schema/Schema_186.java
@@ -14,11 +14,8 @@
package com.google.gerrit.server.schema;
+/** Retains schema version 186 after reverting the Post Review Comment migration. */
public class Schema_186 implements NoteDbSchemaVersion {
@Override
- public void upgrade(NoteDbSchemaVersion.Arguments args, UpdateUI ui) throws Exception {
- new GrantPostReviewCommentPermission(
- args.repoManager, args.projectConfigFactory, args.systemGroupBackend, args.serverUser)
- .execute(args.allProjects);
- }
+ public void upgrade(NoteDbSchemaVersion.Arguments args, UpdateUI ui) throws Exception {}
}
diff --git a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
index ea1e4e4..52c1267 100644
--- a/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
+++ b/java/com/google/gerrit/server/schema/testing/AllProjectsCreatorTestUtil.java
@@ -56,7 +56,6 @@
return ImmutableList.of(
"[access \"refs/*\"]",
" read = group Administrators",
- " postReviewComment = group " + usersGroupName,
" read = block group Blocked Users",
"[access \"refs/for/*\"]",
" addPatchSet = group " + usersGroupName,
diff --git a/java/com/google/gerrit/server/util/H2Url.java b/java/com/google/gerrit/server/util/H2Url.java
new file mode 100644
index 0000000..575ca9c
--- /dev/null
+++ b/java/com/google/gerrit/server/util/H2Url.java
@@ -0,0 +1,22 @@
+// Copyright (C) 2026 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.util;
+
+public final class H2Url {
+ public static final String ASYNC_PREFIX = "jdbc:h2:async:";
+ public static final String FILE_PREFIX = "jdbc:h2:file:";
+
+ private H2Url() {}
+}
diff --git a/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java b/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
index eeeef99..5f29062 100644
--- a/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
+++ b/javatests/com/google/gerrit/acceptance/api/project/AccessIT.java
@@ -71,7 +71,6 @@
import com.google.gerrit.server.group.testing.TestGroupBackend;
import com.google.gerrit.server.permissions.RegexPermissionPolicy;
import com.google.gerrit.server.project.ProjectConfig;
-import com.google.gerrit.server.schema.GrantPostReviewCommentPermission;
import com.google.gerrit.server.schema.GrantRevertPermission;
import com.google.inject.Inject;
import java.util.Arrays;
@@ -103,7 +102,6 @@
@Inject private RequestScopeOperations requestScopeOperations;
@Inject private ExtensionRegistry extensionRegistry;
@Inject private GrantRevertPermission grantRevertPermission;
- @Inject private GrantPostReviewCommentPermission grantPostReviewCommentPermission;
private Project.NameKey newProjectName;
@@ -215,67 +213,6 @@
}
@Test
- public void grantPostReviewCommentPermission() throws Exception {
- String ref = "refs/*";
- String groupId = "global:Registered-Users";
-
- grantPostReviewCommentPermission.execute(newProjectName);
-
- ProjectAccessInfo info = pApi().access();
- assertThat(info.local.containsKey(ref)).isTrue();
- AccessSectionInfo accessSectionInfo = info.local.get(ref);
- assertThat(accessSectionInfo.permissions.containsKey(Permission.POST_REVIEW_COMMENT)).isTrue();
- PermissionInfo permissionInfo =
- accessSectionInfo.permissions.get(Permission.POST_REVIEW_COMMENT);
- assertThat(permissionInfo.rules.containsKey(groupId)).isTrue();
- PermissionRuleInfo permissionRuleInfo = permissionInfo.rules.get(groupId);
- assertThat(permissionRuleInfo.action).isEqualTo(PermissionRuleInfo.Action.ALLOW);
- }
-
- @Test
- public void grantPostReviewCommentPermissionOnlyWorksOnce() throws Exception {
- grantPostReviewCommentPermission.execute(newProjectName);
- grantPostReviewCommentPermission.execute(newProjectName);
-
- try (Repository repo = repoManager.openRepository(newProjectName)) {
- MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, newProjectName, repo);
- ProjectConfig projectConfig = projectConfigFactory.read(md);
- AccessSection all = projectConfig.getAccessSection(AccessSection.ALL);
-
- Permission permission = all.getPermission(Permission.POST_REVIEW_COMMENT);
- assertThat(permission.getRules()).hasSize(1);
- }
- }
-
- @Test
- public void grantPostReviewCommentPermissionDoesntOverrideAdminsPreferences() throws Exception {
- GroupReference otherGroup = systemGroupBackend.getGroup(ANONYMOUS_USERS);
-
- try (Repository repo = repoManager.openRepository(newProjectName)) {
- MetaDataUpdate md = new MetaDataUpdate(GitReferenceUpdated.DISABLED, newProjectName, repo);
- ProjectConfig projectConfig = projectConfigFactory.read(md);
- projectConfig.upsertAccessSection(
- AccessSection.ALL,
- all -> {
- grant(projectConfig, all, Permission.POST_REVIEW_COMMENT, otherGroup);
- });
- md.getCommitBuilder().setAuthor(admin.newIdent());
- md.getCommitBuilder().setCommitter(admin.newIdent());
- md.setMessage("Add Post Review Comment permission for anonymous users\n");
-
- projectConfig.commit(md);
- }
- projectCache.evict(newProjectName);
- ProjectAccessInfo expected = pApi().access();
-
- grantPostReviewCommentPermission.execute(newProjectName);
- projectCache.evict(newProjectName);
- ProjectAccessInfo actual = pApi().access();
- // Permissions don't change.
- assertThat(actual.local).isEqualTo(expected.local);
- }
-
- @Test
public void getDefaultInheritance() throws Exception {
String inheritedName = pApi().access().inheritsFrom.name;
assertThat(inheritedName).isEqualTo(AllProjectsNameProvider.DEFAULT);
diff --git a/javatests/com/google/gerrit/acceptance/server/httpd/BUILD b/javatests/com/google/gerrit/acceptance/server/httpd/BUILD
index d1a64c0..f05651d 100644
--- a/javatests/com/google/gerrit/acceptance/server/httpd/BUILD
+++ b/javatests/com/google/gerrit/acceptance/server/httpd/BUILD
@@ -1,7 +1,17 @@
+load("@rules_java//java:defs.bzl", "java_library")
load("//javatests/com/google/gerrit/acceptance:tests.bzl", "acceptance_tests")
+java_library(
+ name = "http_test_util",
+ srcs = ["HttpTestUtil.java"],
+ deps = [
+ "//lib:servlet-api",
+ ],
+)
+
acceptance_tests(
srcs = glob(["*IT.java"]),
group = "server_httpd",
labels = ["server"],
+ deps = [":http_test_util"],
)
diff --git a/javatests/com/google/gerrit/acceptance/server/httpd/HttpLogoutServletIT.java b/javatests/com/google/gerrit/acceptance/server/httpd/HttpLogoutServletIT.java
index 1dea800..62dac8f 100644
--- a/javatests/com/google/gerrit/acceptance/server/httpd/HttpLogoutServletIT.java
+++ b/javatests/com/google/gerrit/acceptance/server/httpd/HttpLogoutServletIT.java
@@ -22,7 +22,6 @@
import com.google.inject.Inject;
import java.io.IOException;
import java.net.InetAddress;
-import java.net.ServerSocket;
import java.net.URISyntaxException;
import java.util.Arrays;
import java.util.List;
@@ -47,7 +46,11 @@
Config cfg = new Config();
cfg.setString("auth", null, "logouturl", "/test-logout");
cfg.setString("gerrit", null, "canonicalWebUrl", "https://" + LOCALHOST + ":8443/");
- cfg.setString("httpd", null, "listenUrl", "proxy-https://" + LOCALHOST + ":" + getFreePort());
+ cfg.setString(
+ "httpd",
+ null,
+ "listenUrl",
+ "proxy-https://" + LOCALHOST + ":" + HttpTestUtil.getFreePort());
return cfg;
}
@@ -103,10 +106,4 @@
return Optional.empty();
}
}
-
- private static int getFreePort() throws IOException {
- try (ServerSocket s = new ServerSocket(0)) {
- return s.getLocalPort();
- }
- }
}
diff --git a/javatests/com/google/gerrit/acceptance/server/httpd/HttpTestUtil.java b/javatests/com/google/gerrit/acceptance/server/httpd/HttpTestUtil.java
new file mode 100644
index 0000000..9200c37
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/httpd/HttpTestUtil.java
@@ -0,0 +1,47 @@
+// Copyright (C) 2026 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.acceptance.server.httpd;
+
+import java.io.IOException;
+import java.net.ServerSocket;
+import javax.servlet.Filter;
+import javax.servlet.FilterChain;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+public final class HttpTestUtil {
+ static final String REMOTE_ADDRESS_HEADER = "X-Test-Remote-Address";
+
+ private HttpTestUtil() {}
+
+ static int getFreePort() throws IOException {
+ try (ServerSocket s = new ServerSocket(0)) {
+ return s.getLocalPort();
+ }
+ }
+
+ public static final class RemoteAddressFilter implements Filter {
+ @Override
+ public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
+ throws IOException, ServletException {
+ HttpServletResponse httpResponse = (HttpServletResponse) response;
+ httpResponse.setHeader(REMOTE_ADDRESS_HEADER, ((HttpServletRequest) request).getRemoteAddr());
+ chain.doFilter(request, response);
+ }
+ }
+}
diff --git a/javatests/com/google/gerrit/acceptance/server/httpd/JettyProxyProtocolIT.java b/javatests/com/google/gerrit/acceptance/server/httpd/JettyProxyProtocolIT.java
new file mode 100644
index 0000000..5ebbc32
--- /dev/null
+++ b/javatests/com/google/gerrit/acceptance/server/httpd/JettyProxyProtocolIT.java
@@ -0,0 +1,306 @@
+// Copyright (C) 2026 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.acceptance.server.httpd;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.gerrit.acceptance.GerritServer.TestHttpServerAddress;
+import com.google.gerrit.acceptance.StandaloneSiteTest;
+import com.google.gerrit.testing.ConfigSuite;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.net.InetAddress;
+import java.net.InetSocketAddress;
+import java.net.Socket;
+import java.nio.ByteBuffer;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Assume;
+import org.junit.Test;
+
+public class JettyProxyProtocolIT extends StandaloneSiteTest {
+ private static final String SERVER_ADDRESS = "127.0.0.1";
+ private static final String TRUSTED_PROXY_ADDRESS = "127.0.0.2";
+ private static final String PROXY_SOURCE_ADDRESS = "127.0.0.3";
+ private static final String UNTRUSTED_PEER_ADDRESS = "127.0.0.99";
+ private static final String FORWARDED_CLIENT_ADDRESS = "192.0.2.1";
+ private static final int PROXY_SOURCE_PORT = 12345;
+ private static final byte[] PROXY_PROTOCOL_V2_SIGNATURE = {
+ 0x0d, 0x0a, 0x0d, 0x0a, 0x00, 0x0d, 0x0a, 0x51, 0x55, 0x49, 0x54, 0x0a
+ };
+ private static final String PROXY_PROTOCOL_HEADER_PREFIX =
+ "PROXY TCP4 " + PROXY_SOURCE_ADDRESS + " " + SERVER_ADDRESS + " " + PROXY_SOURCE_PORT + " ";
+
+ @ConfigSuite.Default
+ public static Config config() throws IOException {
+ return config(true);
+ }
+
+ @ConfigSuite.Config
+ public static Config proxyProtocolDisabledConfig() throws IOException {
+ return config(false);
+ }
+
+ private static Config config(boolean enableProxyProtocol) throws IOException {
+ Config cfg = new Config();
+ cfg.setString("auth", null, "type", "HTTP");
+ cfg.setString("auth", null, "httpHeader", "X-Remote-User");
+ cfg.setStringList(
+ "auth", null, "httpTrustedProxyNetworks", List.of(TRUSTED_PROXY_ADDRESS + "/32"));
+ cfg.setBoolean("auth", null, "trustContainerAuth", true);
+ int serverPort = HttpTestUtil.getFreePort();
+ cfg.setString("httpd", null, "listenUrl", "proxy-http://" + SERVER_ADDRESS + ":" + serverPort);
+ cfg.setBoolean("httpd", null, "enableProxyProtocol", enableProxyProtocol);
+ cfg.setStringList(
+ "httpd", null, "filterClass", List.of(HttpTestUtil.RemoteAddressFilter.class.getName()));
+ return cfg;
+ }
+
+ @Inject @TestHttpServerAddress private InetSocketAddress httpAddress;
+
+ private enum ProxyProtocolFormat {
+ V1_PROXY,
+ V2_PROXY,
+ V2_LOCAL
+ }
+
+ private static final List<ProxyProtocolFormat> PROXY_FORMATS =
+ List.of(ProxyProtocolFormat.V1_PROXY, ProxyProtocolFormat.V2_PROXY);
+
+ @Test
+ public void trustedProxyAllowsHttpHeaderAuthenticationWithProxyProtocol() throws Exception {
+ Assume.assumeTrue(baseConfig.getBoolean("httpd", null, "enableProxyProtocol", false));
+
+ try (ServerContext ctx = startServer()) {
+ ctx.getInjector().injectMembers(this);
+ int serverPort = httpAddress.getPort();
+ String request =
+ "GET /a/accounts/self HTTP/1.1\r\n"
+ + "Host: localhost\r\n"
+ + "X-Remote-User: "
+ + admin.username()
+ + "\r\n"
+ + "Connection: close\r\n\r\n";
+ for (ProxyProtocolFormat format : PROXY_FORMATS) {
+ byte[] proxyRequest = createProxyRequest(format, serverPort, request);
+
+ HttpResponse proxyResponse = sendRequest(serverPort, proxyRequest, TRUSTED_PROXY_ADDRESS);
+ assertThat(proxyResponse.statusCode()).isEqualTo(200);
+ assertThat(proxyResponse.body()).contains(admin.username());
+ assertThat(proxyResponse.headers())
+ .contains(HttpTestUtil.REMOTE_ADDRESS_HEADER + ": " + PROXY_SOURCE_ADDRESS);
+ }
+ }
+ }
+
+ @Test
+ public void directHttpFromUntrustedPeerCannotUseHttpHeaderAuthentication() throws Exception {
+ Assume.assumeTrue(baseConfig.getBoolean("httpd", null, "enableProxyProtocol", false));
+
+ try (ServerContext ctx = startServer()) {
+ ctx.getInjector().injectMembers(this);
+ int serverPort = httpAddress.getPort();
+ String request =
+ "GET /a/accounts/self HTTP/1.1\r\n"
+ + "Host: localhost\r\n"
+ + "X-Remote-User: "
+ + admin.username()
+ + "\r\n"
+ + "Connection: close\r\n\r\n";
+
+ HttpResponse response = sendRequest(serverPort, request, UNTRUSTED_PEER_ADDRESS);
+ assertThat(response.statusCode()).isEqualTo(403);
+ }
+ }
+
+ @Test
+ public void untrustedProxyCannotUseProxyProtocolForHttpHeaderAuthentication() throws Exception {
+ Assume.assumeTrue(baseConfig.getBoolean("httpd", null, "enableProxyProtocol", false));
+
+ try (ServerContext ctx = startServer()) {
+ ctx.getInjector().injectMembers(this);
+ int serverPort = httpAddress.getPort();
+ String request =
+ "GET /a/accounts/self HTTP/1.1\r\n"
+ + "Host: localhost\r\n"
+ + "X-Remote-User: "
+ + admin.username()
+ + "\r\n"
+ + "Connection: close\r\n\r\n";
+ for (ProxyProtocolFormat format : PROXY_FORMATS) {
+ byte[] proxyRequest = createProxyRequest(format, serverPort, request);
+
+ HttpResponse response = sendRequest(serverPort, proxyRequest, UNTRUSTED_PEER_ADDRESS);
+ assertThat(response.statusCode()).isEqualTo(403);
+ }
+ }
+ }
+
+ @Test
+ public void xForwardedForTakesPrecedenceOverProxyProtocolAddress() throws Exception {
+ Assume.assumeTrue(baseConfig.getBoolean("httpd", null, "enableProxyProtocol", false));
+
+ try (ServerContext ctx = startServer()) {
+ ctx.getInjector().injectMembers(this);
+ int serverPort = httpAddress.getPort();
+ String request =
+ "GET /a/accounts/self HTTP/1.1\r\n"
+ + "Host: localhost\r\n"
+ + "X-Remote-User: "
+ + admin.username()
+ + "\r\n"
+ + "X-Forwarded-For: "
+ + FORWARDED_CLIENT_ADDRESS
+ + "\r\n"
+ + "Connection: close\r\n\r\n";
+ for (ProxyProtocolFormat format : PROXY_FORMATS) {
+ byte[] proxyRequest = createProxyRequest(format, serverPort, request);
+
+ HttpResponse response = sendRequest(serverPort, proxyRequest, TRUSTED_PROXY_ADDRESS);
+ assertThat(response.statusCode()).isEqualTo(200);
+ assertThat(response.body()).contains(admin.username());
+ assertThat(response.headers())
+ .contains(HttpTestUtil.REMOTE_ADDRESS_HEADER + ": " + FORWARDED_CLIENT_ADDRESS);
+ }
+ }
+ }
+
+ // A v2 LOCAL command describes a connection made by the proxy itself, not a relayed client.
+ // The receiver must ignore the address block and keep the actual socket peer address.
+ @Test
+ public void localCommandUsesActualPeerAddress() throws Exception {
+ Assume.assumeTrue(baseConfig.getBoolean("httpd", null, "enableProxyProtocol", false));
+
+ try (ServerContext ctx = startServer()) {
+ ctx.getInjector().injectMembers(this);
+ int serverPort = httpAddress.getPort();
+ String request =
+ "GET /a/accounts/self HTTP/1.1\r\n"
+ + "Host: localhost\r\n"
+ + "X-Remote-User: "
+ + admin.username()
+ + "\r\n"
+ + "Connection: close\r\n\r\n";
+ byte[] localRequest = createProxyRequest(ProxyProtocolFormat.V2_LOCAL, serverPort, request);
+
+ HttpResponse response = sendRequest(serverPort, localRequest, TRUSTED_PROXY_ADDRESS);
+ assertThat(response.statusCode()).isEqualTo(200);
+ assertThat(response.body()).contains(admin.username());
+ assertThat(response.headers())
+ .contains(HttpTestUtil.REMOTE_ADDRESS_HEADER + ": " + TRUSTED_PROXY_ADDRESS);
+ assertThat(response.headers()).doesNotContain(PROXY_SOURCE_ADDRESS);
+ }
+ }
+
+ @Test
+ public void proxyProtocolHeaderIsRejectedWhenDisabled() throws Exception {
+ Assume.assumeFalse(baseConfig.getBoolean("httpd", null, "enableProxyProtocol", false));
+
+ try (ServerContext ctx = startServer()) {
+ ctx.getInjector().injectMembers(this);
+ int serverPort = httpAddress.getPort();
+ String request =
+ "GET /a/accounts/self HTTP/1.1\r\n"
+ + "Host: localhost\r\n"
+ + "X-Remote-User: "
+ + admin.username()
+ + "\r\n"
+ + "Connection: close\r\n\r\n";
+ for (ProxyProtocolFormat format : PROXY_FORMATS) {
+ byte[] proxyRequest = createProxyRequest(format, serverPort, request);
+
+ HttpResponse response = sendRequest(serverPort, proxyRequest, TRUSTED_PROXY_ADDRESS);
+ assertThat(response.statusCode()).isEqualTo(400);
+ }
+ }
+ }
+
+ private static HttpResponse sendRequest(int port, String request, String sourceAddress)
+ throws IOException {
+ return sendRequest(port, request.getBytes(StandardCharsets.US_ASCII), sourceAddress);
+ }
+
+ private static HttpResponse sendRequest(int port, byte[] request, String sourceAddress)
+ throws IOException {
+ try (Socket socket = new Socket()) {
+ if (sourceAddress != null) {
+ socket.bind(new InetSocketAddress(sourceAddress, 0));
+ }
+ socket.connect(new InetSocketAddress(SERVER_ADDRESS, port));
+ socket.getOutputStream().write(request);
+ socket.getOutputStream().flush();
+ return parseResponse(
+ new String(socket.getInputStream().readAllBytes(), StandardCharsets.UTF_8));
+ }
+ }
+
+ private static byte[] createProxyRequest(
+ ProxyProtocolFormat format, int targetPort, String request) throws IOException {
+ if (format == ProxyProtocolFormat.V1_PROXY) {
+ return (PROXY_PROTOCOL_HEADER_PREFIX + targetPort + "\r\n" + request)
+ .getBytes(StandardCharsets.US_ASCII);
+ }
+ byte command = format == ProxyProtocolFormat.V2_LOCAL ? (byte) 0x20 : (byte) 0x21;
+ return concat(createProxyProtocolV2Header(command, targetPort), request);
+ }
+
+ private static byte[] createProxyProtocolV2Header(byte command, int targetPort)
+ throws IOException {
+ ByteBuffer header = ByteBuffer.allocate(28);
+ header.put(PROXY_PROTOCOL_V2_SIGNATURE);
+ header.put(command); // Version 2, PROXY or LOCAL command.
+ header.put((byte) 0x11); // IPv4, stream transport.
+ header.putShort((short) 12); // IPv4 addresses and ports: 4 + 4 + 2 + 2 bytes.
+ header.put(InetAddress.getByName(PROXY_SOURCE_ADDRESS).getAddress());
+ header.put(InetAddress.getByName(SERVER_ADDRESS).getAddress());
+ header.putShort((short) PROXY_SOURCE_PORT);
+ header.putShort((short) targetPort);
+ return header.array();
+ }
+
+ private static byte[] concat(byte[] header, String request) {
+ byte[] requestBytes = request.getBytes(StandardCharsets.US_ASCII);
+ byte[] result = new byte[header.length + requestBytes.length];
+ System.arraycopy(header, 0, result, 0, header.length);
+ System.arraycopy(requestBytes, 0, result, header.length, requestBytes.length);
+ return result;
+ }
+
+ private static HttpResponse parseResponse(String response) throws IOException {
+ int headerEnd = response.indexOf("\r\n\r\n");
+ if (headerEnd < 0) {
+ throw new IOException("HTTP response did not contain a header terminator");
+ }
+ int statusLineEnd = response.indexOf("\r\n");
+ if (statusLineEnd < 0 || statusLineEnd > headerEnd) {
+ throw new IOException("HTTP response did not contain a status line");
+ }
+ String[] statusLineParts = response.substring(0, statusLineEnd).split("\\s+", 3);
+ if (statusLineParts.length < 2 || !statusLineParts[0].startsWith("HTTP/")) {
+ throw new IOException("Invalid HTTP status line: " + response.substring(0, statusLineEnd));
+ }
+ try {
+ return new HttpResponse(
+ Integer.parseInt(statusLineParts[1]),
+ response.substring(statusLineEnd + 2, headerEnd),
+ response.substring(headerEnd + 4));
+ } catch (NumberFormatException e) {
+ throw new IOException("Invalid HTTP status code: " + statusLineParts[1], e);
+ }
+ }
+
+ private record HttpResponse(int statusCode, String headers, String body) {}
+}
diff --git a/javatests/com/google/gerrit/pgm/http/jetty/JettyServerTest.java b/javatests/com/google/gerrit/pgm/http/jetty/JettyServerTest.java
new file mode 100644
index 0000000..c84096c
--- /dev/null
+++ b/javatests/com/google/gerrit/pgm/http/jetty/JettyServerTest.java
@@ -0,0 +1,53 @@
+// Copyright (C) 2026 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.pgm.http.jetty;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.collect.ImmutableList;
+import org.eclipse.jetty.server.ConnectionFactory;
+import org.eclipse.jetty.server.HttpConfiguration;
+import org.eclipse.jetty.server.HttpConnectionFactory;
+import org.eclipse.jetty.server.ProxyConnectionFactory;
+import org.eclipse.jetty.server.Server;
+import org.eclipse.jetty.server.ServerConnector;
+import org.junit.Test;
+
+public class JettyServerTest {
+ @Test
+ public void proxyProtocolConnectorUsesProxyConnectionFactory() {
+ HttpConfiguration config = new HttpConfiguration();
+
+ ServerConnector connector = JettyServer.newServerConnector(new Server(), 0, 0, config, true);
+ ImmutableList<ConnectionFactory> factories =
+ ImmutableList.copyOf(connector.getConnectionFactories());
+
+ assertThat(factories).hasSize(2);
+ assertThat(factories.get(0)).isInstanceOf(ProxyConnectionFactory.class);
+ assertThat(factories.get(1)).isInstanceOf(HttpConnectionFactory.class);
+ }
+
+ @Test
+ public void regularConnectorDoesNotUseProxyProtocol() {
+ HttpConfiguration config = new HttpConfiguration();
+
+ ServerConnector connector = JettyServer.newServerConnector(new Server(), 0, 0, config, false);
+ ImmutableList<ConnectionFactory> factories =
+ ImmutableList.copyOf(connector.getConnectionFactories());
+
+ assertThat(factories).hasSize(1);
+ assertThat(factories.get(0)).isInstanceOf(HttpConnectionFactory.class);
+ }
+}
diff --git a/javatests/com/google/gerrit/server/cache/h2/H2CacheFactoryTest.java b/javatests/com/google/gerrit/server/cache/h2/H2CacheFactoryTest.java
index 9f55711..e06c564 100644
--- a/javatests/com/google/gerrit/server/cache/h2/H2CacheFactoryTest.java
+++ b/javatests/com/google/gerrit/server/cache/h2/H2CacheFactoryTest.java
@@ -66,7 +66,7 @@
assertThat(readField(store, "url"))
.isEqualTo(
- "jdbc:h2:file:"
+ "jdbc:h2:async:"
+ cacheDir.resolve("demo-cache-v2").toAbsolutePath()
+ ";DB_CLOSE_DELAY=-1");
}
diff --git a/javatests/com/google/gerrit/server/schema/H2JGitLockAccountPatchReviewStoreIT.java b/javatests/com/google/gerrit/server/schema/H2JGitLockAccountPatchReviewStoreIT.java
index bac1b31..ef4624a 100644
--- a/javatests/com/google/gerrit/server/schema/H2JGitLockAccountPatchReviewStoreIT.java
+++ b/javatests/com/google/gerrit/server/schema/H2JGitLockAccountPatchReviewStoreIT.java
@@ -82,6 +82,26 @@
}
@Test
+ public void legacyFileUrlStartsAndStoresReviewedFlag() throws Exception {
+ SitePaths sitePaths = new SitePaths(temporaryFolder.getRoot().toPath());
+ Config cfg = new Config();
+ cfg.setString(
+ JdbcAccountPatchReviewStore.ACCOUNT_PATCH_REVIEW_DB,
+ null,
+ "url",
+ "jdbc:h2:file:"
+ + temporaryFolder.newFolder("legacy-account-patch-reviews").toPath().resolve("db"));
+ H2JGitLockAccountPatchReviewStore legacyStore =
+ new H2JGitLockAccountPatchReviewStore(cfg, sitePaths);
+ legacyStore.start();
+
+ var unused = legacyStore.markReviewed(PS, ACCOUNT, FILE);
+
+ assertThat(legacyStore.findReviewed(PS, ACCOUNT)).isPresent();
+ assertThat(legacyStore.findReviewed(PS, ACCOUNT).get().files()).containsExactly(FILE);
+ }
+
+ @Test
public void concurrentMarksAreAllDurable() throws Exception {
int nThreads = 8;
int filesPerThread = 5;
diff --git a/javatests/com/google/gerrit/server/schema/H2JGitLockAccountPatchReviewStoreTest.java b/javatests/com/google/gerrit/server/schema/H2JGitLockAccountPatchReviewStoreTest.java
index c15d1c8..33b2ad6 100644
--- a/javatests/com/google/gerrit/server/schema/H2JGitLockAccountPatchReviewStoreTest.java
+++ b/javatests/com/google/gerrit/server/schema/H2JGitLockAccountPatchReviewStoreTest.java
@@ -24,18 +24,23 @@
public class H2JGitLockAccountPatchReviewStoreTest {
@Test
public void lockTargetFromUrl_plainPath() {
+ assertThat(lockTargetFromUrl("jdbc:h2:async:/path/to/db")).isEqualTo(new File("/path/to/db"));
+ }
+
+ @Test
+ public void lockTargetFromUrl_legacyFileUrl() {
assertThat(lockTargetFromUrl("jdbc:h2:file:/path/to/db")).isEqualTo(new File("/path/to/db"));
}
@Test
public void lockTargetFromUrl_stripsOptions() {
- assertThat(lockTargetFromUrl("jdbc:h2:file:/path/to/db;FILE_LOCK=NO;DB_CLOSE_DELAY=0"))
+ assertThat(lockTargetFromUrl("jdbc:h2:async:/path/to/db;FILE_LOCK=NO;DB_CLOSE_DELAY=0"))
.isEqualTo(new File("/path/to/db"));
}
@Test
public void lockTargetFromUrl_unescapesSemicolonInPath() {
- assertThat(lockTargetFromUrl("jdbc:h2:file:/path/with\\;semi/db;FILE_LOCK=NO"))
+ assertThat(lockTargetFromUrl("jdbc:h2:async:/path/with\\;semi/db;FILE_LOCK=NO"))
.isEqualTo(new File("/path/with;semi/db"));
}
diff --git a/javatests/com/google/gerrit/server/schema/JdbcAccountPatchReviewStoreTest.java b/javatests/com/google/gerrit/server/schema/JdbcAccountPatchReviewStoreTest.java
index d8c876b..18b9479 100644
--- a/javatests/com/google/gerrit/server/schema/JdbcAccountPatchReviewStoreTest.java
+++ b/javatests/com/google/gerrit/server/schema/JdbcAccountPatchReviewStoreTest.java
@@ -16,53 +16,118 @@
import static com.google.common.truth.Truth.assertThat;
+import com.google.gerrit.server.config.SitePaths;
import java.nio.file.Path;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.ResultSet;
+import java.sql.Statement;
+import org.eclipse.jgit.lib.Config;
+import org.junit.Rule;
import org.junit.Test;
+import org.junit.rules.TemporaryFolder;
public class JdbcAccountPatchReviewStoreTest {
+ @Rule public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
@Test
public void checkCreateH2Url() {
assertThat(JdbcAccountPatchReviewStore.createH2Url(Path.of("test")))
- .isEqualTo("jdbc:h2:file:" + Path.of("test").toAbsolutePath());
+ .isEqualTo("jdbc:h2:async:" + Path.of("test").toAbsolutePath());
assertThat(JdbcAccountPatchReviewStore.createH2Url(Path.of("test.db")))
- .isEqualTo("jdbc:h2:file:" + Path.of("test.db").toAbsolutePath());
+ .isEqualTo("jdbc:h2:async:" + Path.of("test.db").toAbsolutePath());
assertThat(JdbcAccountPatchReviewStore.createH2Url(Path.of("test.db.mv.db")))
- .isEqualTo("jdbc:h2:file:" + Path.of("test.db.mv.db").toAbsolutePath());
+ .isEqualTo("jdbc:h2:async:" + Path.of("test.db.mv.db").toAbsolutePath());
assertThat(JdbcAccountPatchReviewStore.createH2Url(Path.of("test.db.mv.db.trace.db")))
- .isEqualTo("jdbc:h2:file:" + Path.of("test.db.mv.db.trace.db").toAbsolutePath());
+ .isEqualTo("jdbc:h2:async:" + Path.of("test.db.mv.db.trace.db").toAbsolutePath());
}
@Test
public void checkCreateH2UrlWithPath() {
assertThat(JdbcAccountPatchReviewStore.createH2Url(Path.of("path/to/test")))
- .isEqualTo("jdbc:h2:file:" + Path.of("path/to/test").toAbsolutePath());
+ .isEqualTo("jdbc:h2:async:" + Path.of("path/to/test").toAbsolutePath());
assertThat(JdbcAccountPatchReviewStore.createH2Url(Path.of("path/to/test.db")))
- .isEqualTo("jdbc:h2:file:" + Path.of("path/to/test.db").toAbsolutePath());
+ .isEqualTo("jdbc:h2:async:" + Path.of("path/to/test.db").toAbsolutePath());
assertThat(JdbcAccountPatchReviewStore.createH2Url(Path.of("path/to/test.db.mv.db")))
- .isEqualTo("jdbc:h2:file:" + Path.of("path/to/test.db.mv.db").toAbsolutePath());
+ .isEqualTo("jdbc:h2:async:" + Path.of("path/to/test.db.mv.db").toAbsolutePath());
assertThat(JdbcAccountPatchReviewStore.createH2Url(Path.of("path/to/test.db.mv.db.trace.db")))
- .isEqualTo("jdbc:h2:file:" + Path.of("path/to/test.db.mv.db.trace.db").toAbsolutePath());
+ .isEqualTo("jdbc:h2:async:" + Path.of("path/to/test.db.mv.db.trace.db").toAbsolutePath());
}
@Test
public void checkCreateH2UrlWithSemicolon() {
assertThat(JdbcAccountPatchReviewStore.createH2Url(Path.of("test;test")))
.isEqualTo(
- "jdbc:h2:file:" + Path.of("test;test").toAbsolutePath().toString().replace(";", "\\;"));
+ "jdbc:h2:async:"
+ + Path.of("test;test").toAbsolutePath().toString().replace(";", "\\;"));
assertThat(JdbcAccountPatchReviewStore.createH2Url(Path.of("test.db;test")))
.isEqualTo(
- "jdbc:h2:file:"
+ "jdbc:h2:async:"
+ Path.of("test.db;test").toAbsolutePath().toString().replace(";", "\\;"));
assertThat(JdbcAccountPatchReviewStore.createH2Url(Path.of("test.db.mv.db;test")))
.isEqualTo(
- "jdbc:h2:file:"
+ "jdbc:h2:async:"
+ Path.of("test.db.mv.db;test").toAbsolutePath().toString().replace(";", "\\;"));
assertThat(JdbcAccountPatchReviewStore.createH2Url(Path.of("test.db.mv.db.trace.db;test")))
.isEqualTo(
- "jdbc:h2:file:"
+ "jdbc:h2:async:"
+ Path.of("test.db.mv.db.trace.db;test")
.toAbsolutePath()
.toString()
.replace(";", "\\;"));
}
+
+ @Test
+ public void normalizeH2Url_preservesAsyncUrl() {
+ assertThat(JdbcAccountPatchReviewStore.normalizeH2Url("jdbc:h2:async:/path/to/db;FILE_LOCK=NO"))
+ .isEqualTo("jdbc:h2:async:/path/to/db;FILE_LOCK=NO");
+ }
+
+ @Test
+ public void normalizeH2Url_convertsLegacyFileUrlToAsyncUrl() {
+ assertThat(JdbcAccountPatchReviewStore.normalizeH2Url("jdbc:h2:file:/path/to/db;FILE_LOCK=NO"))
+ .isEqualTo("jdbc:h2:async:/path/to/db;FILE_LOCK=NO");
+ }
+
+ @Test
+ public void getUrl_normalizesLegacyFileUrlFromConfig() throws Exception {
+ Config cfg = new Config();
+ cfg.setString(
+ JdbcAccountPatchReviewStore.ACCOUNT_PATCH_REVIEW_DB,
+ null,
+ "url",
+ "jdbc:h2:file:/path/to/db;FILE_LOCK=NO");
+
+ assertThat(
+ JdbcAccountPatchReviewStore.getUrl(
+ cfg, new SitePaths(temporaryFolder.getRoot().toPath())))
+ .isEqualTo("jdbc:h2:async:/path/to/db;FILE_LOCK=NO");
+ }
+
+ @Test
+ public void normalizeH2Url_preservesNonFileH2Url() {
+ assertThat(JdbcAccountPatchReviewStore.normalizeH2Url("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1"))
+ .isEqualTo("jdbc:h2:mem:test;DB_CLOSE_DELAY=-1");
+ }
+
+ @Test
+ public void createH2Url_canCreateAndReadAsyncH2Database() throws Exception {
+ String url =
+ JdbcAccountPatchReviewStore.createH2Url(
+ temporaryFolder.newFolder("db").toPath().resolve("account_patch_reviews"));
+
+ try (Connection con = DriverManager.getConnection(url);
+ Statement stmt = con.createStatement()) {
+ stmt.executeUpdate("CREATE TABLE account_patch_reviews (id INT PRIMARY KEY, path VARCHAR)");
+ stmt.executeUpdate("INSERT INTO account_patch_reviews VALUES (1, 'README.md')");
+ }
+
+ try (Connection con = DriverManager.getConnection(url);
+ Statement stmt = con.createStatement();
+ ResultSet rs = stmt.executeQuery("SELECT path FROM account_patch_reviews WHERE id = 1")) {
+ assertThat(rs.next()).isTrue();
+ assertThat(rs.getString(1)).isEqualTo("README.md");
+ assertThat(rs.next()).isFalse();
+ }
+ }
}
diff --git a/javatests/com/google/gerrit/server/util/H2UrlTest.java b/javatests/com/google/gerrit/server/util/H2UrlTest.java
new file mode 100644
index 0000000..e2e0ac4
--- /dev/null
+++ b/javatests/com/google/gerrit/server/util/H2UrlTest.java
@@ -0,0 +1,27 @@
+// Copyright (C) 2026 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.util;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import org.junit.Test;
+
+public class H2UrlTest {
+ @Test
+ public void prefixes() {
+ assertThat(H2Url.ASYNC_PREFIX).isEqualTo("jdbc:h2:async:");
+ assertThat(H2Url.FILE_PREFIX).isEqualTo("jdbc:h2:file:");
+ }
+}