Allow to enable imports of code owner config files with arbitrary file extensions

Code owner config files can only import other code owner config files,
but not arbitrary files. For the find-owners backend files with the
following names are considered as code owner config files: OWNERS,
<prefix>_OWNERS, OWNERS_<extension>

Having a name pattern for code owner config files defined allows the
code-owners plugin to recognise code owner config files on upload and
validate them. The validation ensures that code owner config files never
import non-parseable code owner config files. This is important, since
an import of a non-parseable code owner config file would block the
submission of all changes for which this import is relevant, which can
be a serious outage.

To support maintaining different sets of code owner configuration in one
repository/branch it is possible to configure a file extension for code
owner config files. In this case the find-owners backend considers the
following files as code owner config files: OWNERS.<file-extension>,
<prefix>_OWNERS.<file-extension>, OWNERS_<extension>.<file-extension>
(where ‘file-extension’ matches the configured file-extension, but not
arbitrary file extensions). For this use-case it is important that there
is no validation on code owner config files which belong to a different
set of code configurations (e.g. if OWNERS.google files are used, OWNERS
or OWNERS.other files should not be validated). This is important since
the validation of code owner config files which belong to a different
set of code configurations is not unlikely to fail (e.g. they may use a
different syntax or reference users that don’t exist on the host).

Now the Chrome team has the requirement to support code owner config
files with arbitrary file extensions. E.g. for the find-owners backend
OWNERS.<file-extension> should be supported in addition to OWNERS,
<prefix>_OWNERS, OWNERS_<extension>. For their use-case
OWNERS.<file-extension> files with any file extension should be
validated to prevent that they become non-parseable and can break the
submission of changes when they are being imported. This requirement
conflicts with the use case of using file extensions for code owner
config files to support different sets of code owner configurations in
one repository/branch (as explained above this use-case requires that
code owner config files with non-matching file extensions are not
validated).

To support their use-case anyway we add a new configuration option that
controls whether code owner config files with file extensions are
enabled. If code owner config files with file extensions are enabled,
code owner config files with arbitrary file extensions are validated and
can be imported by other code owner config files.

If this option is used, one should not configure a file extension for
code owner files and vice versa (as both options are not compatible as
explained above which likely causes issues).

Signed-off-by: Edwin Kempin <ekempin@google.com>
Change-Id: If663d70067665eb6f8f1a2e261e5a380eae7de12
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/AbstractFileBasedCodeOwnerBackend.java b/java/com/google/gerrit/plugins/codeowners/backend/AbstractFileBasedCodeOwnerBackend.java
index 20a44c2..15aed2d 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/AbstractFileBasedCodeOwnerBackend.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/AbstractFileBasedCodeOwnerBackend.java
@@ -21,6 +21,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.entities.Project;
 import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration;
+import com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginProjectConfigSnapshot;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -170,11 +171,12 @@
    * @return whether the given file name is code owner config file with an extension in the name
    */
   private boolean isCodeOwnerConfigFileWithExtension(Project.NameKey project, String fileName) {
+    CodeOwnersPluginProjectConfigSnapshot codeOwnersPluginProjectConfigSnapshot =
+        codeOwnersPluginConfiguration.getProjectConfig(project);
     String quotedDefaultFileName = Pattern.quote(defaultFileName);
     String quotedFileExtension =
         Pattern.quote(
-            codeOwnersPluginConfiguration
-                .getProjectConfig(project)
+            codeOwnersPluginProjectConfigSnapshot
                 .getFileExtension()
                 .map(ext -> "." + ext)
                 .orElse(""));
@@ -187,7 +189,12 @@
         || Pattern.compile(
                 "^" + nameExtension + "_" + quotedDefaultFileName + quotedFileExtension + "$")
             .matcher(fileName)
-            .matches();
+            .matches()
+        || (codeOwnersPluginProjectConfigSnapshot.enableCodeOwnerConfigFilesWithFileExtensions()
+            && Pattern.compile(
+                    "^" + quotedDefaultFileName + Pattern.quote(".") + nameExtension + "$")
+                .matcher(fileName)
+                .matches());
   }
 
   private String getFileName(Project.NameKey project) {
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginProjectConfigSnapshot.java b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginProjectConfigSnapshot.java
index ea7c88b..0500d7c 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginProjectConfigSnapshot.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/config/CodeOwnersPluginProjectConfigSnapshot.java
@@ -66,6 +66,7 @@
   private final Config pluginConfig;
 
   @Nullable private Optional<String> fileExtension;
+  @Nullable private Boolean enableCodeOwnerConfigFilesWithFileExtensions;
   @Nullable private Boolean codeOwnerConfigsReadOnly;
   @Nullable private Boolean exemptPureReverts;
   @Nullable private Boolean rejectNonResolvableCodeOwners;
@@ -120,6 +121,15 @@
     return fileExtension;
   }
 
