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

Cole-Greer pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/tinkerpop.git


The following commit(s) were added to refs/heads/master by this push:
     new de8954ad41 Restrict Class value deserialization in GraphSON and Gryo 
(master) (#3706)
de8954ad41 is described below

commit de8954ad41fe06327b4513b19ddebf60029e387d
Author: Iryna-Kahamlyk <[email protected]>
AuthorDate: Thu Oct 22 00:25:49 2026 -0700

    Restrict Class value deserialization in GraphSON and Gryo (master) (#3706)
    
    Ported the upstream fix 
(https://github.com/apache/tinkerpop/commit/d557266e4046672cbb5ed98fab56135ab87c38d2)
 to master, adding class deserialization allowlisting for GraphSON and Gryo to 
prevent arbitrary class loading. GraphBinary changes were not needed on master.
---
 CHANGELOG.asciidoc                                 |   1 +
 docs/src/reference/gremlin-applications.asciidoc   |  21 ++
 docs/src/reference/the-traversal.asciidoc          |   4 +
 docs/src/upgrade/release-4.x.x.asciidoc            |  17 ++
 .../gremlin/structure/io/ClassRegistry.java        | 119 ++++++++++
 .../io/graphson/ClassJacksonDeserializer.java      |  54 +++++
 .../structure/io/graphson/GraphSONModule.java      |   6 +
 .../gremlin/structure/io/gryo/UtilSerializers.java |  16 +-
 .../gremlin/structure/io/ClassRegistryTest.java    | 246 ++++++++++++++++++++
 .../io/graphson/ClassJacksonDeserializerTest.java  | 219 ++++++++++++++++++
 .../structure/io/gryo/ClassSerializerTest.java     | 250 +++++++++++++++++++++
 .../gremlin/structure/io/gryo/GryoMapperTest.java  |   3 +-
 12 files changed, 945 insertions(+), 11 deletions(-)

diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc
index 41e5a47ac8..4808c2e2f1 100644
--- a/CHANGELOG.asciidoc
+++ b/CHANGELOG.asciidoc
@@ -26,6 +26,7 @@ 
image::https://raw.githubusercontent.com/apache/tinkerpop/master/docs/static/ima
 === TinkerPop 4.0.0 (Release Date: NOT OFFICIALLY RELEASED YET)
 
 * Added a `Char` type to `gremlin-go` to represent the Gremlin `Character` 
type in `GremlinLang`.
+* Restricted GraphSON and Gryo `Class` deserialization to classes registered 
with `ClassRegistry.register()`, `registerStrategy()` or `registerStrategies()`.
 * Fixed `gremlin-go` to report a malformed or truncated GraphBinary response 
as a deserialization error rather than a bare decoder message.
 * Made `TinkerGraph` an interface and renamed the in-memory implementation to 
`TinkerMemoryGraph`; `TinkerGraph.open()` and `gremlin.graph=...TinkerGraph` 
behave as before.
 * Renamed `TinkerTransactionGraph` to `TinkerStorageGraph`.
diff --git a/docs/src/reference/gremlin-applications.asciidoc 
b/docs/src/reference/gremlin-applications.asciidoc
index d39caf8690..ed8092c43a 100644
--- a/docs/src/reference/gremlin-applications.asciidoc
+++ b/docs/src/reference/gremlin-applications.asciidoc
@@ -1136,6 +1136,27 @@ It has the MIME type of 
`application/vnd.graphbinary-v4.0` and the following con
 
 As described above, there are multiple ways in which to register serializers 
for GraphBinary-based serialization.
 
+[[class-registry]]
+===== Class Registry
+
+A `Class` value in GraphSON or Gryo is resolved through `ClassRegistry` rather 
than through a class loader, so it can
+only name a class registered in advance. A name the registry does not hold is 
refused and the named class is never
+loaded.
+
+[source,java]
+----
+ClassRegistry.register(MyClass.class);
+ClassRegistry.unregister(MyClass.class);
+----
+
+Registration is required in Java on the deserialization path only. 
`ClassRegistry` matches the fully qualified class
+name exactly, so registering a superclass or an interface does not admit its 
subclasses or implementors. Each concrete
+class that is deserialized needs its own registration.
+
+A `TraversalStrategy` is not registered here. Strategies belong to 
`TraversalStrategies.GlobalCache.GLOBAL_REGISTRY`,
+which `ClassRegistry.lookup` also reads, so a registered strategy is nameable 
already and passing one to
+`ClassRegistry.register` or `unregister` throws `IllegalArgumentException`.
+
 [[metrics]]
 ==== Metrics
 
diff --git a/docs/src/reference/the-traversal.asciidoc 
b/docs/src/reference/the-traversal.asciidoc
index 0a97fdd136..abbc25675b 100644
--- a/docs/src/reference/the-traversal.asciidoc
+++ b/docs/src/reference/the-traversal.asciidoc
@@ -2905,6 +2905,8 @@ g.io("graph.json").write().iterate();
 
 NOTE: Additional documentation for GraphSON can be found in the 
link:https://tinkerpop.apache.org/docs/x.y.z/dev/io/#graphson[IO Reference].
 
+NOTE: A `Class` stored as a property value can only be read back if it is 
registered, see <<class-registry,Class Registry>>.
+
 anchor:gryo-reader-writer[]
 [[gryo]]
 ==== Gryo
@@ -2930,6 +2932,8 @@ g.io("graph.kryo").read().iterate()
 g.io("graph.kryo").write().iterate()
 ----
 
+NOTE: A `Class` stored as a property value can only be read back if it is 
registered, see <<class-registry,Class Registry>>.
+
 *Additional References*
 
 
link:++https://tinkerpop.apache.org/javadocs/x.y.z/core/org/apache/tinkerpop/gremlin/process/traversal/dsl/graph/GraphTraversalSource.html#io(java.lang.String)++[`io(String)`]
diff --git a/docs/src/upgrade/release-4.x.x.asciidoc 
b/docs/src/upgrade/release-4.x.x.asciidoc
index 16e98aa801..f495f36a53 100644
--- a/docs/src/upgrade/release-4.x.x.asciidoc
+++ b/docs/src/upgrade/release-4.x.x.asciidoc
@@ -178,6 +178,23 @@ Storage" above: the protected `graphLocation` and 
`graphFormat` fields and the `
 are removed, as is `requiresPersistence()` on the test provider 
`TinkerMemoryGraphProvider`. Durable storage is now
 the job of a `TinkerStorage` engine on `TinkerStorageGraph`.
 
+===== Class Values Must Be Registered
+
+For deserialization, a `Class` value must now name a registered class in 
GraphSON and Gryo alike. An unregistered name
+is refused on read and the class is never loaded. GraphBinary has no generic 
`Class` wire type and is unaffected.
+Strategies registered with `TraversalStrategies.GlobalCache.registerStrategy` 
already resolve, so only an application
+or provider that serializes its own classes needs to act.
+
+[source,java]
+----
+ClassRegistry.register(MyClass.class);
+ClassRegistry.unregister(MyClass.class);
+----
+
+Registration is required in Java for deserialization only, which typically 
only impacts the server. Without
+registration, read attempts fail with a `Class not recognized` message naming 
the class, raised as a
+`MismatchedInputException` for GraphSON and a `RuntimeException` for Gryo.
+
 ==== Graph Driver Providers
 
 Drivers should send the user agent only as the `User-Agent` request header, in 
the format described in the provider
diff --git 
a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/ClassRegistry.java
 
b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/ClassRegistry.java
new file mode 100644
index 0000000000..f4af05a728
--- /dev/null
+++ 
b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/ClassRegistry.java
@@ -0,0 +1,119 @@
+/*
+ * 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.tinkerpop.gremlin.structure.io;
+
+import org.apache.tinkerpop.gremlin.process.traversal.TraversalStrategies;
+import org.apache.tinkerpop.gremlin.process.traversal.TraversalStrategy;
+import org.apache.tinkerpop.gremlin.process.traversal.step.map.MatchStep;
+import org.apache.tinkerpop.gremlin.structure.util.detached.DetachedFactory;
+import org.apache.tinkerpop.gremlin.structure.util.reference.ReferenceFactory;
+
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Holds the classes a GraphSON or Gryo {@code Class} value may name, so such 
a name is resolved from this registry
+ * rather than by a class loader. Registration is a Java API only and refuses 
a {@link TraversalStrategy}, which
+ * belongs to {@link TraversalStrategies.GlobalCache}.
+ * <p/>
+ * {@link #lookup(String)} also falls back to {@code 
GlobalCache.getRegisteredStrategyClassByFullName}, so a strategy
+ * the selector can construct is nameable without being registered here.
+ */
+public final class ClassRegistry {
+
+    private ClassRegistry() {
+    }
+
+    /**
+     * Fully qualified names ({@link Class#getName()}) of the classes a 
serialized traversal may name as a generic
+     * {@code Class} value but not construct. {@link #lookup(String)} guards a 
{@code null} name itself, because a
+     * {@code ConcurrentHashMap} refuses a {@code null} key.
+     */
+    private static final Map<String, Class<?>> REGISTRY = new 
ConcurrentHashMap<>();
+
+    // this block writes REGISTRY through register(), so the field above has 
to be declared first.
+    static {
+        // Gryo names these two out of MatchAlgorithmStrategy's 
matchAlgorithmClass field, the count one being what that
+        // strategy's builder holds when no algorithm is named.
+        register(MatchStep.GreedyMatchAlgorithm.class);
+        register(MatchStep.CountMatchAlgorithm.class);
+
+        // Gryo names these two out of HaltedTraverserStrategy's 
haltedTraverserFactory field, which holds one or the
+        // other and nothing else.
+        register(DetachedFactory.class);
+        register(ReferenceFactory.class);
+    }
+
+    /**
+     * Registers a class as nameable as a generic {@code Class} value, without 
making it constructible from a serialized
+     * traversal, so it cannot widen what the selector can construct. A {@link 
TraversalStrategy} is refused with an
+     * {@link IllegalArgumentException} and a {@code null} with a {@link 
NullPointerException}.
+     */
+    public static void register(final Class<?> clazz) {
+        Objects.requireNonNull(clazz, "clazz can not be null");
+        rejectStrategy(clazz);
+
+        REGISTRY.put(clazz.getName(), clazz);
+    }
+
+    /**
+     * Removes a class from this registry, so it is no longer nameable as a 
generic {@code Class} value. A
+     * {@link TraversalStrategy} is refused because strategies belong to 
{@link TraversalStrategies.GlobalCache}, and a
+     * {@code null} is refused with a {@link NullPointerException}.
+     */
+    public static void unregister(final Class<?> clazz) {
+        Objects.requireNonNull(clazz, "clazz can not be null");
+        rejectStrategy(clazz);
+
+        REGISTRY.remove(clazz.getName());
+    }
+
+    /**
+     * Looks up a class nameable as a generic {@code Class} value, without 
loading it. Matching is on the exact
+     * {@link Class#getName()}, never assignability, so registering a 
superclass or an interface does not admit its
+     * subtypes. A {@code null} name yields an empty {@link Optional}, since a 
{@code Class} value can name nothing.
+     */
+    public static Optional<Class<?>> lookup(final String className) {
+        // a null key would throw, and it can match nothing, so it leaves 
before the fall-back and its initialization.
+        if (null == className)
+            return Optional.empty();
+
+        final Class<?> clazz = REGISTRY.get(className);
+
+        // this call also forces GlobalCache initialization, which registers 
the strategies from registerStrategies().
+        // Leave it here: a cached copy of what it reads would not.
+        return null != clazz
+                ? Optional.of(clazz)
+                : 
TraversalStrategies.GlobalCache.getRegisteredStrategyClassByFullName(className)
+                        .map(c -> (Class<?>) c);
+    }
+
+    /**
+     * Refuses a {@link TraversalStrategy}, so that a caller who reaches for 
this registry is sent to the one the
+     * strategy selector reads and {@link #lookup(String)} falls back to. Both 
callers guard {@code null} before this
+     * runs, so a class is assumed here.
+     */
+    private static void rejectStrategy(final Class<?> clazz) {
+        if (TraversalStrategy.class.isAssignableFrom(clazz))
+            throw new IllegalArgumentException("TraversalStrategy classes are 
registered with " +
+                    "TraversalStrategies.GlobalCache, not ClassRegistry - " + 
clazz.getName());
+    }
+}
diff --git 
a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/graphson/ClassJacksonDeserializer.java
 
b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/graphson/ClassJacksonDeserializer.java
new file mode 100644
index 0000000000..d4583a47ea
--- /dev/null
+++ 
b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/graphson/ClassJacksonDeserializer.java
@@ -0,0 +1,54 @@
+/*
+ * 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.tinkerpop.gremlin.structure.io.graphson;
+
+import org.apache.tinkerpop.gremlin.structure.io.ClassRegistry;
+import org.apache.tinkerpop.shaded.jackson.core.JsonParser;
+import org.apache.tinkerpop.shaded.jackson.core.JsonToken;
+import org.apache.tinkerpop.shaded.jackson.databind.DeserializationContext;
+import org.apache.tinkerpop.shaded.jackson.databind.deser.std.StdDeserializer;
+
+import java.io.IOException;
+
+/**
+ * Resolves the name a {@code Class} value carries through {@link 
ClassRegistry} rather than through a class loader, so
+ * the named class is never loaded. Registering this keeps Jackson's own 
{@code Class} deserializer from running, which
+ * would resolve the name with {@code Class.forName(name, true, loader)} and 
initialize whatever it reached.
+ */
+final class ClassJacksonDeserializer extends StdDeserializer<Class> {
+
+    ClassJacksonDeserializer() {
+        super(Class.class);
+    }
+
+    @Override
+    public Class deserialize(final JsonParser jsonParser, final 
DeserializationContext deserializationContext) throws IOException {
+        // the parser arrives positioned on the value, so the name is the 
current token and not the next one.
+        // A value that is not a string names no class, so it becomes a null 
name and is refused by the same lookup.
+        final String name = JsonToken.VALUE_STRING == 
jsonParser.currentToken() ? jsonParser.getText() : null;
+
+        return ClassRegistry.lookup(name).
+                orElseThrow(() -> new IOException("Class not recognized - " + 
name));
+    }
+
+    @Override
+    public boolean isCachable() {
+        return true;
+    }
+}
diff --git 
a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/graphson/GraphSONModule.java
 
b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/graphson/GraphSONModule.java
index 95acff7c07..afd7a79e26 100644
--- 
a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/graphson/GraphSONModule.java
+++ 
b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/graphson/GraphSONModule.java
@@ -437,6 +437,9 @@ abstract class GraphSONModule extends 
TinkerPopJacksonModule {
                 addDeserializer(Set.class, new 
JavaUtilSerializersV3.SetJacksonDeserializer());
             }
 
+            // java.lang - a Class value names a registered strategy rather 
than anything a class loader can reach
+            addDeserializer(Class.class, new ClassJacksonDeserializer());
+
             // numbers
             addDeserializer(Integer.class, new 
GraphSONSerializersV3.IntegerJackonsDeserializer());
             addDeserializer(Double.class, new 
GraphSONSerializersV3.DoubleJacksonDeserializer());
@@ -670,6 +673,9 @@ abstract class GraphSONModule extends 
TinkerPopJacksonModule {
             addDeserializer(Tree.class, new 
GraphSONSerializersV2.TreeJacksonDeserializer());
             addDeserializer(TraversalStrategyProxy.class, new 
TraversalSerializersV2.TraversalStrategyProxyJacksonDeserializer<>());
 
+            // java.lang - a Class value names a registered strategy rather 
than anything a class loader can reach
+            addDeserializer(Class.class, new ClassJacksonDeserializer());
+
             // numbers
             addDeserializer(Integer.class, new 
GraphSONSerializersV2.IntegerJacksonDeserializer());
             addDeserializer(Double.class, new 
GraphSONSerializersV2.DoubleJacksonDeserializer());
diff --git 
a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/gryo/UtilSerializers.java
 
b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/gryo/UtilSerializers.java
index bd7bc086e2..87a4f25407 100644
--- 
a/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/gryo/UtilSerializers.java
+++ 
b/gremlin-core/src/main/java/org/apache/tinkerpop/gremlin/structure/io/gryo/UtilSerializers.java
@@ -18,6 +18,7 @@
  */
 package org.apache.tinkerpop.gremlin.structure.io.gryo;
 
+import org.apache.tinkerpop.gremlin.structure.io.ClassRegistry;
 import org.apache.tinkerpop.gremlin.structure.io.gryo.kryoshim.InputShim;
 import org.apache.tinkerpop.gremlin.structure.io.gryo.kryoshim.KryoShim;
 import org.apache.tinkerpop.gremlin.structure.io.gryo.kryoshim.OutputShim;
@@ -96,11 +97,8 @@ final class UtilSerializers {
         @Override
         public <I extends InputShim> Class read(final KryoShim<I, ?> kryo, 
final I input, final Class<Class> clazz) {
             final String name = input.readString();
-            try {
-                return Class.forName(name);
-            } catch (Exception ex) {
-                throw new RuntimeException(ex);
-            }
+            return ClassRegistry.lookup(name).
+                    orElseThrow(() -> new RuntimeException("Class not 
recognized - " + name));
         }
     }
 
@@ -118,11 +116,9 @@ final class UtilSerializers {
             final int size = input.readInt();
             final Class[] clazzes = new Class[size];
             for (int i = 0; i < size; i++) {
-                try {
-                    clazzes[i] = Class.forName(input.readString());
-                } catch (Exception ex) {
-                    throw new RuntimeException(ex);
-                }
+                final String name = input.readString();
+                clazzes[i] = ClassRegistry.lookup(name).
+                        orElseThrow(() -> new RuntimeException("Class not 
recognized - " + name));
             }
             return clazzes;
         }
diff --git 
a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/ClassRegistryTest.java
 
b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/ClassRegistryTest.java
new file mode 100644
index 0000000000..48bd5f308c
--- /dev/null
+++ 
b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/ClassRegistryTest.java
@@ -0,0 +1,246 @@
+/*
+ * 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.tinkerpop.gremlin.structure.io;
+
+import org.apache.tinkerpop.gremlin.process.traversal.Traversal;
+import org.apache.tinkerpop.gremlin.process.traversal.TraversalStrategy;
+import org.apache.tinkerpop.gremlin.process.traversal.step.map.MatchStep;
+import 
org.apache.tinkerpop.gremlin.process.traversal.strategy.AbstractTraversalStrategy;
+import 
org.apache.tinkerpop.gremlin.process.traversal.strategy.verification.ReadOnlyStrategy;
+import org.apache.tinkerpop.gremlin.structure.util.detached.DetachedFactory;
+import org.apache.tinkerpop.gremlin.structure.util.reference.ReferenceFactory;
+import org.junit.After;
+import org.junit.Test;
+
+import java.util.Arrays;
+
+import static 
org.apache.tinkerpop.gremlin.process.traversal.TraversalStrategies.GlobalCache.getRegisteredStrategyClassByFullName;
+import static 
org.apache.tinkerpop.gremlin.process.traversal.TraversalStrategies.GlobalCache.unregisterStrategy;
+import static org.apache.tinkerpop.gremlin.structure.io.ClassRegistry.lookup;
+import static org.apache.tinkerpop.gremlin.structure.io.ClassRegistry.register;
+import static 
org.apache.tinkerpop.gremlin.structure.io.ClassRegistry.unregister;
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.StringContains.containsString;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.fail;
+
+/**
+ * Covers what {@link ClassRegistry} holds, what it refuses, and how a name 
resolves against it. Also covers
+ * {@link ClassRegistry#lookup(String)} falling back into {@code 
TraversalStrategies.GlobalCache}, which is how a
+ * strategy the selector can construct is nameable without being registered 
here.
+ */
+public class ClassRegistryTest {
+
+    @After
+    public void unregisterProviderType() {
+        unregister(ProviderType.class);
+    }
+
+    /**
+     * The registry holds the classes that are not strategies, which is what 
it is for, so a registered one resolves by
+     * its fully qualified name without being known to the strategy selector.
+     */
+    @Test
+    public void shouldResolveRegisteredClassAsNameable() {
+        register(ProviderType.class);
+
+        assertEquals(ProviderType.class, 
lookup(ProviderType.class.getName()).get());
+    }
+
+    /**
+     * Gryo names the two algorithms out of {@code MatchAlgorithmStrategy}'s 
{@code matchAlgorithmClass} field and the
+     * two factories out of {@code HaltedTraverserStrategy}'s {@code 
haltedTraverserFactory} field, so the seeds are
+     * what keep those readable. Nothing else here covers the seeds, only Gryo 
suites in this module.
+     */
+    @Test
+    public void shouldSeedTheClassesGryoNamesFromStrategyFields() {
+        for (final Class<?> clazz : 
Arrays.asList(MatchStep.GreedyMatchAlgorithm.class,
+                MatchStep.CountMatchAlgorithm.class, DetachedFactory.class, 
ReferenceFactory.class)) {
+            assertEquals("the seed for " + clazz.getName() + " is missing", 
clazz,
+                    lookup(clazz.getName()).orElse(null));
+        }
+    }
+
+    /**
+     * A {@link TraversalStrategy} is registered with {@code GlobalCache}, 
which is what makes it nameable, so this
+     * registry refuses one rather than holding a second place to register it. 
Both a strategy the selector knows and
+     * one it does not are refused, since the class being a strategy is the 
whole of the test.
+     */
+    @Test
+    public void shouldRefuseToRegisterAStrategy() {
+        for (final Class<? extends TraversalStrategy> clazz : 
Arrays.asList(ReadOnlyStrategy.class,
+                AbsentStrategy.class)) {
+            try {
+                register(clazz);
+                fail("A TraversalStrategy must not be registered with 
ClassRegistry - " + clazz.getName());
+            } catch (IllegalArgumentException ex) {
+                assertThat(ex.getMessage(), containsString(clazz.getName()));
+                assertThat(ex.getMessage(), 
containsString("TraversalStrategies.GlobalCache"));
+            }
+        }
+    }
+
+    /**
+     * A caller unregistering a strategy here is making the same mistake as 
one registering it, since
+     * {@code GlobalCache.unregisterStrategy} is what takes a strategy's 
nameability away.
+     */
+    @Test
+    public void shouldRefuseToUnregisterAStrategy() {
+        try {
+            unregister(ReadOnlyStrategy.class);
+            fail("A TraversalStrategy must not be unregistered from 
ClassRegistry");
+        } catch (IllegalArgumentException ex) {
+            assertThat(ex.getMessage(), 
containsString(ReadOnlyStrategy.class.getName()));
+            assertThat(ex.getMessage(), 
containsString("TraversalStrategies.GlobalCache"));
+        }
+
+        assertEquals(ReadOnlyStrategy.class, 
lookup(ReadOnlyStrategy.class.getName()).get());
+    }
+
+    /**
+     * A {@code null} reaching registration is a mistake in a caller's Java 
code rather than anything a serialized
+     * traversal can send, so it fails with a message naming the parameter 
instead of the bare
+     * {@link NullPointerException} that {@code isAssignableFrom} throws, 
which names nothing at all. Contrast
+     * {@link #shouldNotResolveNullAsNameable}, where a {@code null} arrives 
on a read path and must not throw.
+     */
+    @Test
+    public void shouldRefuseToRegisterNull() {
+        try {
+            register(null);
+            fail("A null must not be registered with ClassRegistry");
+        } catch (NullPointerException ex) {
+            assertThat(ex.getMessage(), containsString("clazz"));
+        }
+    }
+
+    /**
+     * Unregistration guards a {@code null} the same way, since both entry 
points reach the same field and a caller
+     * passing one has made the same mistake in either direction.
+     */
+    @Test
+    public void shouldRefuseToUnregisterNull() {
+        try {
+            unregister(null);
+            fail("A null must not be unregistered from ClassRegistry");
+        } catch (NullPointerException ex) {
+            assertThat(ex.getMessage(), containsString("clazz"));
+        }
+    }
+
+    /**
+     * The other direction of the containment: construction implies 
nameability, so a strategy registered for the
+     * selector resolves through both accessors without being registered as 
nameable.
+     */
+    @Test
+    public void shouldResolveConstructibleStrategyAsNameable() {
+        assertEquals(ReadOnlyStrategy.class,
+                
getRegisteredStrategyClassByFullName(ReadOnlyStrategy.class.getName()).get());
+        assertEquals(ReadOnlyStrategy.class,
+                lookup(ReadOnlyStrategy.class.getName()).get());
+    }
+
+    @Test
+    public void shouldNotResolveUnregisteredStrategyAsNameable() {
+        unregisterStrategy(AbsentStrategy.class);
+        assertFalse(lookup(AbsentStrategy.class.getName()).isPresent());
+    }
+
+    /**
+     * A {@code null} must reach an empty {@code Optional} rather than a 
{@link NullPointerException}, since an
+     * unchecked throw on a deserialization read path escapes the request 
handlers and leaves the client with no
+     * response at all.
+     */
+    @Test
+    public void shouldNotResolveNullAsNameable() {
+        assertFalse(lookup(null).isPresent());
+    }
+
+    @Test
+    public void shouldNotResolveSimpleNameAsNameable() {
+        register(ProviderType.class);
+
+        
assertFalse(lookup(ReadOnlyStrategy.class.getSimpleName()).isPresent());
+        assertFalse(lookup(ProviderType.class.getSimpleName()).isPresent());
+    }
+
+    /**
+     * {@link ClassRegistry#lookup(String)} matches the exact {@link 
Class#getName()}, never assignability, so
+     * registering a superclass admits that class alone. {@code Object} is the 
widest case there is: were the match on
+     * assignability, one registration would make every class on the classpath 
nameable. Registered here rather than in
+     * {@code unregisterProviderType} because no other test wants it, so it is 
undone in a {@code finally}.
+     */
+    @Test
+    public void shouldNotResolveSubclassOfRegisteredClassAsNameable() {
+        register(Object.class);
+        try {
+            assertEquals(Object.class, 
lookup(Object.class.getName()).orElse(null));
+
+            assertFalse(lookup(ProviderType.class.getName()).isPresent());
+            assertFalse(lookup(String.class.getName()).isPresent());
+        } finally {
+            unregister(Object.class);
+        }
+    }
+
+    /**
+     * The same exact match applies to an interface, so registering one does 
not admit the classes that implement it.
+     * {@code ProviderType} implements {@code ProviderInterface} for this 
test, so the registration being no help to it
+     * is the point rather than an accident of the two being unrelated.
+     */
+    @Test
+    public void shouldNotResolveImplementorOfRegisteredInterfaceAsNameable() {
+        register(ProviderInterface.class);
+        try {
+            assertEquals(ProviderInterface.class, 
lookup(ProviderInterface.class.getName()).orElse(null));
+
+            assertFalse(lookup(ProviderType.class.getName()).isPresent());
+        } finally {
+            unregister(ProviderInterface.class);
+        }
+    }
+
+    /**
+     * Stands in for an interface a provider registers as nameable. {@code 
ProviderType} implements it so that
+     * registering the interface can be shown not to admit the implementor.
+     */
+    private interface ProviderInterface {
+    }
+
+    /**
+     * Stands in for a class a provider registers as nameable, which is 
deliberately not a {@link TraversalStrategy}
+     * because the registry refuses one. It is unregistered again in {@code 
unregisterProviderType}.
+     */
+    private static final class ProviderType implements ProviderInterface {
+    }
+
+    /**
+     * A {@link TraversalStrategy} that the selector does not know, so 
refusing to register it tests the class being a
+     * strategy rather than the selector already holding it.
+     */
+    private static final class AbsentStrategy
+            extends 
AbstractTraversalStrategy<TraversalStrategy.DecorationStrategy>
+            implements TraversalStrategy.DecorationStrategy {
+
+        @Override
+        public void apply(final Traversal.Admin<?, ?> traversal) {
+            // do nothing
+        }
+    }
+}
diff --git 
a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/graphson/ClassJacksonDeserializerTest.java
 
b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/graphson/ClassJacksonDeserializerTest.java
new file mode 100644
index 0000000000..50fc9c4bd4
--- /dev/null
+++ 
b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/graphson/ClassJacksonDeserializerTest.java
@@ -0,0 +1,219 @@
+/*
+ * 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.tinkerpop.gremlin.structure.io.graphson;
+
+import org.apache.tinkerpop.gremlin.process.traversal.Traversal;
+import org.apache.tinkerpop.gremlin.process.traversal.TraversalStrategy;
+import 
org.apache.tinkerpop.gremlin.process.traversal.strategy.AbstractTraversalStrategy;
+import 
org.apache.tinkerpop.gremlin.process.traversal.strategy.decoration.SubgraphStrategy;
+import org.apache.tinkerpop.gremlin.structure.io.ClassRegistry;
+import org.apache.tinkerpop.shaded.jackson.databind.ObjectMapper;
+import 
org.apache.tinkerpop.shaded.jackson.databind.exc.MismatchedInputException;
+import org.junit.After;
+import org.junit.Test;
+
+import java.io.File;
+import java.util.Arrays;
+import java.util.List;
+
+import static org.hamcrest.MatcherAssert.assertThat;
+import static org.hamcrest.core.StringContains.containsString;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+/**
+ * Each test reads through both the V2 and V3 mappers, which share one 
deserializer. The file is not parameterized by
+ * version because the canary can report only one load, so a second run would 
see a flag the first had already set.
+ */
+public class ClassJacksonDeserializerTest extends AbstractGraphSONTest {
+
+    private final ObjectMapper mapperV2 = 
GraphSONMapper.build().version(GraphSONVersion.V2_0).
+            typeInfo(TypeInfo.PARTIAL_TYPES).create().createMapper();
+
+    private final ObjectMapper mapperV3 = 
GraphSONMapper.build().version(GraphSONVersion.V3_0).
+            typeInfo(TypeInfo.PARTIAL_TYPES).create().createMapper();
+
+    @After
+    public void unregisterProviderType() {
+        ClassRegistry.unregister(ProviderType.class);
+    }
+
+    /**
+     * {@code SubgraphStrategy} is registered with {@code 
TraversalStrategies.GlobalCache}, which the registry falls
+     * back to, so nothing has to register it here.
+     */
+    @Test
+    public void shouldReadClassOfRegisteredStrategy() throws Exception {
+        assertEquals(SubgraphStrategy.class, serializeDeserialize(mapperV2, 
SubgraphStrategy.class, Class.class));
+        assertEquals(SubgraphStrategy.class, serializeDeserialize(mapperV3, 
SubgraphStrategy.class, Class.class));
+    }
+
+    /**
+     * A provider makes a class of its own nameable by calling {@link 
ClassRegistry#register(Class)}, so that call is
+     * pinned here through the deserializer rather than only through the 
registry's fall-back to {@code GlobalCache}.
+     * {@code ProviderType} is registered rather than refused, so do not write 
a refusal test against it.
+     */
+    @Test
+    public void shouldReadClassRegisteredThroughClassRegistry() throws 
Exception {
+        ClassRegistry.register(ProviderType.class);
+
+        assertEquals(ProviderType.class, serializeDeserialize(mapperV2, 
ProviderType.class, Class.class));
+        assertEquals(ProviderType.class, serializeDeserialize(mapperV3, 
ProviderType.class, Class.class));
+    }
+
+    @Test
+    public void shouldRejectClassThatIsNotRegistered() throws Exception {
+        final String fqcn = File.class.getName();
+
+        assertThat(refusalOf(mapperV2, fqcn).getMessage(), 
containsString(fqcn));
+        assertThat(refusalOf(mapperV3, fqcn).getMessage(), 
containsString(fqcn));
+    }
+
+    /**
+     * The counterpart to the refusal below, without which a blanket refusal 
of every list holding a {@code Class} would
+     * pass that test. Both names resolve, one through {@code GlobalCache} and 
one through {@link ClassRegistry}, so both
+     * resolution paths are covered inside a container, and both elements are 
pinned by identity and in order so a
+     * partly read or empty list cannot pass.
+     */
+    @Test
+    public void shouldReadListElementsThatAreRegistered() throws Exception {
+        ClassRegistry.register(ProviderType.class);
+
+        final String elements = classValue(SubgraphStrategy.class.getName()) + 
"," +
+                classValue(ProviderType.class.getName());
+
+        // V2 writes a list as a bare JSON array while V3 wraps it in g:List, 
as in the refusal below
+        final List<?> fromV2 = readList(mapperV2, "[" + elements + "]");
+        final List<?> fromV3 = readList(mapperV3, 
"{\"@type\":\"g:List\",\"@value\":[" + elements + "]}");
+
+        for (final List<?> read : Arrays.asList(fromV2, fromV3)) {
+            assertEquals(2, read.size());
+            assertSame(SubgraphStrategy.class, read.get(0));
+            assertSame(ProviderType.class, read.get(1));
+        }
+    }
+
+    /**
+     * A {@code Class} held inside a container reaches the same deserializer 
as one read on its own, because a list
+     * reads each element as an {@code Object} and so resolves the type the 
element itself carries. The unregistered
+     * name is the second element, so the refusal covers every element rather 
than only the first. V2 writes a list as a
+     * bare JSON array while V3 wraps it in {@code g:List}, so the two reads 
are not handed the same document.
+     */
+    @Test
+    public void shouldRejectListElementThatIsNotRegistered() throws Exception {
+        final String refusal = "Class not recognized - " + 
File.class.getName();
+        final String elements = classValue(SubgraphStrategy.class.getName()) + 
"," +
+                classValue(File.class.getName());
+
+        assertThat(refusalOfJson(mapperV2, "[" + elements + "]").getMessage(), 
containsString(refusal));
+        assertThat(refusalOfJson(mapperV3, 
"{\"@type\":\"g:List\",\"@value\":[" + elements + "]}").getMessage(),
+                containsString(refusal));
+    }
+
+    @Test
+    public void shouldNotLoadClassNamedByUnregisteredStrategy() throws 
Exception {
+        assertFalse("the canary was already initialised before the read, so 
this fixture no longer proves anything",
+                CanaryFlag.initialised);
+
+        final String fqcn = ClassJacksonDeserializerTest.class.getName() + 
"$CanaryStrategy";
+        assertThat(refusalOf(mapperV2, fqcn).getMessage(), 
containsString(fqcn));
+        assertThat(refusalOf(mapperV3, fqcn).getMessage(), 
containsString(fqcn));
+
+        assertFalse("reading the name of an unregistered strategy initialised 
the class it named",
+                CanaryFlag.initialised);
+
+        Class.forName(fqcn);
+        assertTrue("the canary cannot report a load, so the assertions above 
cannot fail", CanaryFlag.initialised);
+    }
+
+    /**
+     * Reads a {@code Class} value naming {@code fqcn} into an {@code Object}, 
as a bytecode argument arrives, and
+     * returns the refusal. The deserializer's {@code IOException} surfaces as 
a {@link MismatchedInputException}.
+     */
+    private MismatchedInputException refusalOf(final ObjectMapper mapper, 
final String fqcn) throws Exception {
+        return refusalOfJson(mapper, classValue(fqcn));
+    }
+
+    /**
+     * Reads {@code json} into an {@code Object} and hands back the refusal it 
produced, failing the calling test if it
+     * deserialized instead.
+     */
+    private MismatchedInputException refusalOfJson(final ObjectMapper mapper, 
final String json) throws Exception {
+        try {
+            mapper.readValue(json, Object.class);
+        } catch (MismatchedInputException ex) {
+            return ex;
+        }
+
+        fail("A class that is not a registered strategy must not deserialize - 
" + json);
+        return null;
+    }
+
+    /**
+     * Reads {@code json} into an {@code Object}, as a bytecode argument 
arrives, and hands back the list it produced.
+     */
+    private List<?> readList(final ObjectMapper mapper, final String json) 
throws Exception {
+        return (List<?>) mapper.readValue(json, Object.class);
+    }
+
+    /**
+     * The {@code Class} value a client sends, as it appears both on its own 
and as an element of a list.
+     */
+    private static String classValue(final String fqcn) {
+        return "{\"@type\":\"g:Class\",\"@value\":\"" + fqcn + "\"}";
+    }
+
+    /**
+     * The flag lives here rather than on {@code CanaryStrategy} because 
reading a static field initialises the class
+     * declaring it, so a flag on {@code CanaryStrategy} could not be read 
without loading what it reports on.
+     */
+    static final class CanaryFlag {
+        static boolean initialised;
+    }
+
+    /**
+     * A {@link TraversalStrategy} that is never registered, so refusing it 
tests registry membership rather than the
+     * class not being a strategy. Never name it as a class literal and never 
register it.
+     */
+    static final class CanaryStrategy
+            extends 
AbstractTraversalStrategy<TraversalStrategy.DecorationStrategy>
+            implements TraversalStrategy.DecorationStrategy {
+
+        static {
+            CanaryFlag.initialised = true;
+        }
+
+        @Override
+        public void apply(final Traversal.Admin<?, ?> traversal) {
+            // do nothing
+        }
+    }
+
+    /**
+     * Stands in for a class a provider ships, registered by each test that 
reads it and unregistered again in
+     * {@code unregisterProviderType}. It is deliberately not a {@link 
TraversalStrategy}, which
+     * the registry refuses, and it is registered rather than refused, so it 
is not a refusal fixture:
+     * {@code CanaryStrategy} is the class that is never registered.
+     */
+    private static final class ProviderType {
+    }
+}
diff --git 
a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/gryo/ClassSerializerTest.java
 
b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/gryo/ClassSerializerTest.java
new file mode 100644
index 0000000000..98f1acf806
--- /dev/null
+++ 
b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/gryo/ClassSerializerTest.java
@@ -0,0 +1,250 @@
+/*
+ * 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.tinkerpop.gremlin.structure.io.gryo;
+
+import org.apache.tinkerpop.gremlin.process.traversal.Traversal;
+import org.apache.tinkerpop.gremlin.process.traversal.TraversalStrategy;
+import 
org.apache.tinkerpop.gremlin.process.traversal.strategy.AbstractTraversalStrategy;
+import 
org.apache.tinkerpop.gremlin.process.traversal.strategy.decoration.SubgraphStrategy;
+import org.apache.tinkerpop.gremlin.structure.io.ClassRegistry;
+import 
org.apache.tinkerpop.gremlin.structure.io.gryo.kryoshim.shaded.ShadedInputAdapter;
+import org.apache.tinkerpop.shaded.kryo.Kryo;
+import org.apache.tinkerpop.shaded.kryo.io.Input;
+import org.apache.tinkerpop.shaded.kryo.io.Output;
+import org.junit.After;
+import org.junit.Test;
+
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.File;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.Arrays;
+
+import static org.junit.Assert.assertArrayEquals;
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertSame;
+import static org.junit.Assert.assertTrue;
+import static org.junit.Assert.fail;
+
+/**
+ * Covers both serializers that read a class name, {@code ClassSerializer} for 
a single value and
+ * {@code ClassArraySerializer} for the {@code withoutStrategies()} argument. 
Each test reads through both Gryo
+ * versions, which share them. The file is not parameterized by version 
because the canary can report only one load, so
+ * a second run would see a flag the first had already set.
+ */
+public class ClassSerializerTest {
+
+    private static final GryoVersion[] VERSIONS = new 
GryoVersion[]{GryoVersion.V1_0, GryoVersion.V3_0};
+
+    @After
+    public void unregisterProviderType() {
+        ClassRegistry.unregister(ProviderType.class);
+    }
+
+    /**
+     * A provider makes a class of its own nameable by calling {@link 
ClassRegistry#register(Class)}, so that call is
+     * pinned here through the serializer rather than only through the 
registry's fall-back to {@code GlobalCache}.
+     * {@code ProviderType} is registered rather than refused, so do not write 
a refusal test against it.
+     */
+    @Test
+    public void shouldReadClassRegisteredThroughClassRegistry() throws 
Exception {
+        ClassRegistry.register(ProviderType.class);
+
+        for (final GryoVersion version : VERSIONS) {
+            assertEquals(ProviderType.class, roundTrip(version, 
ProviderType.class, Class.class));
+        }
+    }
+
+    /**
+     * {@code SubgraphStrategy} is registered with {@code 
TraversalStrategies.GlobalCache}, which the registry falls
+     * back to, so only {@code ProviderType} has to be registered here. Two 
elements are written so that the read
+     * covers the loop rather than one name.
+     */
+    @Test
+    public void shouldReadClassArrayRegisteredThroughClassRegistry() throws 
Exception {
+        ClassRegistry.register(ProviderType.class);
+
+        final Class[] classes = new Class[]{SubgraphStrategy.class, 
ProviderType.class};
+        for (final GryoVersion version : VERSIONS) {
+            assertArrayEquals(classes, roundTrip(version, classes, 
Class[].class));
+        }
+    }
+
+    @Test
+    public void shouldRejectClassThatIsNotRegistered() throws Exception {
+        for (final GryoVersion version : VERSIONS) {
+            assertEquals("Class not recognized - " + File.class.getName(),
+                    refusalOf(version, File.class, Class.class).getMessage());
+        }
+    }
+
+    /**
+     * The unregistered name is the second element, so the refusal covers 
every element rather than only the first.
+     * Refusing part way through leaves the stream partly consumed, which 
costs nothing because the whole read fails.
+     */
+    @Test
+    public void shouldRejectClassArrayThatIsNotRegistered() throws Exception {
+        final Class[] classes = new Class[]{SubgraphStrategy.class, 
File.class};
+        for (final GryoVersion version : VERSIONS) {
+            assertEquals("Class not recognized - " + File.class.getName(),
+                    refusalOf(version, classes, Class[].class).getMessage());
+        }
+    }
+
+    /**
+     * The counterpart to the refusal below, without which a blanket refusal 
of every list holding a {@code Class} would
+     * pass that test. Both names resolve, one through {@code GlobalCache} and 
one through {@link ClassRegistry}, so both
+     * resolution paths are covered inside a container, and both elements are 
pinned by identity and in order so a
+     * partly read or empty list cannot pass.
+     */
+    @Test
+    public void shouldReadListElementsThatAreRegistered() throws Exception {
+        ClassRegistry.register(ProviderType.class);
+
+        final ArrayList<Class> classes = new 
ArrayList<>(Arrays.asList(SubgraphStrategy.class, ProviderType.class));
+        for (final GryoVersion version : VERSIONS) {
+            final ArrayList<?> read = roundTrip(version, classes, 
ArrayList.class);
+
+            assertEquals(2, read.size());
+            assertSame(SubgraphStrategy.class, read.get(0));
+            assertSame(ProviderType.class, read.get(1));
+        }
+    }
+
+    /**
+     * A {@code Class} held inside a container reaches the same serializer as 
one read on its own, because Kryo reads
+     * each element of a collection that carries no element type with the 
serializer registered for the class the
+     * element itself names. The unregistered name is the second element, so 
the refusal covers every element rather
+     * than only the first.
+     */
+    @Test
+    public void shouldRejectListElementThatIsNotRegistered() throws Exception {
+        final ArrayList<Class> classes = new 
ArrayList<>(Arrays.asList(SubgraphStrategy.class, File.class));
+        for (final GryoVersion version : VERSIONS) {
+            assertEquals("Class not recognized - " + File.class.getName(),
+                    refusalOf(version, classes, ArrayList.class).getMessage());
+        }
+    }
+
+    /**
+     * The refusal test above would pass against an implementation that 
resolved the name through a class loader before
+     * refusing, so this is the only test that distinguishes a refusal from 
never having loaded the class. It detects a
+     * return to the initialising {@code Class.forName(name)}. The 
three-argument form loads without initialising and
+     * would not be detected.
+     */
+    @Test
+    public void shouldNotLoadClassNamedByUnregisteredStrategy() throws 
Exception {
+        assertFalse("the canary was already initialised before the read, so 
this fixture no longer proves anything",
+                CanaryFlag.initialised);
+
+        final String fqcn = ClassSerializerTest.class.getName() + 
"$CanaryStrategy";
+        try {
+            readName(fqcn);
+            fail("A class that is not a registered strategy must not 
deserialize - " + fqcn);
+        } catch (RuntimeException ex) {
+            assertEquals("Class not recognized - " + fqcn, ex.getMessage());
+        }
+
+        assertFalse("reading the name of an unregistered strategy initialised 
the class it named",
+                CanaryFlag.initialised);
+
+        Class.forName(fqcn);
+        assertTrue("the canary cannot report a load, so the assertions above 
cannot fail", CanaryFlag.initialised);
+    }
+
+    private <T> T roundTrip(final GryoVersion version, final Object value, 
final Class<T> clazz) throws Exception {
+        final Kryo kryo = 
GryoMapper.build().version(version).create().createMapper();
+        try (final ByteArrayOutputStream stream = new ByteArrayOutputStream()) 
{
+            final Output output = new Output(stream);
+            kryo.writeObject(output, value);
+            output.flush();
+
+            try (final InputStream inputStream = new 
ByteArrayInputStream(stream.toByteArray())) {
+                return kryo.readObject(new Input(inputStream), clazz);
+            }
+        }
+    }
+
+    /**
+     * Round trips {@code value} and hands back the refusal the read produced, 
failing the calling test if it
+     * deserialized instead.
+     */
+    private RuntimeException refusalOf(final GryoVersion version, final Object 
value,
+                                       final Class<?> clazz) throws Exception {
+        try {
+            roundTrip(version, value, clazz);
+        } catch (RuntimeException ex) {
+            return ex;
+        }
+
+        fail("A class that is not a registered strategy must not deserialize - 
" + value);
+        return null;
+    }
+
+    /**
+     * Hands a name to {@code ClassSerializer} written the way its own {@code 
write} writes one. The writer takes a
+     * loaded {@code Class}, so a name that must stay unloaded cannot be 
produced by round tripping a class literal.
+     */
+    private Class readName(final String fqcn) {
+        final Output output = new Output(64, -1);
+        output.writeString(fqcn);
+        output.flush();
+
+        return new UtilSerializers.ClassSerializer().read(null,
+                new ShadedInputAdapter(new Input(output.toBytes())), 
Class.class);
+    }
+
+    /**
+     * Records that the static initialiser of {@code CanaryStrategy} ran. It 
is held here, on a class of its own,
+     * rather than on {@code CanaryStrategy} itself, because reading a static 
field initialises the class that declares
+     * it: a flag on {@code CanaryStrategy} could not be read without loading 
the very class it reports on.
+     */
+    static final class CanaryFlag {
+        static boolean initialised;
+    }
+
+    /**
+     * A {@link TraversalStrategy} on the test classpath, never registered, so 
refusing it tests registry membership
+     * rather than the class not being a strategy. Never name it as a class 
literal and never register it.
+     */
+    static final class CanaryStrategy
+            extends 
AbstractTraversalStrategy<TraversalStrategy.DecorationStrategy>
+            implements TraversalStrategy.DecorationStrategy {
+
+        static {
+            CanaryFlag.initialised = true;
+        }
+
+        @Override
+        public void apply(final Traversal.Admin<?, ?> traversal) {
+            // do nothing
+        }
+    }
+
+    /**
+     * Stands in for a class a provider ships, registered by each test that 
reads it and unregistered again in
+     * {@code unregisterProviderType}. It is deliberately not a {@link 
TraversalStrategy}, which the registry refuses,
+     * and it is registered rather than refused, so it is not a refusal 
fixture: {@code CanaryStrategy} is the class
+     * that is never registered.
+     */
+    private static final class ProviderType {
+    }
+}
diff --git 
a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/gryo/GryoMapperTest.java
 
b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/gryo/GryoMapperTest.java
index 06ec76009f..36a4f56367 100644
--- 
a/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/gryo/GryoMapperTest.java
+++ 
b/gremlin-core/src/test/java/org/apache/tinkerpop/gremlin/structure/io/gryo/GryoMapperTest.java
@@ -23,6 +23,7 @@ import 
org.apache.tinkerpop.gremlin.process.traversal.GremlinLang;
 import org.apache.tinkerpop.gremlin.process.traversal.Merge;
 import org.apache.tinkerpop.gremlin.process.traversal.TextP;
 import 
org.apache.tinkerpop.gremlin.process.traversal.strategy.decoration.OptionsStrategy;
+import 
org.apache.tinkerpop.gremlin.process.traversal.strategy.decoration.SubgraphStrategy;
 import 
org.apache.tinkerpop.gremlin.process.traversal.util.TraversalExplanation;
 import org.apache.tinkerpop.gremlin.structure.Vertex;
 import org.apache.tinkerpop.gremlin.structure.io.Io;
@@ -752,7 +753,7 @@ public class GryoMapperTest {
 
     @Test
     public void shouldHandleClass() throws Exception {
-        final Class<?> clazz = java.io.File.class;
+        final Class<?> clazz = SubgraphStrategy.class;
         assertEquals(clazz, serializeDeserialize(clazz, Class.class));
     }
 

Reply via email to