Merge branch 'stable-3.10'

* stable-3.10:
  PushOne: Remove unused addRef() method
  TasksStorage: Remove synchronized from methods
  Place the replaying flag clearing in a finally
  Demote delete errors when distributor is enabled
  distributor: Reduce log level for no-op consolidations

Change-Id: I3ce1519c44ec6cbae7725527ea626e55401cc488
Release-Notes: skip
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadConfigDecorator.java b/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadConfigDecorator.java
index 2c049fc..5e2c758 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadConfigDecorator.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadConfigDecorator.java
@@ -128,4 +128,9 @@
   public Config getConfig() {
     return currentConfig.getConfig();
   }
+
+  @Override
+  public boolean useLegacyCredentials() {
+    return currentConfig.useLegacyCredentials();
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadSecureCredentialsFactoryDecorator.java b/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadSecureCredentialsFactoryDecorator.java
index e9409a6..32c439b 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadSecureCredentialsFactoryDecorator.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/AutoReloadSecureCredentialsFactoryDecorator.java
@@ -14,49 +14,60 @@
 
 package com.googlesource.gerrit.plugins.replication;
 
-import static com.google.gerrit.common.FileUtil.lastModified;
-
 import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.common.UsedAt.Project;
 import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.securestore.SecureStore;
 import com.google.inject.Inject;
 import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig;
 import java.io.IOException;
-import java.nio.file.Files;
 import java.util.concurrent.atomic.AtomicReference;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.transport.CredentialsProvider;
 
+@UsedAt(Project.PLUGIN_PULL_REPLICATION)
 public class AutoReloadSecureCredentialsFactoryDecorator implements CredentialsFactory {
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
-  private final AtomicReference<SecureCredentialsFactory> secureCredentialsFactory;
-  private volatile long secureCredentialsFactoryLoadTs;
+  private final AtomicReference<CredentialsFactory> secureCredentialsFactory;
+  private final SecureStore secureStore;
   private final SitePaths site;
-  private ReplicationConfig config;
+  private final ReplicationConfig config;
 
   @Inject
-  public AutoReloadSecureCredentialsFactoryDecorator(SitePaths site, ReplicationConfig config)
+  public AutoReloadSecureCredentialsFactoryDecorator(
+      SitePaths site, SecureStore secureStore, ReplicationConfig config)
       throws ConfigInvalidException, IOException {
     this.site = site;
+    this.secureStore = secureStore;
     this.config = config;
-    this.secureCredentialsFactory = new AtomicReference<>(new SecureCredentialsFactory(site));
-    this.secureCredentialsFactoryLoadTs = getSecureConfigLastEditTs();
+    this.secureCredentialsFactory =
+        new AtomicReference<>(newSecureCredentialsFactory(site, secureStore, config));
+    if (config.useLegacyCredentials()) {
+      logger.atWarning().log(
+          "Using legacy credentials in clear text in secure.config. Please encrypt your credentials using "
+              + "'java -jar gerrit.war passwd' for each remote, remove the gerrit.useLegacyCredentials in replication.config "
+              + "and then reload the replication plugin.");
+    }
   }
 
-  private long getSecureConfigLastEditTs() {
-    if (!Files.exists(site.secure_config)) {
-      return 0L;
+  private static CredentialsFactory newSecureCredentialsFactory(
+      SitePaths site, SecureStore secureStore, ReplicationConfig config)
+      throws ConfigInvalidException, IOException {
+    if (config.useLegacyCredentials()) {
+      return new LegacyCredentialsFactory(site);
     }
-    return lastModified(site.secure_config);
+    return new SecureCredentialsFactory(secureStore);
   }
 
   @Override
   public CredentialsProvider create(String remoteName) {
     try {
       if (needsReload()) {
+        secureStore.reload();
         secureCredentialsFactory.compareAndSet(
-            secureCredentialsFactory.get(), new SecureCredentialsFactory(site));
-        secureCredentialsFactoryLoadTs = getSecureConfigLastEditTs();
+            secureCredentialsFactory.get(), newSecureCredentialsFactory(site, secureStore, config));
         logger.atInfo().log("secure.config reloaded as it was updated on the file system");
       }
     } catch (Exception e) {
@@ -69,7 +80,11 @@
   }
 
   private boolean needsReload() {
-    return config.getConfig().getBoolean("gerrit", "autoReload", false)
-        && getSecureConfigLastEditTs() != secureCredentialsFactoryLoadTs;
+    return config.getConfig().getBoolean("gerrit", "autoReload", false) && secureStore.isOutdated();
+  }
+
+  @Override
+  public boolean validate(String remoteConfigName) {
+    return secureCredentialsFactory.get().validate(remoteConfigName);
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/CredentialsFactory.java b/src/main/java/com/googlesource/gerrit/plugins/replication/CredentialsFactory.java
index 3bb64ab..141fb2d 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/CredentialsFactory.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/CredentialsFactory.java
@@ -18,4 +18,8 @@
 public interface CredentialsFactory {
 
   CredentialsProvider create(String remoteName);
+
+  default boolean validate(String remoteConfigName) {
+    return true;
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java b/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
index c186493..3bb1d2d 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/Destination.java
@@ -97,6 +97,8 @@
 
   private static final String PROJECT_NOT_AVAILABLE = "source project %s not available";
 
+  private final CredentialsFactory credentialsFactory;
+
   public interface Factory {
     Destination create(DestinationConfiguration config);
   }
@@ -149,6 +151,7 @@
       GroupIncludeCache groupIncludeCache,
       DynamicItem<EventDispatcher> eventDispatcher,
       Provider<ReplicationTasksStorage> rts,
+      CredentialsFactory credentialsFactory,
       @Assisted DestinationConfiguration cfg) {
     this.eventDispatcher = eventDispatcher;
     gitManager = gitRepositoryManager;
@@ -157,6 +160,7 @@
     this.projectCache = projectCache;
     this.stateLog = stateLog;
     this.replicationTasksStorage = rts;
+    this.credentialsFactory = credentialsFactory;
     config = cfg;
     CurrentUser remoteUser;
     if (!cfg.getAuthGroupNames().isEmpty()) {
@@ -216,6 +220,10 @@
     threadScoper = child.getInstance(PerThreadRequestScope.Scoper.class);
   }
 
+  public boolean validate() {
+    return (credentialsFactory == null || credentialsFactory.validate(getRemoteConfigName()));
+  }
+
   private void addRecursiveParents(
       AccountGroup.UUID g,
       ImmutableSet.Builder<AccountGroup.UUID> builder,
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationsCollection.java b/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationsCollection.java
index 8a41945..dc3b05e 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationsCollection.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/DestinationsCollection.java
@@ -180,6 +180,12 @@
   public synchronized void startup(WorkQueue workQueue) {
     shuttingDown = false;
     for (Destination cfg : destinations) {
+      if (!cfg.validate()) {
+        throw new IllegalStateException(
+            "Unable to start replication plugin because remote "
+                + cfg.getRemoteConfigName()
+                + " is not valid");
+      }
       cfg.start(workQueue);
     }
   }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/FanoutConfigResource.java b/src/main/java/com/googlesource/gerrit/plugins/replication/FanoutConfigResource.java
index 4220ddb..c634cce 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/FanoutConfigResource.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/FanoutConfigResource.java
@@ -17,17 +17,20 @@
 import static com.google.common.io.Files.getNameWithoutExtension;
 import static java.nio.charset.StandardCharsets.UTF_8;
 
-import com.google.common.collect.Lists;
 import com.google.common.flogger.FluentLogger;
 import com.google.common.hash.Hasher;
 import com.google.common.hash.Hashing;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
+import java.io.File;
 import java.io.IOException;
 import java.nio.file.Files;
 import java.nio.file.Path;
-import java.util.Optional;
+import java.util.List;
 import java.util.Set;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
 import java.util.stream.Stream;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.internal.storage.file.FileSnapshot;
@@ -40,6 +43,7 @@
   private static final FluentLogger logger = FluentLogger.forEnclosingClass();
 
   private final Path remoteConfigsDirPath;
+  private final Config fanoutConfig;
 
   @Inject
   FanoutConfigResource(SitePaths site) throws IOException, ConfigInvalidException {
@@ -48,16 +52,59 @@
     removeRemotes(config);
 
     try (Stream<Path> files = Files.list(remoteConfigsDirPath)) {
-      files
-          .filter(Files::isRegularFile)
-          .filter(FanoutConfigResource::isConfig)
-          .map(FanoutConfigResource::loadConfig)
-          .filter(Optional::isPresent)
-          .map(Optional::get)
-          .filter(FanoutConfigResource::isValid)
-          .forEach(cfg -> addRemoteConfig(cfg, config));
-    } catch (IllegalStateException e) {
-      throw new ConfigInvalidException(e.getMessage());
+      Stream<String> mergedConfigs =
+          files
+              .filter(Files::isRegularFile)
+              .filter(FanoutConfigResource::isConfig)
+              .flatMap(path -> getConfigLines(path, line -> addRemoteSubsection(line, path)));
+
+      fanoutConfig = new Config(config);
+      fanoutConfig.fromText(mergedConfigs.collect(Collectors.joining("\n")));
+    }
+  }
+
+  private static String addRemoteSubsection(String line, Path path) {
+    return line.contains("[remote]")
+        ? line.replace(
+            "[remote]", "[remote \"" + getNameWithoutExtension(path.toFile().getName()) + "\"]")
+        : line;
+  }
+
+  private static Stream<String> getConfigLines(
+      Path path, Function<String, String> configLineMapping) {
+    try {
+      List<String> configLines = Files.readAllLines(path);
+      if (!isValid(path, configLines)) {
+        return Stream.empty();
+      }
+      return configLines.stream().map(configLineMapping);
+    } catch (IOException e) {
+      logger.atSevere().withCause(e).log("Unable to access replication config %s", path);
+      return Stream.empty();
+    }
+  }
+
+  @Override
+  public void update(Config updates) throws IOException {
+    super.update(filterOutRemotes(updates));
+
+    Set<String> remotes = updates.getSubsections("remote");
+    for (String remote : remotes) {
+      File remoteFile = remoteConfigsDirPath.resolve(remote + ".config").toFile();
+      FileBasedConfig remoteConfig = new FileBasedConfig(remoteFile, FS.DETECTED);
+      try {
+        remoteConfig.load();
+      } catch (ConfigInvalidException e) {
+        throw new IOException(
+            String.format("Cannot parse configuration file: %s", remoteFile.getAbsoluteFile()), e);
+      }
+      Set<String> options = updates.getNames("remote", remote);
+      for (String option : options) {
+        List<String> values = List.of(updates.getStringList("remote", remote, option));
+        remoteConfig.setStringList("remote", null, option, values);
+        config.setStringList("remote", remote, option, values);
+      }
+      remoteConfig.save();
     }
   }
 
@@ -74,42 +121,67 @@
     }
   }
 
-  private static void addRemoteConfig(FileBasedConfig source, Config destination) {
-    String remoteName = getNameWithoutExtension(source.getFile().getName());
-    for (String name : source.getNames("remote")) {
-      destination.setStringList(
-          "remote",
-          remoteName,
-          name,
-          Lists.newArrayList(source.getStringList("remote", null, name)));
-    }
+  private static Config filterOutRemotes(Config config) {
+    Config filteredConfig = new Config();
+    Set<String> sections = config.getSections();
+    sections.stream()
+        .filter(Predicate.not("remote"::equalsIgnoreCase))
+        .forEach(
+            section -> {
+              config
+                  .getNames(section)
+                  .forEach(
+                      sectionName ->
+                          filteredConfig.setStringList(
+                              section,
+                              null,
+                              sectionName,
+                              List.of(config.getStringList(section, null, sectionName))));
+              config
+                  .getSubsections(section)
+                  .forEach(
+                      subsection ->
+                          config
+                              .getNames(section, subsection)
+                              .forEach(
+                                  name ->
+                                      filteredConfig.setStringList(
+                                          section,
+                                          subsection,
+                                          name,
+                                          List.of(
+                                              config.getStringList(section, subsection, name)))));
+            });
+    return filteredConfig;
   }
 
-  private static boolean isValid(Config cfg) {
-    if (cfg.getSections().size() != 1 || !cfg.getSections().contains("remote")) {
-      logger.atSevere().log(
-          "Remote replication configuration file %s must contain only one remote section.", cfg);
-      return false;
-    }
-    if (cfg.getSubsections("remote").size() > 0) {
-      logger.atSevere().log(
-          "Remote replication configuration file %s cannot contain remote subsections.", cfg);
-      return false;
+  private static boolean isValid(Path path, List<String> remoteConfigLines) {
+    int numRemoteSections = 0;
+    int numRemoteSectionsWithName = 0;
+    boolean valid = true;
+    for (String configLine : remoteConfigLines) {
+      if (configLine.contains("[remote]")) {
+        numRemoteSections++;
+      }
+
+      if (configLine.contains("[remote \"")) {
+        numRemoteSectionsWithName++;
+      }
     }
 
-    return true;
-  }
-
-  private static Optional<FileBasedConfig> loadConfig(Path path) {
-    FileBasedConfig cfg = new FileBasedConfig(path.toFile(), FS.DETECTED);
-    try {
-      cfg.load();
-    } catch (IOException | ConfigInvalidException e) {
-      logger.atSevere().withCause(e).log(
-          "Cannot load remote replication configuration file %s.", path);
-      return Optional.empty();
+    if (numRemoteSectionsWithName > 0) {
+      logger.atSevere().log(
+          "Remote replication configuration file %s cannot contain remote subsections.", path);
+      valid = false;
     }
-    return Optional.of(cfg);
+
+    if (numRemoteSections != 1) {
+      logger.atSevere().log(
+          "Remote replication configuration file %s must contain only one remote section.", path);
+      valid = false;
+    }
+
+    return valid;
   }
 
   private static boolean isConfig(Path p) {
@@ -140,4 +212,9 @@
       return parentVersion;
     }
   }
+
+  @Override
+  public Config getConfig() {
+    return fanoutConfig;
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/FileConfigResource.java b/src/main/java/com/googlesource/gerrit/plugins/replication/FileConfigResource.java
index baccb83..7a14cd1 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/FileConfigResource.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/FileConfigResource.java
@@ -17,6 +17,7 @@
 import static com.googlesource.gerrit.plugins.replication.ReplicationQueue.repLog;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.common.collect.Lists;
 import com.google.gerrit.common.UsedAt;
 import com.google.gerrit.common.UsedAt.Project;
 import com.google.gerrit.server.config.SitePaths;
@@ -24,6 +25,7 @@
 import com.googlesource.gerrit.plugins.replication.api.ConfigResource;
 import java.io.IOException;
 import java.nio.file.Path;
+import java.util.List;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
@@ -54,6 +56,25 @@
   }
 
   @Override
+  public void update(Config updates) throws IOException {
+    for (String section : updates.getSections()) {
+      for (String subsection : updates.getSubsections(section)) {
+        for (String name : updates.getNames(section, subsection, true)) {
+          List<String> values =
+              Lists.newArrayList(updates.getStringList(section, subsection, name));
+          config.setStringList(section, subsection, name, values);
+        }
+      }
+
+      for (String name : updates.getNames(section, true)) {
+        List<String> values = Lists.newArrayList(updates.getStringList(section, null, name));
+        config.setStringList(section, null, name, values);
+      }
+    }
+    config.save();
+  }
+
+  @Override
   public String getVersion() {
     return Long.toString(config.getFile().lastModified());
   }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/LegacyCredentialsFactory.java b/src/main/java/com/googlesource/gerrit/plugins/replication/LegacyCredentialsFactory.java
new file mode 100644
index 0000000..a89c936
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/LegacyCredentialsFactory.java
@@ -0,0 +1,66 @@
+// Copyright (C) 2011 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.googlesource.gerrit.plugins.replication;
+
+import com.google.gerrit.server.config.SitePaths;
+import com.google.inject.Inject;
+import java.io.IOException;
+import java.util.Objects;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.transport.CredentialsProvider;
+import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider;
+import org.eclipse.jgit.util.FS;
+
+/**
+ * Looks up a remote's password in secure.config.
+ *
+ * @deprecated This class is not secure and should no longer be used; it was allowing to record and
+ *     read credentials in clear text in secure.config even though the Gerrit administrator
+ *     installed a proper SecureStore implementation such as the secure-config.jar libModule.
+ */
+@Deprecated(forRemoval = true)
+class LegacyCredentialsFactory implements CredentialsFactory {
+  private final Config config;
+
+  @Inject
+  public LegacyCredentialsFactory(SitePaths site) throws ConfigInvalidException, IOException {
+    config = load(site);
+  }
+
+  private static Config load(SitePaths site) throws ConfigInvalidException, IOException {
+    FileBasedConfig cfg = new FileBasedConfig(site.secure_config.toFile(), FS.DETECTED);
+    if (cfg.getFile().exists() && cfg.getFile().length() > 0) {
+      try {
+        cfg.load();
+      } catch (ConfigInvalidException e) {
+        throw new ConfigInvalidException(
+            String.format("Config file %s is invalid: %s", cfg.getFile(), e.getMessage()), e);
+      } catch (IOException e) {
+        throw new IOException(
+            String.format("Cannot read %s: %s", cfg.getFile(), e.getMessage()), e);
+      }
+    }
+    return cfg;
+  }
+
+  @Override
+  public CredentialsProvider create(String remoteName) {
+    String user = Objects.toString(config.getString("remote", remoteName, "username"), "");
+    String pass = Objects.toString(config.getString("remote", remoteName, "password"), "");
+    return new UsernamePasswordCredentialsProvider(user, pass);
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/MergedConfigResource.java b/src/main/java/com/googlesource/gerrit/plugins/replication/MergedConfigResource.java
index 43d90a5..58a10f0 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/MergedConfigResource.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/MergedConfigResource.java
@@ -26,6 +26,7 @@
 import com.google.inject.util.Providers;
 import com.googlesource.gerrit.plugins.replication.api.ConfigResource;
 import com.googlesource.gerrit.plugins.replication.api.ReplicationConfigOverrides;
+import java.io.IOException;
 import java.util.function.Supplier;
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.Config;
@@ -77,7 +78,15 @@
     return baseVersion + overrides.get().getVersion();
   }
 
-  private boolean noOverrides() {
+  boolean noOverrides() {
     return overrides == null || overrides.get() == null;
   }
+
+  void update(Config remotesConfig) throws IOException {
+    if (noOverrides()) {
+      base.get().update(remotesConfig);
+    } else {
+      overrides.get().update(remotesConfig);
+    }
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java b/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java
index 2b60145..a648845 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/PushOne.java
@@ -79,7 +79,6 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.Ref;
 import org.eclipse.jgit.lib.Repository;
-import org.eclipse.jgit.transport.CredentialsProvider;
 import org.eclipse.jgit.transport.FetchConnection;
 import org.eclipse.jgit.transport.PushResult;
 import org.eclipse.jgit.transport.RefSpec;
@@ -119,7 +118,7 @@
   private final Destination pool;
   private final RemoteConfig config;
   private final ReplicationConfig replConfig;
-  private final CredentialsProvider credentialsProvider;
+  private final CredentialsFactory credentialsFactory;
   private final PerThreadRequestScope.Scoper threadScoper;
 
   private final Project.NameKey projectName;
@@ -166,7 +165,7 @@
     pool = p;
     config = c;
     replConfig = rc;
-    credentialsProvider = cpFactory.create(c.getName());
+    credentialsFactory = cpFactory;
     threadScoper = ts;
     projectName = d;
     uri = u;
@@ -563,7 +562,7 @@
 
   private PushResult pushVia(Transport tn) throws IOException, PermissionBackendException {
     tn.applyConfig(config);
-    tn.setCredentialsProvider(credentialsProvider);
+    tn.setCredentialsProvider(credentialsFactory.create(config.getName()));
 
     List<RemoteRefUpdate> todo = generateUpdates(tn);
     if (todo.isEmpty()) {
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfigImpl.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfigImpl.java
index 1223db0..8f9b805 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfigImpl.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationConfigImpl.java
@@ -31,6 +31,7 @@
 
   private final SitePaths site;
   private final MergedConfigResource configResource;
+  private final boolean useLegacyCredentials;
   private boolean replicateAllOnPluginStart;
   private boolean defaultForceUpdate;
   private int maxRefsToLog;
@@ -62,6 +63,7 @@
                 DEFAULT_SSH_CONNECTION_TIMEOUT_MS,
                 MILLISECONDS);
     this.pluginDataDir = pluginDataDir;
+    this.useLegacyCredentials = config.getBoolean("gerrit", "useLegacyCredentials", false);
   }
 
   @Nullable
@@ -125,6 +127,11 @@
   }
 
   @Override
+  public boolean useLegacyCredentials() {
+    return useLegacyCredentials;
+  }
+
+  @Override
   public String getVersion() {
     return configResource.getVersion();
   }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationModule.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationModule.java
index ac27280..e3e4021 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationModule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationModule.java
@@ -23,6 +23,7 @@
 import com.google.gerrit.extensions.events.HeadUpdatedListener;
 import com.google.gerrit.extensions.events.LifecycleListener;
 import com.google.gerrit.extensions.events.ProjectDeletedListener;
+import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.server.events.EventTypes;
 import com.google.inject.AbstractModule;
@@ -30,6 +31,7 @@
 import com.google.inject.Scopes;
 import com.google.inject.assistedinject.FactoryModuleBuilder;
 import com.google.inject.internal.UniqueAnnotations;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationRemotesApi;
 import com.googlesource.gerrit.plugins.replication.events.ProjectDeletionReplicationDoneEvent;
 import com.googlesource.gerrit.plugins.replication.events.ProjectDeletionReplicationFailedEvent;
 import com.googlesource.gerrit.plugins.replication.events.ProjectDeletionReplicationScheduledEvent;
@@ -52,9 +54,7 @@
 
   @Override
   protected void configure() {
-    install(new FactoryModuleBuilder().build(Destination.Factory.class));
     install(configModule);
-    bind(ReplicationQueue.class).in(Scopes.SINGLETON);
     bind(ObservableQueue.class).to(ReplicationQueue.class);
     bind(LifecycleListener.class)
         .annotatedWith(UniqueAnnotations.create())
@@ -63,6 +63,7 @@
     DynamicSet.bind(binder(), GitBatchRefUpdateListener.class).to(ReplicationQueue.class);
     DynamicSet.bind(binder(), ProjectDeletedListener.class).to(ReplicationQueue.class);
     DynamicSet.bind(binder(), HeadUpdatedListener.class).to(ReplicationQueue.class);
+    DynamicItem.bind(binder(), ReplicationRemotesApi.class).to(ReplicationRemotesApiImpl.class);
 
     bind(OnStartStop.class).in(Scopes.SINGLETON);
     bind(LifecycleListener.class).annotatedWith(UniqueAnnotations.create()).to(OnStartStop.class);
@@ -77,10 +78,8 @@
         .to(StartReplicationCapability.class);
 
     install(new FactoryModuleBuilder().build(PushAll.Factory.class));
-    install(new FactoryModuleBuilder().build(ProjectDeletionState.Factory.class));
 
     bind(EventBus.class).in(Scopes.SINGLETON);
-    bind(ReplicationDestinations.class).to(DestinationsCollection.class);
     bind(ConfigParser.class).to(DestinationConfigParser.class).in(Scopes.SINGLETON);
 
     DynamicSet.setOf(binder(), ReplicationStateListener.class);
@@ -103,5 +102,11 @@
 
     bind(TransportFactory.class).to(TransportFactoryImpl.class).in(Scopes.SINGLETON);
     bind(CloseableHttpClient.class).toProvider(HttpClientProvider.class).in(Scopes.SINGLETON);
+
+    bind(ReplicationQueue.class).in(Scopes.SINGLETON);
+    bind(ReplicationDestinations.class).to(DestinationsCollection.class);
+
+    install(new FactoryModuleBuilder().build(Destination.Factory.class));
+    install(new FactoryModuleBuilder().build(ProjectDeletionState.Factory.class));
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationRemotesApiImpl.java b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationRemotesApiImpl.java
new file mode 100644
index 0000000..cc6bcad
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/ReplicationRemotesApiImpl.java
@@ -0,0 +1,100 @@
+// Copyright (C) 2024 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.googlesource.gerrit.plugins.replication;
+
+import com.google.gerrit.server.securestore.SecureStore;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationRemotesApi;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Set;
+import org.eclipse.jgit.lib.Config;
+
+@Singleton
+class ReplicationRemotesApiImpl implements ReplicationRemotesApi {
+
+  private final SecureStore secureStore;
+  private final MergedConfigResource mergedConfigResource;
+
+  @Inject
+  ReplicationRemotesApiImpl(SecureStore secureStore, MergedConfigResource mergedConfigResource) {
+    this.secureStore = secureStore;
+    this.mergedConfigResource = mergedConfigResource;
+  }
+
+  @Override
+  public Config get(String... remoteNames) {
+    Config replicationConfig = mergedConfigResource.getConfig();
+    Config remoteConfig = new Config();
+    for (String remoteName : remoteNames) {
+      Set<String> configNames = replicationConfig.getNames("remote", remoteName);
+      for (String configName : configNames) {
+        String[] values = replicationConfig.getStringList("remote", remoteName, configName);
+        if (values.length > 0) {
+          remoteConfig.setStringList("remote", remoteName, configName, Arrays.asList(values));
+        }
+      }
+    }
+    return remoteConfig;
+  }
+
+  @Override
+  public void update(Config remoteConfig) throws IOException {
+    if (remoteConfig.getSubsections("remote").isEmpty()) {
+      throw new IllegalArgumentException(
+          "configuration update must have at least one 'remote' section");
+    }
+
+    SeparatedRemoteConfigs configs = onlyRemoteSectionsWithSeparatedCredentials(remoteConfig);
+    persistRemotesCredentials(configs);
+
+    mergedConfigResource.update(configs.remotes);
+  }
+
+  private SeparatedRemoteConfigs onlyRemoteSectionsWithSeparatedCredentials(Config configUpdates) {
+    SeparatedRemoteConfigs configs = new SeparatedRemoteConfigs();
+    for (String subSection : configUpdates.getSubsections("remote")) {
+      for (String name : configUpdates.getNames("remote", subSection)) {
+        List<String> values = List.of(configUpdates.getStringList("remote", subSection, name));
+        if ("password".equals(name) || "username".equals(name)) {
+          configs.credentials.setStringList("remote", subSection, name, values);
+        } else {
+          configs.remotes.setStringList("remote", subSection, name, values);
+        }
+      }
+    }
+
+    return configs;
+  }
+
+  private void persistRemotesCredentials(SeparatedRemoteConfigs configs) {
+    for (String subSection : configs.credentials.getSubsections("remote")) {
+      copyRemoteStringList(configs.credentials, subSection, "username");
+      copyRemoteStringList(configs.credentials, subSection, "password");
+    }
+  }
+
+  private void copyRemoteStringList(Config config, String subSection, String key) {
+    secureStore.setList(
+        "remote", subSection, key, List.of(config.getStringList("remote", subSection, key)));
+  }
+
+  private static class SeparatedRemoteConfigs {
+    private final Config remotes = new Config();
+    private final Config credentials = new Config();
+  }
+}
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/SecureCredentialsFactory.java b/src/main/java/com/googlesource/gerrit/plugins/replication/SecureCredentialsFactory.java
index ed15b92..3e00089 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/SecureCredentialsFactory.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/SecureCredentialsFactory.java
@@ -1,4 +1,4 @@
-// Copyright (C) 2011 The Android Open Source Project
+// Copyright (C) 2024 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.
@@ -14,46 +14,43 @@
 
 package com.googlesource.gerrit.plugins.replication;
 
-import com.google.gerrit.server.config.SitePaths;
+import com.google.common.flogger.FluentLogger;
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.common.UsedAt.Project;
+import com.google.gerrit.server.securestore.SecureStore;
 import com.google.inject.Inject;
-import java.io.IOException;
 import java.util.Objects;
-import org.eclipse.jgit.errors.ConfigInvalidException;
-import org.eclipse.jgit.lib.Config;
-import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.transport.CredentialsProvider;
 import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider;
-import org.eclipse.jgit.util.FS;
 
-/** Looks up a remote's password in secure.config. */
+/** Looks up a remote's password in SecureStore */
+@UsedAt(Project.PLUGIN_PULL_REPLICATION)
 public class SecureCredentialsFactory implements CredentialsFactory {
-  private final Config config;
+  private static final FluentLogger log = FluentLogger.forEnclosingClass();
+  private final SecureStore secureStore;
 
   @Inject
-  public SecureCredentialsFactory(SitePaths site) throws ConfigInvalidException, IOException {
-    config = load(site);
-  }
-
-  private static Config load(SitePaths site) throws ConfigInvalidException, IOException {
-    FileBasedConfig cfg = new FileBasedConfig(site.secure_config.toFile(), FS.DETECTED);
-    if (cfg.getFile().exists() && cfg.getFile().length() > 0) {
-      try {
-        cfg.load();
-      } catch (ConfigInvalidException e) {
-        throw new ConfigInvalidException(
-            String.format("Config file %s is invalid: %s", cfg.getFile(), e.getMessage()), e);
-      } catch (IOException e) {
-        throw new IOException(
-            String.format("Cannot read %s: %s", cfg.getFile(), e.getMessage()), e);
-      }
-    }
-    return cfg;
+  public SecureCredentialsFactory(SecureStore secureStore) {
+    this.secureStore = secureStore;
   }
 
   @Override
   public CredentialsProvider create(String remoteName) {
-    String user = Objects.toString(config.getString("remote", remoteName, "username"), "");
-    String pass = Objects.toString(config.getString("remote", remoteName, "password"), "");
+    String user = Objects.toString(secureStore.get("remote", remoteName, "username"), "");
+    String pass = Objects.toString(secureStore.get("remote", remoteName, "password"), "");
     return new UsernamePasswordCredentialsProvider(user, pass);
   }
+
+  @Override
+  public boolean validate(String remoteName) {
+    try {
+      String unused = secureStore.get("remote", remoteName, "username");
+      unused = secureStore.get("remote", remoteName, "password");
+      return true;
+    } catch (Throwable t) {
+      log.atSevere().withCause(t).log(
+          "Credentials for replication remote %s are invalid", remoteName);
+      return false;
+    }
+  }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/api/ApiModule.java b/src/main/java/com/googlesource/gerrit/plugins/replication/api/ApiModule.java
index 3764ede..44ec88c 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/api/ApiModule.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/api/ApiModule.java
@@ -22,5 +22,6 @@
   protected void configure() {
     DynamicItem.itemOf(binder(), ReplicationPushFilter.class);
     DynamicItem.itemOf(binder(), ReplicationConfigOverrides.class);
+    DynamicItem.itemOf(binder(), ReplicationRemotesApi.class);
   }
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/api/ConfigResource.java b/src/main/java/com/googlesource/gerrit/plugins/replication/api/ConfigResource.java
index 43733bc..0052fc2 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/api/ConfigResource.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/api/ConfigResource.java
@@ -14,6 +14,7 @@
 
 package com.googlesource.gerrit.plugins.replication.api;
 
+import java.io.IOException;
 import org.eclipse.jgit.lib.Config;
 
 /**
@@ -37,6 +38,16 @@
   Config getConfig();
 
   /**
+   * Update the configuration resource.
+   *
+   * <p>Allows to persist changes to the configuration resource.
+   *
+   * @param config updated configuration
+   * @throws IOException when configuration cannot be persisted
+   */
+  void update(Config config) throws IOException;
+
+  /**
    * Current logical version string of the current configuration on the persistent storage.
    *
    * @return latest logical version number on the persistent storage
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/api/ReplicationConfig.java b/src/main/java/com/googlesource/gerrit/plugins/replication/api/ReplicationConfig.java
index 8ef4e2b..d5ccb02 100644
--- a/src/main/java/com/googlesource/gerrit/plugins/replication/api/ReplicationConfig.java
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/api/ReplicationConfig.java
@@ -102,4 +102,16 @@
    * @return the config.
    */
   Config getConfig();
+
+  /**
+   * Use legacy credentials for migrating existing sites.
+   *
+   * <p>Existing installations may have used a mix of encrypted and clear text credentials in
+   * secure.config, leveraging the replication plugin bug that was not accessing it using the
+   * correct API. The legacy feature flag 'gerrit.useLegacyCredentials' allows Gerrit to still use
+   * direct access to secure.config without decrypting its values.
+   *
+   * @return true if the secure.config should be read always directly and without decryption
+   */
+  boolean useLegacyCredentials();
 }
diff --git a/src/main/java/com/googlesource/gerrit/plugins/replication/api/ReplicationRemotesApi.java b/src/main/java/com/googlesource/gerrit/plugins/replication/api/ReplicationRemotesApi.java
new file mode 100644
index 0000000..76d6d35
--- /dev/null
+++ b/src/main/java/com/googlesource/gerrit/plugins/replication/api/ReplicationRemotesApi.java
@@ -0,0 +1,55 @@
+// Copyright (C) 2024 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.googlesource.gerrit.plugins.replication.api;
+
+import static com.google.gerrit.common.UsedAt.Project.PLUGIN_GITHUB;
+
+import com.google.gerrit.common.UsedAt;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.server.securestore.SecureStore;
+import java.io.IOException;
+import org.eclipse.jgit.lib.Config;
+
+/** Public API to update replication plugin remotes configurations programmatically. */
+@UsedAt(PLUGIN_GITHUB)
+@DynamicItem.Final(implementedByPlugin = "replication")
+public interface ReplicationRemotesApi {
+
+  /**
+   * Retrieves the configuration for a remote by name.
+   *
+   * <p>Builds a JGit {@link Config} with the remote's configuration obtained by parsing the
+   * replication configuration sources.
+   *
+   * <p>NOTE: The remotes secrets are excluded for security reasons.
+   *
+   * @param remoteNames the remote names to retrieve.
+   * @return {@link Config} associated with the remoteName(s)
+   */
+  Config get(String... remoteNames);
+
+  /**
+   * Adds or updates the remote configuration for the replication plugin.
+   *
+   * <p>Provided JGit {@link Config} object should contain at least one named <em>remote</em>
+   * section. All other configurations will be ignored.
+   *
+   * <p>NOTE: The {@code remote.$name.password} will be stored using {@link SecureStore}.
+   *
+   * @param remoteConfig remotes to add or update
+   * @throws IOException when persisting fails
+   */
+  void update(Config remoteConfig) throws IOException;
+}
diff --git a/src/main/resources/Documentation/config.md b/src/main/resources/Documentation/config.md
index 6f9a49d..bb4be9a 100644
--- a/src/main/resources/Documentation/config.md
+++ b/src/main/resources/Documentation/config.md
@@ -170,6 +170,14 @@
 :	Timeout for SSH connections. If 0, there is no timeout and
         the client waits indefinitely. By default, 2 minutes.
 
+gerrit.useLegacyCredentials
+:	Use legacy credentials on secure.config which did not allow encryption
+	through a SecureStore provider. Legacy credentials should not be used in
+	production and are provided purely for backward compatibility with existing
+	secure.config files. When enabling the SecureStore in Gerrit, for instance,
+	the secure-config.jar libModule, make sure that all existing replication
+	credentials are encrypted. By default, false.
+
 replication.distributionInterval
 :	Interval in seconds for running the replication distributor. When
 	run, the replication distributor will add all persisted waiting tasks
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/AbstractConfigTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/AbstractConfigTest.java
index b8bbc08..935fdb4 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/AbstractConfigTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/AbstractConfigTest.java
@@ -57,7 +57,8 @@
     public final DestinationConfiguration config;
 
     protected FakeDestination(DestinationConfiguration config) {
-      super(injectorMock(), null, null, null, null, null, null, null, null, null, null, config);
+      super(
+          injectorMock(), null, null, null, null, null, null, null, null, null, null, null, config);
       this.config = config;
     }
 
@@ -78,7 +79,7 @@
   }
 
   @Before
-  public void setup() {
+  public void setup() throws IOException {
     when(destinationFactoryMock.create(any(DestinationConfiguration.class)))
         .thenAnswer(
             new Answer<Destination>() {
@@ -103,6 +104,10 @@
     return newReplicationConfig("replication.config");
   }
 
+  protected FileBasedConfig newSecureConfig() {
+    return newReplicationConfig("secure.config");
+  }
+
   protected FileBasedConfig newReplicationConfig(String path) {
     FileBasedConfig replicationConfig =
         new FileBasedConfig(sitePaths.etc_dir.resolve(path).toFile(), FS.DETECTED);
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/AutoReloadSecureCredentialsFactoryDecoratorTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/AutoReloadSecureCredentialsFactoryDecoratorTest.java
new file mode 100644
index 0000000..c959cc9
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/AutoReloadSecureCredentialsFactoryDecoratorTest.java
@@ -0,0 +1,97 @@
+// Copyright (C) 2024 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.googlesource.gerrit.plugins.replication;
+
+import static com.google.common.truth.Truth.assertThat;
+import static org.mockito.Mockito.when;
+
+import com.google.gerrit.server.securestore.SecureStore;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationConfig;
+import java.io.IOException;
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.transport.CredentialItem;
+import org.eclipse.jgit.transport.CredentialsProvider;
+import org.eclipse.jgit.transport.URIish;
+import org.eclipse.jgit.transport.UsernamePasswordCredentialsProvider;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+@RunWith(MockitoJUnitRunner.class)
+public class AutoReloadSecureCredentialsFactoryDecoratorTest extends AbstractConfigTest {
+  private static final String REMOTE_TEST = "remote-test";
+  private static final String USERNAME_CLEARTEXT = "username-clear";
+  private static final String PASSWORD_CLEARTEXT = "password-clear";
+  private static final String USERNAME_CIPHERTEXT = "username-encrypted";
+  private static final String PASSWORD_CIPHERTEXT = "password-encrypted";
+
+  @Mock private SecureStore secureStoreMock;
+  @Mock private ReplicationConfig replicationConfigMock;
+
+  public AutoReloadSecureCredentialsFactoryDecoratorTest() throws IOException {
+    super();
+  }
+
+  @Override
+  @Before
+  public void setup() throws IOException {
+    when(replicationConfigMock.getConfig()).thenReturn(new Config());
+
+    when(secureStoreMock.get("remote", REMOTE_TEST, "username")).thenReturn(USERNAME_CIPHERTEXT);
+    when(secureStoreMock.get("remote", REMOTE_TEST, "password")).thenReturn(PASSWORD_CIPHERTEXT);
+
+    FileBasedConfig secureConfig = newSecureConfig();
+    secureConfig.setString("remote", REMOTE_TEST, "username", USERNAME_CLEARTEXT);
+    secureConfig.setString("remote", REMOTE_TEST, "password", PASSWORD_CLEARTEXT);
+    secureConfig.save();
+  }
+
+  @Test
+  public void shouldCreateLegacyCredentials() throws Exception {
+    when(replicationConfigMock.useLegacyCredentials()).thenReturn(true);
+    assertUsernamePasswordCredentials(
+        getCredentialsProvider(), USERNAME_CLEARTEXT, PASSWORD_CLEARTEXT);
+  }
+
+  @Test
+  public void shouldCreateEncryptedCredentialsByDefault() throws Exception {
+    assertUsernamePasswordCredentials(
+        getCredentialsProvider(), USERNAME_CIPHERTEXT, PASSWORD_CIPHERTEXT);
+  }
+
+  private UsernamePasswordCredentialsProvider getCredentialsProvider()
+      throws ConfigInvalidException, IOException {
+    AutoReloadSecureCredentialsFactoryDecorator credFactory =
+        new AutoReloadSecureCredentialsFactoryDecorator(
+            sitePaths, secureStoreMock, replicationConfigMock);
+    CredentialsProvider legacyCredentials = credFactory.create(REMOTE_TEST);
+    assertThat(legacyCredentials).isInstanceOf(UsernamePasswordCredentialsProvider.class);
+    return (UsernamePasswordCredentialsProvider) legacyCredentials;
+  }
+
+  private static void assertUsernamePasswordCredentials(
+      UsernamePasswordCredentialsProvider credentials, String username, String password) {
+    CredentialItem.Username usernameItem = new CredentialItem.Username();
+    CredentialItem.Password passwordItem = new CredentialItem.Password();
+    assertThat(credentials.get(new URIish(), usernameItem, passwordItem)).isTrue();
+
+    assertThat(usernameItem.getValue()).isEqualTo(username);
+    assertThat(new String(passwordItem.getValue())).isEqualTo(password);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/FanoutConfigResourceTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/FanoutConfigResourceTest.java
index 9147ea1..a3d811f 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/FanoutConfigResourceTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/FanoutConfigResourceTest.java
@@ -24,6 +24,7 @@
 import java.io.IOException;
 import java.util.List;
 import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.storage.file.FileBasedConfig;
 import org.eclipse.jgit.util.FS;
 import org.junit.Before;
@@ -285,9 +286,73 @@
     assertThat(objectUnderTest.getVersion()).isEqualTo(replicationConfigVersion);
   }
 
+  @Test
+  public void shouldAddConfigOptionToMainConfig() throws Exception {
+    FanoutConfigResource objectUnderTest = new FanoutConfigResource(sitePaths);
+    Config update = new Config();
+    update.setString("new", null, "value", "set");
+
+    objectUnderTest.update(update);
+    Config updatedConfig = objectUnderTest.getConfig();
+
+    assertThat(updatedConfig.getString("new", null, "value")).isEqualTo("set");
+  }
+
+  @Test
+  public void shouldUpdateConfigOptionInMainConfig() throws Exception {
+    FileBasedConfig config = newReplicationConfig();
+    config.setString("updatable", null, "value", "orig");
+    config.save();
+    FanoutConfigResource objectUnderTest = new FanoutConfigResource(sitePaths);
+    Config update = new Config();
+    update.setString("updatable", null, "value", "updated");
+
+    objectUnderTest.update(update);
+    Config updatedConfig = objectUnderTest.getConfig();
+
+    assertThat(updatedConfig.getString("updatable", null, "value")).isEqualTo("updated");
+  }
+
+  @Test
+  public void shouldAddNewRemoteFile() throws Exception {
+    FanoutConfigResource objectUnderTest = new FanoutConfigResource(sitePaths);
+    Config update = new Config();
+    update.setString("remote", remoteName1, "url", remoteUrl1);
+
+    objectUnderTest.update(update);
+
+    assertThat(objectUnderTest.getConfig().getString("remote", remoteName1, "url"))
+        .isEqualTo(remoteUrl1);
+    Config actual = loadRemoteConfig(remoteName1);
+    assertThat(actual.getString("remote", null, "url")).isEqualTo(remoteUrl1);
+  }
+
+  @Test
+  public void shouldUpdateExistingRemote() throws Exception {
+    FileBasedConfig rawRemoteConfig = newRemoteConfig(remoteName1);
+    rawRemoteConfig.setString("remote", remoteName1, "url", remoteUrl1);
+    rawRemoteConfig.save();
+    FanoutConfigResource objectUnderTest = new FanoutConfigResource(sitePaths);
+    Config update = new Config();
+    update.setString("remote", remoteName1, "url", remoteUrl2);
+
+    objectUnderTest.update(update);
+
+    assertThat(objectUnderTest.getConfig().getString("remote", remoteName1, "url"))
+        .isEqualTo(remoteUrl2);
+    Config actual = loadRemoteConfig(remoteName1);
+    assertThat(actual.getString("remote", null, "url")).isEqualTo(remoteUrl2);
+  }
+
   protected FileBasedConfig newRemoteConfig(String configFileName) {
     return new FileBasedConfig(
         sitePaths.etc_dir.resolve("replication/" + configFileName + ".config").toFile(),
         FS.DETECTED);
   }
+
+  private Config loadRemoteConfig(String siteName) throws Exception {
+    FileBasedConfig config = newRemoteConfig(siteName);
+    config.load();
+    return config;
+  }
 }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/FileConfigResourceTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/FileConfigResourceTest.java
new file mode 100644
index 0000000..92bf58d
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/FileConfigResourceTest.java
@@ -0,0 +1,121 @@
+// Copyright (C) 2024 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.googlesource.gerrit.plugins.replication;
+
+import static com.google.common.io.RecursiveDeleteOption.ALLOW_INSECURE;
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.common.io.MoreFiles;
+import com.google.gerrit.server.config.SitePaths;
+import com.googlesource.gerrit.plugins.replication.api.ConfigResource;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.eclipse.jgit.lib.Config;
+import org.eclipse.jgit.storage.file.FileBasedConfig;
+import org.eclipse.jgit.util.FS;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class FileConfigResourceTest {
+  private static final String VALUE_KEY = "value";
+  private static final String INITIAL_KEY = "initial";
+  private static final String UPDATABLE_KEY = "updatable";
+
+  private Path testDir;
+  private SitePaths sitePaths;
+  private ConfigResource configResource;
+
+  @Before
+  public void setUp() throws Exception {
+    testDir = Files.createTempDirectory("fileConfigResourceTest");
+    sitePaths = new SitePaths(testDir);
+    configResource = newFileConfigResource();
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    MoreFiles.deleteRecursively(testDir, ALLOW_INSECURE);
+  }
+
+  @Test
+  public void updateEmptyFile() throws Exception {
+    Config configUpdate = newConfigUpdate();
+
+    Config beforeUpdate = configResource.getConfig();
+    assertThat(beforeUpdate.getSections()).isEmpty();
+
+    configResource.update(configUpdate);
+    Config updatedConfig = newFileConfigResource().getConfig();
+
+    assertConfigUpdate(updatedConfig);
+  }
+
+  @Test
+  public void appendOptionToConfig() throws Exception {
+    FileBasedConfig rawConfig = getRawReplicationConfig();
+    rawConfig.setInt(INITIAL_KEY, null, VALUE_KEY, 10);
+    rawConfig.save();
+    configResource = newFileConfigResource();
+    Config configUpdate = newConfigUpdate();
+
+    configResource.update(configUpdate);
+    Config updatedConfig = configResource.getConfig();
+
+    assertConfigUpdate(updatedConfig);
+    assertThat(updatedConfig.getInt(INITIAL_KEY, null, VALUE_KEY, -1)).isEqualTo(10);
+  }
+
+  @Test
+  public void updateExistingOption() throws Exception {
+    int expectedValue = 20;
+    Config configUpdate = new Config();
+    configUpdate.setInt(UPDATABLE_KEY, null, VALUE_KEY, expectedValue);
+    FileBasedConfig rawConfig = getRawReplicationConfig(newConfigUpdate());
+    rawConfig.save();
+
+    configResource.update(configUpdate);
+    Config updatedConfig = configResource.getConfig();
+
+    assertConfigUpdate(updatedConfig, expectedValue);
+  }
+
+  private FileConfigResource newFileConfigResource() {
+    return new FileConfigResource(sitePaths);
+  }
+
+  private Config newConfigUpdate() {
+    Config configUpdate = new Config();
+    configUpdate.setInt(UPDATABLE_KEY, null, VALUE_KEY, 1);
+    return configUpdate;
+  }
+
+  private void assertConfigUpdate(Config config) {
+    assertConfigUpdate(config, 1);
+  }
+
+  private void assertConfigUpdate(Config config, int expectedValue) {
+    assertThat(config.getInt(UPDATABLE_KEY, null, VALUE_KEY, -1)).isEqualTo(expectedValue);
+  }
+
+  private FileBasedConfig getRawReplicationConfig() {
+    return getRawReplicationConfig(new Config());
+  }
+
+  private FileBasedConfig getRawReplicationConfig(Config base) {
+    Path configPath = sitePaths.etc_dir.resolve(FileConfigResource.CONFIG_NAME);
+    return new FileBasedConfig(base, configPath.toFile(), FS.DETECTED);
+  }
+}
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/MergedConfigResourceTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/MergedConfigResourceTest.java
index 18bb603..cae78e3 100644
--- a/src/test/java/com/googlesource/gerrit/plugins/replication/MergedConfigResourceTest.java
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/MergedConfigResourceTest.java
@@ -22,6 +22,7 @@
 import com.googlesource.gerrit.plugins.replication.api.ApiModule;
 import com.googlesource.gerrit.plugins.replication.api.ConfigResource;
 import com.googlesource.gerrit.plugins.replication.api.ReplicationConfigOverrides;
+import org.apache.commons.lang3.NotImplementedException;
 import org.eclipse.jgit.lib.Config;
 import org.junit.Test;
 
@@ -78,6 +79,11 @@
     }
 
     @Override
+    public void update(Config config) {
+      throw new NotImplementedException("not implemented");
+    }
+
+    @Override
     public String getVersion() {
       return "base";
     }
@@ -93,6 +99,11 @@
     }
 
     @Override
+    public void update(Config config) {
+      throw new NotImplementedException("not implemented");
+    }
+
+    @Override
     public String getVersion() {
       return "override";
     }
diff --git a/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationRemotesApiTest.java b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationRemotesApiTest.java
new file mode 100644
index 0000000..40d9846
--- /dev/null
+++ b/src/test/java/com/googlesource/gerrit/plugins/replication/ReplicationRemotesApiTest.java
@@ -0,0 +1,212 @@
+// Copyright (C) 2024 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.googlesource.gerrit.plugins.replication;
+
+import static com.google.common.io.RecursiveDeleteOption.ALLOW_INSECURE;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.testing.GerritJUnit.assertThrows;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+
+import com.google.common.io.MoreFiles;
+import com.google.common.truth.StringSubject;
+import com.google.gerrit.extensions.registration.DynamicItem;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.securestore.SecureStore;
+import com.google.inject.AbstractModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.googlesource.gerrit.plugins.replication.api.ConfigResource;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationConfigOverrides;
+import com.googlesource.gerrit.plugins.replication.api.ReplicationRemotesApi;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+import org.eclipse.jgit.lib.Config;
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Test;
+
+public class ReplicationRemotesApiTest {
+
+  private Path testSite;
+  private SecureStore secureStoreMock;
+  private FileConfigResource baseConfig;
+  private Injector testInjector;
+  private AtomicReference<ReplicationConfigOverrides> testOverrides;
+  private String url1;
+  private String remoteName1;
+  private String url2;
+  private String remoteName2;
+
+  @Before
+  public void setUp() throws Exception {
+    testSite = Files.createTempDirectory("replicationRemotesUpdateTest");
+    secureStoreMock = mock(SecureStore.class);
+    baseConfig = new FileConfigResource(new SitePaths(testSite));
+    testOverrides = new AtomicReference<>(new TestReplicationConfigOverrides());
+    testInjector =
+        Guice.createInjector(
+            new AbstractModule() {
+              @Override
+              protected void configure() {
+                bind(ConfigResource.class).toInstance(baseConfig);
+                bind(SecureStore.class).toInstance(secureStoreMock);
+                bind(ReplicationRemotesApi.class).to(ReplicationRemotesApiImpl.class);
+                DynamicItem.itemOf(binder(), ReplicationConfigOverrides.class);
+                DynamicItem.bind(binder(), ReplicationConfigOverrides.class)
+                    .toProvider(testOverrides::get);
+              }
+            });
+    url1 = "fake_url1";
+    remoteName1 = "site1";
+    url2 = "fake_url2";
+    remoteName2 = "site2";
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    MoreFiles.deleteRecursively(testSite, ALLOW_INSECURE);
+  }
+
+  @Test
+  public void shouldThrowWhenNoRemotesInTheUpdate() {
+    Config update = new Config();
+    ReplicationRemotesApi objectUnderTest = getReplicationRemotesApi();
+
+    assertThrows(IllegalArgumentException.class, () -> objectUnderTest.update(update));
+
+    update.setString("non-remote", null, "value", "one");
+
+    assertThrows(IllegalArgumentException.class, () -> objectUnderTest.update(update));
+  }
+
+  @Test
+  public void shouldReturnEmptyConfigWhenNoRemotes() {
+    ReplicationRemotesApi objectUnderTest = getReplicationRemotesApi();
+    assertThat(objectUnderTest.get("site").getSections()).isEmpty();
+  }
+
+  @Test
+  public void addRemoteSectionsToBaseConfigWhenNoOverrides() throws Exception {
+    testOverrides.set(null);
+
+    Config update = new Config();
+    setRemoteSite(update, remoteName1, "url", url1);
+    setRemoteSite(update, remoteName2, "url", url2);
+    ReplicationRemotesApi objectUnderTest = getReplicationRemotesApi();
+
+    objectUnderTest.update(update);
+
+    assertRemoteSite(baseConfig.getConfig(), remoteName1, "url").isEqualTo(url1);
+    assertRemoteSite(baseConfig.getConfig(), remoteName2, "url").isEqualTo(url2);
+  }
+
+  @Test
+  public void shouldReturnRemotesFromBaseConfigWhenNoOverrides() {
+    ReplicationRemotesApi objectUnderTest = getReplicationRemotesApi();
+
+    baseConfig.getConfig().setString("remote", remoteName1, "url", url1);
+    baseConfig.getConfig().setString("remote", remoteName2, "url", url2);
+
+    Config remotesConfig = objectUnderTest.get(remoteName1, remoteName2);
+    assertRemoteSite(remotesConfig, remoteName1, "url").isEqualTo(url1);
+    assertRemoteSite(remotesConfig, remoteName2, "url").isEqualTo(url2);
+  }
+
+  @Test
+  public void addRemotesSectionToBaseOverridesConfig() throws Exception {
+    Config update = new Config();
+    setRemoteSite(update, remoteName1, "url", url1);
+    setRemoteSite(update, remoteName2, "url", url2);
+    ReplicationRemotesApi objectUnderTest = getReplicationRemotesApi();
+
+    objectUnderTest.update(update);
+
+    assertRemoteSite(testOverrides.get().getConfig(), remoteName1, "url").isEqualTo(url1);
+    assertRemoteSite(testOverrides.get().getConfig(), remoteName2, "url").isEqualTo(url2);
+    assertRemoteSite(baseConfig.getConfig(), remoteName1, "url").isNull();
+    assertRemoteSite(baseConfig.getConfig(), remoteName2, "url").isNull();
+
+    Config remotesConfig = objectUnderTest.get(remoteName1, remoteName2);
+    assertRemoteSite(remotesConfig, remoteName1, "url").isEqualTo(url1);
+    assertRemoteSite(remotesConfig, remoteName2, "url").isEqualTo(url2);
+  }
+
+  @Test
+  public void shouldSetPasswordViaSecureStoreButNotStoreInConfig() throws Exception {
+    Config update = new Config();
+    String password = "encrypted-password";
+    String remoteName = "site";
+    setRemoteSite(update, remoteName, "password", password);
+    ReplicationRemotesApi objectUnderTest = getReplicationRemotesApi();
+
+    objectUnderTest.update(update);
+
+    verify(secureStoreMock).setList("remote", remoteName, "password", List.of(password));
+    assertRemoteSite(baseConfig.getConfig(), remoteName, "password").isNull();
+    assertRemoteSite(testOverrides.get().getConfig(), remoteName, "password").isNull();
+    assertRemoteSite(objectUnderTest.get(remoteName), remoteName, "password").isNull();
+  }
+
+  @Test
+  public void shouldSetUsernameViaSecureStoreButNotStoreInConfig() throws Exception {
+    Config update = new Config();
+    String username = "encrypted-username";
+    String remoteName = "site";
+    setRemoteSite(update, remoteName, "username", username);
+    ReplicationRemotesApi objectUnderTest = getReplicationRemotesApi();
+
+    objectUnderTest.update(update);
+
+    verify(secureStoreMock).setList("remote", remoteName, "username", List.of(username));
+    assertRemoteSite(baseConfig.getConfig(), remoteName, "username").isNull();
+    assertRemoteSite(testOverrides.get().getConfig(), remoteName, "username").isNull();
+    assertRemoteSite(objectUnderTest.get(remoteName), remoteName, "username").isNull();
+  }
+
+  private void setRemoteSite(Config config, String remoteName, String name, String value) {
+    config.setString("remote", remoteName, name, value);
+  }
+
+  private StringSubject assertRemoteSite(Config config, String remoteName, String name) {
+    return assertThat(config.getString("remote", remoteName, name));
+  }
+
+  private ReplicationRemotesApi getReplicationRemotesApi() {
+    return testInjector.getInstance(ReplicationRemotesApi.class);
+  }
+
+  static class TestReplicationConfigOverrides implements ReplicationConfigOverrides {
+    private Config config = new Config();
+
+    @Override
+    public Config getConfig() {
+      return config;
+    }
+
+    @Override
+    public void update(Config update) throws IOException {
+      config = update;
+    }
+
+    @Override
+    public String getVersion() {
+      return "none";
+    }
+  }
+}