+  /** Whether file extensions for code owner config files are enabled. */
+  public boolean enableCodeOwnerConfigFilesWithFileExtensions() {
+    if (enableCodeOwnerConfigFilesWithFileExtensions == null) {
+      enableCodeOwnerConfigFilesWithFileExtensions =
+          generalConfig.enableCodeOwnerConfigFilesWithFileExtensions(projectName, pluginConfig);
+    }
+    return enableCodeOwnerConfigFilesWithFileExtensions;
+  }
+
   /** Whether code owner configs are read-only. */
   public boolean areCodeOwnerConfigsReadOnly() {
     if (codeOwnerConfigsReadOnly == null) {
diff --git a/java/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfig.java b/java/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfig.java
index 7ab6706..299311d 100644
--- a/java/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfig.java
+++ b/java/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfig.java
@@ -65,6 +65,8 @@
   public static final String SECTION_VALIDATION = "validation";
 
   public static final String KEY_FILE_EXTENSION = "fileExtension";
+  public static final String KEY_ENABLE_CODE_OWNER_CONFIG_FILES_WITH_FILE_EXTENSIONS =
+      "enableCodeOwnerConfigFilesWithFileExtensions";
   public static final String KEY_READ_ONLY = "readOnly";
   public static final String KEY_EXEMPT_PURE_REVERTS = "exemptPureReverts";
   public static final String KEY_FALLBACK_CODE_OWNERS = "fallbackCodeOwners";
@@ -186,6 +188,27 @@
   }
 
   /**
+   * Whether file extensions for code owner config files are enabled.
+   *
+   * <p>If enabled, code owner config files with file extensions are treated as regular code owner
+   * config files. This means they are validated on push/submit (if validation is enabled) and can
+   * be imported by other code owner config files (regardless of whether they have the same file
+   * extension or not).
+   *
+   * @param project the project for which the configuration should be read
+   * @param pluginConfig the plugin config from which the configuration should be read.
+   * @return whether file extensions for code owner config files are enabled
+   */
+  boolean enableCodeOwnerConfigFilesWithFileExtensions(
+      Project.NameKey project, Config pluginConfig) {
+    return getBooleanConfig(
+        project,
+        pluginConfig,
+        KEY_ENABLE_CODE_OWNER_CONFIG_FILES_WITH_FILE_EXTENSIONS,
+        /* defaultValue= */ false);
+  }
+
+  /**
    * Returns the email domains that are allowed to be used for code owners.
    *
    * @return the email domains that are allowed to be used for code owners, an empty set if all
diff --git a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerConfigValidatorIT.java b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerConfigValidatorIT.java
index 1a4ec49..06a83e8 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerConfigValidatorIT.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/acceptance/api/CodeOwnerConfigValidatorIT.java
@@ -110,6 +110,47 @@
   }
 
   @Test
+  public void codeOwnerConfigFileWithNonMatchingFileExtensionIsNotValidated() throws Exception {
+    PushOneCommit.Result r =
+        createChange(
+            "Add code owner config with file extension",
+            getCodeOwnerConfigFileName() + ".foo",
+            "INVALID");
+    assertOkWithoutMessages(r);
+  }
+
+  @Test
+  @GerritConfig(name = "plugin.code-owners.fileExtension", value = "foo")
+  public void codeOwnerConfigFileWithMatchingFileExtensionIsValidated() throws Exception {
+    PushOneCommit.Result r =
+        createChange(
+            "Add code owner config with file extension",
+            getCodeOwnerConfigFileName() + ".foo",
+            "INVALID");
+    String abbreviatedCommit = abbreviateName(r.getCommit());
+    r.assertErrorStatus(
+        String.format(
+            "commit %s: [code-owners] %s", abbreviatedCommit, "invalid code owner config files"));
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.enableCodeOwnerConfigFilesWithFileExtensions",
+      value = "true")
+  public void codeOwnerConfigFileWithFileExtensionIsValidatedIfFileExtensionsAreEnabled()
+      throws Exception {
+    PushOneCommit.Result r =
+        createChange(
+            "Add code owner config with file extension",
+            getCodeOwnerConfigFileName() + ".foo",
+            "INVALID");
+    String abbreviatedCommit = abbreviateName(r.getCommit());
+    r.assertErrorStatus(
+        String.format(
+            "commit %s: [code-owners] %s", abbreviatedCommit, "invalid code owner config files"));
+  }
+
+  @Test
   public void canUploadConfigWithoutIssues() throws Exception {
     CodeOwnerConfig.Key codeOwnerConfigKey = createCodeOwnerConfigKey("/");
 
@@ -1412,6 +1453,121 @@
   }
 
   @Test
+  public void cannotUploadConfigWithGlobalImportOfFileWithFileExtension() throws Exception {
+    testCannotUploadConfigWithImportOfFileWithFileExtension(CodeOwnerConfigImportType.GLOBAL);
+  }
+
+  @Test
+  public void cannotUploadConfigWithPerFileImportOfFileWithFileExtension() throws Exception {
+    testCannotUploadConfigWithImportOfFileWithFileExtension(CodeOwnerConfigImportType.PER_FILE);
+  }
+
+  private void testCannotUploadConfigWithImportOfFileWithFileExtension(
+      CodeOwnerConfigImportType importType) throws Exception {
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
+
+    // create a code owner config that imports a code owner config from the same folder but with a
+    // file extension in the file name
+    CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig = createCodeOwnerConfigKey("/");
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .fileName(getCodeOwnerConfigFileName() + ".extension")
+            .addCodeOwnerEmail(user.email())
+            .create();
+    GitUtil.fetch(testRepo, "refs/*:refs/*");
+    testRepo.reset(projectOperations.project(project).getHead("master"));
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        CodeOwnerConfigReference.builder(
+                CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY,
+                codeOwnerConfigOperations
+                    .codeOwnerConfig(keyOfImportedCodeOwnerConfig)
+                    .getFilePath())
+            .setProject(project)
+            .build();
+    CodeOwnerConfig codeOwnerConfig =
+        createCodeOwnerConfigWithImport(
+            keyOfImportingCodeOwnerConfig, importType, codeOwnerConfigReference);
+
+    PushOneCommit.Result r =
+        createChange(
+            "Add code owners",
+            codeOwnerConfigOperations
+                .codeOwnerConfig(keyOfImportingCodeOwnerConfig)
+                .getJGitFilePath(),
+            format(codeOwnerConfig));
+    assertErrorWithMessages(
+        r,
+        "invalid code owner config files",
+        String.format(
+            "invalid %s import in '%s':" + " '%s' is not a code owner config file",
+            importType.getType(),
+            codeOwnerConfigOperations.codeOwnerConfig(keyOfImportingCodeOwnerConfig).getFilePath(),
+            codeOwnerConfigOperations.codeOwnerConfig(keyOfImportedCodeOwnerConfig).getFilePath()));
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.enableCodeOwnerConfigFilesWithFileExtensions",
+      value = "true")
+  public void canUploadConfigWithGlobalImportOfFileWithFileExtensionIfFileExtensionsAreEnabled()
+      throws Exception {
+    testUploadConfigWithImportOfFileWithFileExtension(CodeOwnerConfigImportType.GLOBAL);
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.enableCodeOwnerConfigFilesWithFileExtensions",
+      value = "true")
+  public void canUploadConfigWithPerFileImportOfFileWithFileExtensionIfFileExtensionsAreEnabled()
+      throws Exception {
+    testUploadConfigWithImportOfFileWithFileExtension(CodeOwnerConfigImportType.PER_FILE);
+  }
+
+  private void testUploadConfigWithImportOfFileWithFileExtension(
+      CodeOwnerConfigImportType importType) throws Exception {
+    skipTestIfImportsNotSupportedByCodeOwnersBackend();
+
+    // create a code owner config that imports a code owner config from the same folder but with a
+    // file extension in the file name
+    CodeOwnerConfig.Key keyOfImportingCodeOwnerConfig = createCodeOwnerConfigKey("/");
+    CodeOwnerConfig.Key keyOfImportedCodeOwnerConfig =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .fileName(getCodeOwnerConfigFileName() + ".extension")
+            .addCodeOwnerEmail(user.email())
+            .create();
+    GitUtil.fetch(testRepo, "refs/*:refs/*");
+    testRepo.reset(projectOperations.project(project).getHead("master"));
+    CodeOwnerConfigReference codeOwnerConfigReference =
+        CodeOwnerConfigReference.builder(
+                CodeOwnerConfigImportMode.GLOBAL_CODE_OWNER_SETS_ONLY,
+                codeOwnerConfigOperations
+                    .codeOwnerConfig(keyOfImportedCodeOwnerConfig)
+                    .getFilePath())
+            .setProject(project)
+            .build();
+    CodeOwnerConfig codeOwnerConfig =
+        createCodeOwnerConfigWithImport(
+            keyOfImportingCodeOwnerConfig, importType, codeOwnerConfigReference);
+
+    PushOneCommit.Result r =
+        createChange(
+            "Add code owners",
+            codeOwnerConfigOperations
+                .codeOwnerConfig(keyOfImportingCodeOwnerConfig)
+                .getJGitFilePath(),
+            format(codeOwnerConfig));
+    r.assertOkStatus();
+  }
+
+  @Test
   public void cannotUploadConfigWithGlobalImportFromNonExistingProject() throws Exception {
     testUploadConfigWithImportFromNonExistingProject(CodeOwnerConfigImportType.GLOBAL);
   }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersTest.java
index 486ea88..92f7f5b 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/PathCodeOwnersTest.java
@@ -543,6 +543,98 @@
   }
 
   @Test
+  public void importOfCodeOwnerConfigFileWithFileExtensionIsIgnored() throws Exception {
+    // Create a code owner config file with a file extension. This file is only considered as a code
+    // owner config file if either the file extension matches the configured file extension (config
+    // parameter fileExtension) or file extensions are enabled for code owner config files (config
+    // paramater enableCodeOwnerConfigFilesWithFileExtensions). Both is not the case here, hence any
+    // import of this file in another code owner config file should get ignored.
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .fileName("OWNERS.foo")
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+    // create the importing config
+    CodeOwnerConfig.Key importingCodeOwnerConfigKey =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .addImport(
+                CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "/OWNERS.FOO"))
+            .addCodeOwnerSet(CodeOwnerSet.builder().addCodeOwnerEmail(admin.email()).build())
+            .create();
+
+    Optional<PathCodeOwners> pathCodeOwners =
+        pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
+            importingCodeOwnerConfigKey,
+            projectOperations.project(project).getHead("master"),
+            Paths.get("/foo.md"));
+    assertThat(pathCodeOwners).isPresent();
+
+    // Expectation: we get the global code owner from the importing code owner config, the
+    // import of the code owner config file with the file extension is silently ignored since it is
+    // not considered as a code owner config file
+    PathCodeOwnersResult pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
+    assertThat(pathCodeOwnersResult.getPathCodeOwners())
+        .comparingElementsUsing(hasEmail())
+        .containsExactly(admin.email());
+    assertThat(pathCodeOwnersResult.hasUnresolvedImports()).isTrue();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.enableCodeOwnerConfigFilesWithFileExtensions",
+      value = "true")
+  public void importOfCodeOwnerConfigFileWithFileExtension() throws Exception {
+    // Create a code owner config file with a file extension. This file is considered as a code
+    // owner config file since file extensions for code owner config files are enabled (paramater
+    // enableCodeOwnerConfigFilesWithFileExtensions).
+    codeOwnerConfigOperations
+        .newCodeOwnerConfig()
+        .project(project)
+        .branch("master")
+        .folderPath("/")
+        .fileName("OWNERS.FOO")
+        .addCodeOwnerEmail(user.email())
+        .create();
+
+    // create the importing config
+    CodeOwnerConfig.Key importingCodeOwnerConfigKey =
+        codeOwnerConfigOperations
+            .newCodeOwnerConfig()
+            .project(project)
+            .branch("master")
+            .folderPath("/")
+            .addImport(
+                CodeOwnerConfigReference.create(CodeOwnerConfigImportMode.ALL, "/OWNERS.FOO"))
+            .addCodeOwnerSet(CodeOwnerSet.builder().addCodeOwnerEmail(admin.email()).build())
+            .create();
+
+    Optional<PathCodeOwners> pathCodeOwners =
+        pathCodeOwnersFactory.create(
+            transientCodeOwnerConfigCacheProvider.get(),
+            importingCodeOwnerConfigKey,
+            projectOperations.project(project).getHead("master"),
+            Paths.get("/foo.md"));
+    assertThat(pathCodeOwners).isPresent();
+
+    // Expectation: we get the global code owner from the importing code owner config and the global
+    // code owner from the imported code owner config
+    PathCodeOwnersResult pathCodeOwnersResult = pathCodeOwners.get().resolveCodeOwnerConfig().get();
+    assertThat(pathCodeOwnersResult.getPathCodeOwners())
+        .comparingElementsUsing(hasEmail())
+        .containsExactly(admin.email(), user.email());
+    assertThat(pathCodeOwnersResult.hasUnresolvedImports()).isFalse();
+  }
+
+  @Test
   public void importGlobalCodeOwners_importModeAll() throws Exception {
     testImportGlobalCodeOwners(CodeOwnerConfigImportMode.ALL);
   }
diff --git a/javatests/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfigTest.java b/javatests/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfigTest.java
index 9220d02..3f6ba86 100644
--- a/javatests/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfigTest.java
+++ b/javatests/com/google/gerrit/plugins/codeowners/backend/config/GeneralConfigTest.java
@@ -17,6 +17,7 @@
 import static com.google.common.truth.Truth.assertThat;
 import static com.google.gerrit.plugins.codeowners.backend.config.CodeOwnersPluginConfiguration.SECTION_CODE_OWNERS;
 import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.DEFAULT_MAX_PATHS_IN_CHANGE_MESSAGES;
+import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_ENABLE_CODE_OWNER_CONFIG_FILES_WITH_FILE_EXTENSIONS;
 import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_ENABLE_IMPLICIT_APPROVALS;
 import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_ENABLE_VALIDATION_ON_COMMIT_RECEIVED;
 import static com.google.gerrit.plugins.codeowners.backend.config.GeneralConfig.KEY_ENABLE_VALIDATION_ON_SUBMIT;
@@ -91,6 +92,90 @@
   }
 
   @Test
+  public void cannotGetEnableCodeOwnerConfigFilesWithFileExtensionsForNullProject()
+      throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                generalConfig.enableCodeOwnerConfigFilesWithFileExtensions(
+                    /* project= */ null, new Config()));
+    assertThat(npe).hasMessageThat().isEqualTo("project");
+  }
+
+  @Test
+  public void cannotGetEnableCodeOwnerConfigFilesWithFileExtensionsForNullPluginConfig()
+      throws Exception {
+    NullPointerException npe =
+        assertThrows(
+            NullPointerException.class,
+            () ->
+                generalConfig.enableCodeOwnerConfigFilesWithFileExtensions(
+                    project, /* pluginConfig= */ null));
+    assertThat(npe).hasMessageThat().isEqualTo("pluginConfig");
+  }
+
+  @Test
+  public void noEnableCodeOwnerConfigFilesWithFileExtensionsConfiguration() throws Exception {
+    assertThat(generalConfig.enableCodeOwnerConfigFilesWithFileExtensions(project, new Config()))
+        .isFalse();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.enableCodeOwnerConfigFilesWithFileExtensions",
+      value = "true")
+  public void
+      enableCodeOwnerConfigFilesWithFileExtensionsConfigurationIsRetrievedFromGerritConfigIfNotSpecifiedOnProjectLevel()
+          throws Exception {
+    assertThat(generalConfig.enableCodeOwnerConfigFilesWithFileExtensions(project, new Config()))
+        .isTrue();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.enableCodeOwnerConfigFilesWithFileExtensions",
+      value = "true")
+  public void
+      enableCodeOwnerConfigFilesWithFileExtensionsConfigurationInPluginConfigOverridesReadOnlyConfigurationInGerritConfig()
+          throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        KEY_ENABLE_CODE_OWNER_CONFIG_FILES_WITH_FILE_EXTENSIONS,
+        "false");
+    assertThat(generalConfig.enableCodeOwnerConfigFilesWithFileExtensions(project, cfg)).isFalse();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.enableCodeOwnerConfigFilesWithFileExtensions",
+      value = "true")
+  public void
+      invalidEnableCodeOwnerConfigFilesWithFileExtensionsConfigurationInPluginConfigIsIgnored()
+          throws Exception {
+    Config cfg = new Config();
+    cfg.setString(
+        SECTION_CODE_OWNERS,
+        /* subsection= */ null,
+        KEY_ENABLE_CODE_OWNER_CONFIG_FILES_WITH_FILE_EXTENSIONS,
+        "INVALID");
+    assertThat(generalConfig.enableCodeOwnerConfigFilesWithFileExtensions(project, cfg)).isTrue();
+  }
+
+  @Test
+  @GerritConfig(
+      name = "plugin.code-owners.enableCodeOwnerConfigFilesWithFileExtensions",
+      value = "INVALID")
+  public void
+      invalidEnableCodeOwnerConfigFilesWithFileExtensionsConfigurationInGerritConfigIsIgnored()
+          throws Exception {
+    assertThat(generalConfig.enableCodeOwnerConfigFilesWithFileExtensions(project, new Config()))
+        .isFalse();
+  }
+
+  @Test
   @GerritConfig(
       name = "plugin.code-owners.allowedEmailDomain",
       values = {"example.com", "example.net"})
