You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@polaris.apache.org by ad...@apache.org on 2026/04/30 20:07:36 UTC

(polaris) branch main updated: Reject invalid entity names in the REST layer (#4282)

This is an automated email from the ASF dual-hosted git repository.

adutra pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/polaris.git


The following commit(s) were added to refs/heads/main by this push:
     new b18c048add Reject invalid entity names in the REST layer (#4282)
b18c048add is described below

commit b18c048add63c3f0b29cbd17e23d259d9fbb5788
Author: Alexandre Dutra <ad...@apache.org>
AuthorDate: Thu Apr 30 22:07:32 2026 +0200

    Reject invalid entity names in the REST layer (#4282)
    
    Disallow slash, empty, and leading/trailing whitespace in names submitted to the create-namespace, create/register/rename-table, create/rename-view, and create-generic-table endpoints.
    
    Policies already enforce a stricter pattern via Open-API generated bean validation, so they are left untouched.
---
 CHANGELOG.md                                       |   1 +
 .../it/test/PolarisRestCatalogIntegrationBase.java | 179 +++++++++++++++++++++
 .../generic/GenericTableCatalogAdapter.java        |  11 +-
 .../catalog/iceberg/IcebergCatalogAdapter.java     |   7 +
 .../catalog/validation/EntityNameValidator.java    |  63 ++++++++
 .../iceberg/AbstractIcebergCatalogTest.java        |   5 +
 .../validation/EntityNameValidatorTest.java        | 138 ++++++++++++++++
 7 files changed, 400 insertions(+), 4 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index f0b539f42c..90228f4347 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -34,6 +34,7 @@ request adding CHANGELOG notes for breaking (!) changes and possibly other secti
 ### Breaking changes
 - The ExternalCatalogFactory interface has been renamed to FederatedCatalogFactory. Its createCatalog() and createGenericCatalog() method signatures have been extended to include a `catalogProperties` parameter of type `Map<String, String>` for passing through proxy and timeout settings to federated catalog HTTP clients.
 - The `ConnectionCredentials.of()` method now throws an exception when more than one expiration timestamp property is present in the credentials map. Only a single expiration timestamp is allowed per credentials bundle.
+- Entity names (namespaces, tables, views, generic tables) submitted to the REST layer are now rejected with HTTP 400 if they are empty, contain a `/`, or have leading/trailing whitespace. Clients that were previously able to create such entities must rename them before upgrading.
 
 ### New Features
 - Added `envFrom` support in Helm chart.
diff --git a/integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisRestCatalogIntegrationBase.java b/integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisRestCatalogIntegrationBase.java
index febc444c49..e12740a6fe 100644
--- a/integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisRestCatalogIntegrationBase.java
+++ b/integration-tests/src/main/java/org/apache/polaris/service/it/test/PolarisRestCatalogIntegrationBase.java
@@ -121,6 +121,8 @@ import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.TestInfo;
 import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 
 /**
  * Import the full core Iceberg catalog tests by hitting the REST service via the RESTCatalog
@@ -434,6 +436,11 @@ public abstract class PolarisRestCatalogIntegrationBase extends CatalogTests<RES
     return true;
   }
 
+  @Override
+  protected boolean supportsNamesWithSlashes() {
+    return false;
+  }
+
   @Override
   protected boolean overridesRequestedLocation() {
     return true;
@@ -2412,4 +2419,176 @@ public abstract class PolarisRestCatalogIntegrationBase extends CatalogTests<RES
     assertThat(nsResponse.namespaces()).hasSize(5);
     assertThat(nsResponse.nextPageToken()).isNull();
   }
+
+  @ParameterizedTest
+  @ValueSource(strings = {"bad/name", " leading", "trailing "})
+  public void testCreateNamespaceRejectsInvalidName(String badName) {
+    try (Response res =
+        catalogApi
+            .request("v1/{cat}/namespaces", Map.of("cat", currentCatalogName))
+            .post(Entity.json(Map.of("namespace", List.of(badName))))) {
+      assertThat(res.getStatus()).isEqualTo(Response.Status.BAD_REQUEST.getStatusCode());
+      assertThat(res.readEntity(String.class)).contains("Entity name");
+    }
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {"bad/name", " leading", "trailing "})
+  public void testCreateTableRejectsInvalidName(String badName) {
+    Namespace ns = Namespace.of("ns_create_table_bad");
+    restCatalog.createNamespace(ns);
+    try {
+      String nsEncoded = RESTUtil.encodeNamespace(ns);
+      try (Response res =
+          catalogApi
+              .request(
+                  "v1/{cat}/namespaces/{ns}/tables",
+                  Map.of("cat", currentCatalogName, "ns", nsEncoded))
+              .post(Entity.json(Map.of("name", badName, "schema", SCHEMA)))) {
+        assertThat(res.getStatus()).isEqualTo(Response.Status.BAD_REQUEST.getStatusCode());
+        assertThat(res.readEntity(String.class)).contains("Entity name");
+      }
+    } finally {
+      catalogApi.purge(currentCatalogName, ns);
+    }
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {"bad/name", " leading", "trailing "})
+  public void testRegisterTableRejectsInvalidName(String badName) {
+    Namespace ns = Namespace.of("ns_register_bad");
+    restCatalog.createNamespace(ns);
+    try {
+      String nsEncoded = RESTUtil.encodeNamespace(ns);
+      try (Response res =
+          catalogApi
+              .request(
+                  "v1/{cat}/namespaces/{ns}/register",
+                  Map.of("cat", currentCatalogName, "ns", nsEncoded))
+              .post(
+                  Entity.json(
+                      Map.of("name", badName, "metadata-location", "file:/tmp/nowhere.json")))) {
+        assertThat(res.getStatus()).isEqualTo(Response.Status.BAD_REQUEST.getStatusCode());
+        assertThat(res.readEntity(String.class)).contains("Entity name");
+      }
+    } finally {
+      catalogApi.purge(currentCatalogName, ns);
+    }
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {"bad/name", " leading", "trailing "})
+  public void testCreateViewRejectsInvalidName(String badName) {
+    Namespace ns = Namespace.of("ns_create_view_bad");
+    restCatalog.createNamespace(ns);
+    try {
+      String nsEncoded = RESTUtil.encodeNamespace(ns);
+      Map<String, Object> viewVersion =
+          Map.of(
+              "version-id",
+              1,
+              "timestamp-ms",
+              0,
+              "schema-id",
+              0,
+              "summary",
+              Map.of(),
+              "representations",
+              List.of(Map.of("type", "sql", "sql", VIEW_QUERY, "dialect", "spark")),
+              "default-namespace",
+              List.of("ns_create_view_bad"));
+      try (Response res =
+          catalogApi
+              .request(
+                  "v1/{cat}/namespaces/{ns}/views",
+                  Map.of("cat", currentCatalogName, "ns", nsEncoded))
+              .post(
+                  Entity.json(
+                      Map.of("name", badName, "schema", SCHEMA, "view-version", viewVersion)))) {
+        assertThat(res.getStatus()).isEqualTo(Response.Status.BAD_REQUEST.getStatusCode());
+        assertThat(res.readEntity(String.class)).contains("Entity name");
+      }
+    } finally {
+      catalogApi.purge(currentCatalogName, ns);
+    }
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {"bad/name", " leading", "trailing "})
+  public void testRenameTableRejectsInvalidDestinationName(String badName) {
+    Namespace ns = Namespace.of("ns_rename_table_bad");
+    restCatalog.createNamespace(ns);
+    try {
+      restCatalog.buildTable(TableIdentifier.of(ns, "src_tbl"), SCHEMA).create();
+      try (Response res =
+          catalogApi
+              .request("v1/{cat}/tables/rename", Map.of("cat", currentCatalogName))
+              .post(
+                  Entity.json(
+                      Map.of(
+                          "source", Map.of("namespace", List.of(ns.level(0)), "name", "src_tbl"),
+                          "destination",
+                              Map.of("namespace", List.of(ns.level(0)), "name", badName))))) {
+        assertThat(res.getStatus()).isEqualTo(Response.Status.BAD_REQUEST.getStatusCode());
+        assertThat(res.readEntity(String.class)).contains("Entity name");
+      }
+    } finally {
+      catalogApi.purge(currentCatalogName, ns);
+    }
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {"bad/name", " leading", "trailing "})
+  public void testRenameViewRejectsInvalidDestinationName(String badName) {
+    Namespace ns = Namespace.of("ns_rename_view_bad");
+    restCatalog.createNamespace(ns);
+    try {
+      restCatalog
+          .buildView(TableIdentifier.of(ns, "src_view"))
+          .withSchema(SCHEMA)
+          .withDefaultNamespace(ns)
+          .withQuery("spark", VIEW_QUERY)
+          .create();
+      try (Response res =
+          catalogApi
+              .request("v1/{cat}/views/rename", Map.of("cat", currentCatalogName))
+              .post(
+                  Entity.json(
+                      Map.of(
+                          "source", Map.of("namespace", List.of(ns.level(0)), "name", "src_view"),
+                          "destination",
+                              Map.of("namespace", List.of(ns.level(0)), "name", badName))))) {
+        assertThat(res.getStatus()).isEqualTo(Response.Status.BAD_REQUEST.getStatusCode());
+        assertThat(res.readEntity(String.class)).contains("Entity name");
+      }
+    } finally {
+      catalogApi.purge(currentCatalogName, ns);
+    }
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {"bad/name", " leading", "trailing "})
+  public void testCreateGenericTableRejectsInvalidName(String badName) {
+    Namespace ns = Namespace.of("ns_generic_bad");
+    restCatalog.createNamespace(ns);
+    try {
+      String nsEncoded = RESTUtil.encodeNamespace(ns);
+      try (Response res =
+          genericTableApi
+              .request(
+                  "polaris/v1/{cat}/namespaces/{ns}/generic-tables",
+                  Map.of("cat", currentCatalogName, "ns", nsEncoded))
+              .post(
+                  Entity.json(
+                      CreateGenericTableRequest.builder()
+                          .setName(badName)
+                          .setFormat("format")
+                          .build()))) {
+        assertThat(res.getStatus()).isEqualTo(Response.Status.BAD_REQUEST.getStatusCode());
+        assertThat(res.readEntity(String.class)).contains("Entity name");
+      }
+    } finally {
+      genericTableApi.purge(currentCatalogName, ns);
+    }
+  }
 }
diff --git a/runtime/service/src/main/java/org/apache/polaris/service/catalog/generic/GenericTableCatalogAdapter.java b/runtime/service/src/main/java/org/apache/polaris/service/catalog/generic/GenericTableCatalogAdapter.java
index 31188771c4..8e65652071 100644
--- a/runtime/service/src/main/java/org/apache/polaris/service/catalog/generic/GenericTableCatalogAdapter.java
+++ b/runtime/service/src/main/java/org/apache/polaris/service/catalog/generic/GenericTableCatalogAdapter.java
@@ -32,6 +32,7 @@ import org.apache.polaris.core.rest.NamespaceUtils;
 import org.apache.polaris.service.catalog.CatalogPrefixParser;
 import org.apache.polaris.service.catalog.api.PolarisCatalogGenericTableApiService;
 import org.apache.polaris.service.catalog.common.CatalogAdapter;
+import org.apache.polaris.service.catalog.validation.EntityNameValidator;
 import org.apache.polaris.service.config.ReservedProperties;
 import org.apache.polaris.service.types.CreateGenericTableRequest;
 import org.apache.polaris.service.types.ListGenericTablesResponse;
@@ -74,13 +75,15 @@ public class GenericTableCatalogAdapter
       String polarisGenericTableAccessDelegation,
       RealmContext realmContext,
       SecurityContext securityContext) {
+    TableIdentifier identifier =
+        TableIdentifier.of(
+            NamespaceUtils.splitNamespace(namespace, NamespaceUtils.DEFAULT_NAMESPACE_SEPARATOR),
+            createGenericTableRequest.getName());
+    EntityNameValidator.validateIdentifier(identifier);
     GenericTableCatalogHandler handler = newHandler(securityContext, prefix);
     LoadGenericTableResponse response =
         handler.createGenericTable(
-            TableIdentifier.of(
-                NamespaceUtils.splitNamespace(
-                    namespace, NamespaceUtils.DEFAULT_NAMESPACE_SEPARATOR),
-                createGenericTableRequest.getName()),
+            identifier,
             createGenericTableRequest.getFormat(),
             createGenericTableRequest.getBaseLocation(),
             createGenericTableRequest.getDoc(),
diff --git a/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogAdapter.java b/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogAdapter.java
index 780a85054f..98b1e46265 100644
--- a/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogAdapter.java
+++ b/runtime/service/src/main/java/org/apache/polaris/service/catalog/iceberg/IcebergCatalogAdapter.java
@@ -55,6 +55,7 @@ import org.apache.polaris.service.catalog.CatalogPrefixParser;
 import org.apache.polaris.service.catalog.api.IcebergRestCatalogApiService;
 import org.apache.polaris.service.catalog.api.IcebergRestConfigurationApiService;
 import org.apache.polaris.service.catalog.common.CatalogAdapter;
+import org.apache.polaris.service.catalog.validation.EntityNameValidator;
 import org.apache.polaris.service.config.ReservedProperties;
 import org.apache.polaris.service.http.IcebergHttpUtil;
 import org.apache.polaris.service.http.IfNoneMatch;
@@ -131,6 +132,7 @@ public class IcebergCatalogAdapter
       RealmContext realmContext,
       SecurityContext securityContext) {
     validateIcebergProperties(realmConfig, createNamespaceRequest.properties());
+    EntityNameValidator.validateNamespace(createNamespaceRequest.namespace());
     return withCatalog(
         securityContext,
         prefix,
@@ -267,6 +269,7 @@ public class IcebergCatalogAdapter
         parseAccessDelegationModes(accessDelegationMode);
     Namespace ns =
         NamespaceUtils.splitNamespace(namespace, NamespaceUtils.DEFAULT_NAMESPACE_SEPARATOR);
+    EntityNameValidator.validateIdentifier(TableIdentifier.of(ns, createTableRequest.name()));
     return withCatalog(
         securityContext,
         prefix,
@@ -412,6 +415,7 @@ public class IcebergCatalogAdapter
       SecurityContext securityContext) {
     Namespace ns =
         NamespaceUtils.splitNamespace(namespace, NamespaceUtils.DEFAULT_NAMESPACE_SEPARATOR);
+    EntityNameValidator.validateIdentifier(TableIdentifier.of(ns, registerTableRequest.name()));
     return withCatalog(
         securityContext,
         prefix,
@@ -429,6 +433,7 @@ public class IcebergCatalogAdapter
       RenameTableRequest renameTableRequest,
       RealmContext realmContext,
       SecurityContext securityContext) {
+    EntityNameValidator.validateIdentifier(renameTableRequest.destination());
     return withCatalog(
         securityContext,
         prefix,
@@ -489,6 +494,7 @@ public class IcebergCatalogAdapter
                 reservedProperties.removeReservedProperties(createViewRequest.properties()));
     Namespace ns =
         NamespaceUtils.splitNamespace(namespace, NamespaceUtils.DEFAULT_NAMESPACE_SEPARATOR);
+    EntityNameValidator.validateIdentifier(TableIdentifier.of(ns, revisedRequest.name()));
     return withCatalog(
         securityContext,
         prefix,
@@ -587,6 +593,7 @@ public class IcebergCatalogAdapter
       RenameTableRequest renameTableRequest,
       RealmContext realmContext,
       SecurityContext securityContext) {
+    EntityNameValidator.validateIdentifier(renameTableRequest.destination());
     return withCatalog(
         securityContext,
         prefix,
diff --git a/runtime/service/src/main/java/org/apache/polaris/service/catalog/validation/EntityNameValidator.java b/runtime/service/src/main/java/org/apache/polaris/service/catalog/validation/EntityNameValidator.java
new file mode 100644
index 0000000000..a86397327a
--- /dev/null
+++ b/runtime/service/src/main/java/org/apache/polaris/service/catalog/validation/EntityNameValidator.java
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 org.apache.polaris.service.catalog.validation;
+
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.catalog.TableIdentifier;
+
+/**
+ * Validates entity names provided by clients at the REST layer (tables, views, namespaces, generic
+ * tables, ...). A valid name:
+ *
+ * <ul>
+ *   <li>is not null or empty;
+ *   <li>does not contain a forward slash ({@code /});
+ *   <li>does not start or end with whitespace.
+ * </ul>
+ */
+public final class EntityNameValidator {
+
+  private EntityNameValidator() {}
+
+  /** Validates a single entity name (table, view, namespace level, ...). */
+  public static void validateName(String name) {
+    if (name == null || name.isEmpty()) {
+      throw new IllegalArgumentException("Entity name must not be empty");
+    }
+    if (name.indexOf('/') >= 0) {
+      throw new IllegalArgumentException("Entity name must not contain '/': " + name);
+    }
+    if (!name.equals(name.strip())) {
+      throw new IllegalArgumentException(
+          "Entity name must not have leading or trailing whitespace: " + name);
+    }
+  }
+
+  /** Validates each level of a namespace. */
+  public static void validateNamespace(Namespace namespace) {
+    for (String level : namespace.levels()) {
+      validateName(level);
+    }
+  }
+
+  public static void validateIdentifier(TableIdentifier identifier) {
+    validateNamespace(identifier.namespace());
+    validateName(identifier.name());
+  }
+}
diff --git a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractIcebergCatalogTest.java b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractIcebergCatalogTest.java
index 7bb7cb1ca1..ac99fc3de0 100644
--- a/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractIcebergCatalogTest.java
+++ b/runtime/service/src/test/java/org/apache/polaris/service/catalog/iceberg/AbstractIcebergCatalogTest.java
@@ -396,6 +396,11 @@ public abstract class AbstractIcebergCatalogTest extends CatalogTests<IcebergCat
         + identifier.name();
   }
 
+  @Override
+  protected boolean supportsNamesWithSlashes() {
+    return false;
+  }
+
   protected boolean supportsNotifications() {
     return true;
   }
diff --git a/runtime/service/src/test/java/org/apache/polaris/service/catalog/validation/EntityNameValidatorTest.java b/runtime/service/src/test/java/org/apache/polaris/service/catalog/validation/EntityNameValidatorTest.java
new file mode 100644
index 0000000000..85f8c6e60e
--- /dev/null
+++ b/runtime/service/src/test/java/org/apache/polaris/service/catalog/validation/EntityNameValidatorTest.java
@@ -0,0 +1,138 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you 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 org.apache.polaris.service.catalog.validation;
+
+import static org.assertj.core.api.Assertions.assertThatCode;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import org.apache.iceberg.catalog.Namespace;
+import org.apache.iceberg.catalog.TableIdentifier;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.NullAndEmptySource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+class EntityNameValidatorTest {
+
+  @ParameterizedTest
+  @ValueSource(
+      strings = {
+        "simple",
+        "with space",
+        "under_score",
+        "hy-phen",
+        "with.dot",
+        "café",
+        "日本語",
+        "a+b",
+        "percent%20encoded",
+      })
+  void validateNameAccepts(String name) {
+    assertThatCode(() -> EntityNameValidator.validateName(name)).doesNotThrowAnyException();
+  }
+
+  @ParameterizedTest
+  @NullAndEmptySource
+  void validateNameRejectsNullOrEmpty(String name) {
+    assertThatThrownBy(() -> EntityNameValidator.validateName(name))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessageContaining("empty");
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {"/", "a/b", "/leading", "trailing/", "a/b/c"})
+  void validateNameRejectsSlash(String name) {
+    assertThatThrownBy(() -> EntityNameValidator.validateName(name))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessageContaining("'/'");
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {" lead", "trail ", " both ", "\ttab", "line\n", " ", "\t", "\n"})
+  void validateNameRejectsSurroundingWhitespace(String name) {
+    assertThatThrownBy(() -> EntityNameValidator.validateName(name))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessageContaining("whitespace");
+  }
+
+  @Test
+  void validateNamespaceAcceptsEmpty() {
+    assertThatCode(() -> EntityNameValidator.validateNamespace(Namespace.empty()))
+        .doesNotThrowAnyException();
+  }
+
+  @Test
+  void validateNamespaceAcceptsMultiLevel() {
+    assertThatCode(
+            () -> EntityNameValidator.validateNamespace(Namespace.of("ns1", "ns2", "with space")))
+        .doesNotThrowAnyException();
+  }
+
+  @Test
+  void validateNamespaceRejectsLevelWithSlash() {
+    assertThatThrownBy(
+            () -> EntityNameValidator.validateNamespace(Namespace.of("ns1", "bad/level")))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessageContaining("'/'");
+  }
+
+  @Test
+  void validateNamespaceRejectsEmptyLevel() {
+    assertThatThrownBy(() -> EntityNameValidator.validateNamespace(Namespace.of("ns1", "")))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessageContaining("empty");
+  }
+
+  @Test
+  void validateNamespaceRejectsLevelWithSurroundingWhitespace() {
+    assertThatThrownBy(() -> EntityNameValidator.validateNamespace(Namespace.of("ns1", " bad ")))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessageContaining("whitespace");
+  }
+
+  @Test
+  void validateIdentifierAccepts() {
+    assertThatCode(
+            () ->
+                EntityNameValidator.validateIdentifier(
+                    TableIdentifier.of(Namespace.of("ns1", "ns2"), "table")))
+        .doesNotThrowAnyException();
+  }
+
+  @Test
+  void validateIdentifierRejectsBadNamespaceLevel() {
+    assertThatThrownBy(
+            () ->
+                EntityNameValidator.validateIdentifier(
+                    TableIdentifier.of(Namespace.of("ns1", "bad/level"), "table")))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessageContaining("'/'");
+  }
+
+  @Test
+  void validateIdentifierRejectsBadName() {
+    assertThatThrownBy(
+            () ->
+                EntityNameValidator.validateIdentifier(
+                    TableIdentifier.of(Namespace.of("ns1"), " bad ")))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessageContaining("whitespace");
+  }
+}