diff --git a/resources/Documentation/backend-find-owners.md b/resources/Documentation/backend-find-owners.md
index 122b598..b258374 100644
--- a/resources/Documentation/backend-find-owners.md
+++ b/resources/Documentation/backend-find-owners.md
@@ -151,12 +151,25 @@
 copy or integrate the line between multiple `OWNERS` files.
 
 The file that is referenced by the `file` keyword must be a code owner config
-file. This means it cannot have an arbitrary name, but the file name must be
-`OWNERS` or `OWNER.<file-extension>`, if a
-[file extension](#codeOwnerConfigFileExtension) is configured. In addition it is
-allowed that the file names have an arbitray prefix (`<prefix>_OWNERS`, e.g.
-`BUILD_OWNERS`) or an arbitrary extension (`OWNERS_<extension>`, e.g.
-`OWNERS_BUILD`).
+file.
+
+By default, only `OWNERS` files and `OWNERS` files with an arbitratry prefix or
+extension (`<prefix>_OWNERS`, e.g. `BUILD_OWNERS` and `OWNERS_<extension>`, e.g.
+`OWNERS_BUILD`) are considered as code owner config files.
+
+If a [file extension](#codeOwnerConfigFileExtension) is configured, only
+`OWNERS.<file-extension>`, `<prefix>_OWNERS.<file-extension>` and
+`OWNERS_<extension>.<file-extension>` files where `<file-extension>` matches the
+configured file extension are considered as code owner config files. These files
+are used instead of the `OWNERS`, `<prefix>_OWNERS` and `OWNERS_<extension>`
+files, which are ignored if a file extension is configured.
+
+If arbitrary file extensions for code owner config files are
+[enabled](config.html#codeOwnersEnableCodeOwnerConfigFilesWithFileExtensions)
+`OWNERS.<file-extension>` files with any file extension are considered as code
+owner config files in addition to `OWNERS`, `<prefix>_OWNERS` and
+`OWNERS_<extension>` files (using this configuration option is not compatible
+with configuring a [file extension](#codeOwnerConfigFileExtension)).
 
 It's also possible to reference code owner config files from other projects or
 branches (only within the same host):
diff --git a/resources/Documentation/config.md b/resources/Documentation/config.md
index b39e732..db5c9c8 100644
--- a/resources/Documentation/config.md
+++ b/resources/Documentation/config.md
@@ -158,7 +158,33 @@
         Can be overridden per project by setting
         [codeOwners.fileExtension](#codeOwnersFileExtension) in
         `@PLUGIN@.config`.\
-        By default unset (no file extension is used).
+        By default unset (no file extension is used).\
+        If a file extension is configured,
+        [plugin.@PLUGIN@.enableCodeOwnerConfigFilesWithFileExtensions](#pluginCodeOwnersEnableCodeOwnerConfigFilesWithFileExtensions)
+        should be set to `false`, as otherwise code owner config files with any
+        file extension will be validated, which causes validation errors if code
+        owner config files with other file extensions use a different owners
+        syntax or reference users that do not exist on this Gerrit host.
+
+<a id="pluginCodeOwnersEnableCodeOwnerConfigFilesWithFileExtensions">plugin.@PLUGIN@.enableCodeOwnerConfigFilesWithFileExtensions</a>
+:       Whether file extensions for code owner config files are enabled.\
+        If enabled, code owner config files with file extensions are treated as
+        regular code owner config files. This means they are validated on
+        push/submit (if validation is enabled) and can be imported by other code
+        owner config files (regardless of whether they have the same file
+        extension or not).\
+        Enabling this option should not be used in combination with the
+        [plugin.@PLUGIN@.fileExtension](#pluginCodeOwnersFileExtension) option
+        as that option uses file extensions to differentiate different sets of
+        code owner config files in the same repository/branch which may use
+        different code owner syntaxes or reference users that do not exist on
+        this Gerrit host. In this case, code owner config files with (other)
+        file extensions should not be validated as they likely will fail the
+        validation.\
+        Can be overridden per project by setting
+        [codeOwners.enableCodeOwnerConfigFilesWithFileExtensions](#codeOwnersEnableCodeOwnerConfigFilesWithFileExtensions)
+        in `@PLUGIN@.config`.\
+        By default `false`.
 
 <a id="pluginCodeOwnersOverrideInfoUrl">plugin.@PLUGIN@.overrideInfoUrl</a>
 :       A URL for a page that provides host-specific information about how to
@@ -638,7 +664,36 @@
         projects.\
         If not set, the global setting
         [plugin.@PLUGIN@.fileExtension](#pluginCodeOwnersFileExtension) in
-        `gerrit.config` is used.
+        `gerrit.config` is used.\
+        If a file extension is configured,
+        [codeOwners.enableCodeOwnerConfigFilesWithFileExtensions](#codeOwnersEnableCodeOwnerConfigFilesWithFileExtensions)
+        should be set to `false`, as otherwise code owner config files with any
+        file extension will be validated, which causes validation errors if code
+        owner config files with other file extensions use a different owners
+        syntax or reference users that do not exist on this Gerrit host.
+
+<a id="codeOwnersEnableCodeOwnerConfigFilesWithFileExtensions">codeOwners.enableCodeOwnerConfigFilesWithFileExtensions</a>
+:       Whether file extensions for code owner config files are enabled.\
+        If enabled, code owner config files with file extensions are treated as
+        regular code owner config files. This means they are validated on
+        push/submit (if validation is enabled) and can be imported by other code
+        owner config files (regardless of whether they have the same file
+        extension or not).\
+        Enabling this option should not be used in combination with the
+        [plugin.@PLUGIN@.fileExtension](#pluginCodeOwnersFileExtension) option
+        as that option uses file extensions to differentiate different sets of
+        code owner config files in the same repository/branch which may use
+        different code owner syntaxes or reference users that do not exist on
+        this Gerrit host. In this case, code owner config files with (other)
+        file extensions should not be validated as they likely will fail the
+        validation.
+        Overrides the global setting
+        [plugin.@PLUGIN@.enableCodeOwnerConfigFilesWithFileExtensions](#pluginCodeOwnersEnableCodeOwnerConfigFilesWithFileExtensions)
+        in `gerrit.config` and the `codeOwners.fileExtension` setting from
+        parent projects.\
+        If not set, the global setting
+        [plugin.@PLUGIN@.enableCodeOwnerConfigFilesWithFileExtensions](#pluginCodeOwnersEnableCodeOwnerConfigFilesWithFileExtensions)
+        in `gerrit.config` is used.\
 
 <a id="codeOwnersOverrideInfoUrl">codeOwners.overrideInfoUrl</a>
 :       A URL for a page that provides project-specific information about how to
diff --git a/resources/Documentation/validation.md b/resources/Documentation/validation.md
index 61cf60d..e8ed8d5 100644
--- a/resources/Documentation/validation.md
+++ b/resources/Documentation/validation.md
@@ -66,7 +66,9 @@
   configured](setup-guide.html#configureCodeOwnersBackend) which now uses a
   different syntax or different names for code owner config files, the [file
   extension for code owner config file is set/changed](config.html#codeOwnersFileExtension),
-  or the [allowed email domains are changed](config.html#pluginCodeOwnersAllowedEmailDomain))
+  [arbitrary file extensions for code owner config files](config.html#codeOwnersEnableCodeOwnerConfigFilesWithFileExtensions)
+  get enabled/disabled or the [allowed email domains are
+  changed](config.html#pluginCodeOwnersAllowedEmailDomain))
 * emails of users may change so that emails in code owner configs can no longer
   be resolved
 * imported code owner config files may get deleted or renamed so that import