IGNITE-12664 Prevent BinaryObjectExImpl.toString failing with exception if a class of an object serialized with OptimizedMarshaller is not accessible (#7413)

diff --git a/modules/core/src/main/java/org/apache/ignite/internal/binary/BinaryObjectExImpl.java b/modules/core/src/main/java/org/apache/ignite/internal/binary/BinaryObjectExImpl.java
index e11d8e6..27bbbe7 100644
--- a/modules/core/src/main/java/org/apache/ignite/internal/binary/BinaryObjectExImpl.java
+++ b/modules/core/src/main/java/org/apache/ignite/internal/binary/BinaryObjectExImpl.java
@@ -29,6 +29,8 @@
 import org.apache.ignite.binary.BinaryObjectException;
 import org.apache.ignite.binary.BinaryType;
 import org.apache.ignite.internal.binary.builder.BinaryObjectBuilderImpl;
+import org.apache.ignite.internal.marshaller.optimized.OptimizedMarshallerInaccessibleClassException;
+import org.apache.ignite.internal.util.typedef.X;
 import org.apache.ignite.internal.util.typedef.internal.S;
 import org.apache.ignite.internal.util.typedef.internal.SB;
 import org.apache.ignite.lang.IgniteUuid;
@@ -228,7 +230,7 @@
             buf.a(" [idHash=").a(idHash).a(", hash=").a(hash);
 
             for (String name : meta.fieldNames()) {
-                Object val = field(ctx, name);
+                Object val = fieldForToString(ctx, name);
 
                 buf.a(", ").a(name).a('=');
 
@@ -241,6 +243,23 @@
         return buf.toString();
     }
 
+    /** */
+    private Object fieldForToString(BinaryReaderHandles ctx, String name) {
+        try {
+            return field(ctx, name);
+        }
+        catch (Exception e) {
+            OptimizedMarshallerInaccessibleClassException e1 =
+                X.cause(e, OptimizedMarshallerInaccessibleClassException.class);
+
+            String msg = "Failed to create a string representation";
+
+            return e1 != null
+                ? "(" + msg + ": class not found " + e1.inaccessibleClass() + ")"
+                : "(" + msg + ")";
+        }
+    }
+
     /**
      * @param val Value to append.
      * @param buf Buffer to append to.
diff --git a/modules/core/src/main/java/org/apache/ignite/internal/marshaller/optimized/OptimizedMarshaller.java b/modules/core/src/main/java/org/apache/ignite/internal/marshaller/optimized/OptimizedMarshaller.java
index afcabce..c77cfa25 100644
--- a/modules/core/src/main/java/org/apache/ignite/internal/marshaller/optimized/OptimizedMarshaller.java
+++ b/modules/core/src/main/java/org/apache/ignite/internal/marshaller/optimized/OptimizedMarshaller.java
@@ -227,7 +227,8 @@
             return (T)objIn.readObject();
         }
         catch (ClassNotFoundException e) {
-            throw new IgniteCheckedException("Failed to find class with given class loader for unmarshalling " +
+            throw new OptimizedMarshallerInaccessibleClassException(
+                "Failed to find class with given class loader for unmarshalling " +
                 "(make sure same versions of all classes are available on all nodes or enable peer-class-loading) " +
                 "[clsLdr=" + clsLdr + ", cls=" + e.getMessage() + "]", e);
         }
diff --git a/modules/core/src/main/java/org/apache/ignite/internal/marshaller/optimized/OptimizedMarshallerInaccessibleClassException.java b/modules/core/src/main/java/org/apache/ignite/internal/marshaller/optimized/OptimizedMarshallerInaccessibleClassException.java
new file mode 100644
index 0000000..ef8a1cf
--- /dev/null
+++ b/modules/core/src/main/java/org/apache/ignite/internal/marshaller/optimized/OptimizedMarshallerInaccessibleClassException.java
@@ -0,0 +1,37 @@
+/*
+ * 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.ignite.internal.marshaller.optimized;
+
+import org.apache.ignite.IgniteCheckedException;
+
+/** */
+public final class OptimizedMarshallerInaccessibleClassException extends IgniteCheckedException {
+    /** */
+    private static final long serialVersionUID = 0L;
+
+    /** */
+    public OptimizedMarshallerInaccessibleClassException(String msg, ClassNotFoundException cause) {
+        super(msg, cause);
+    }
+
+    /** */
+    public String inaccessibleClass() {
+        // Message of ClassNotFoundException is a not found class name
+        return getCause().getMessage();
+    }
+}
diff --git a/modules/core/src/test/java/org/apache/ignite/internal/binary/BinaryObjectToStringTest.java b/modules/core/src/test/java/org/apache/ignite/internal/binary/BinaryObjectToStringTest.java
new file mode 100644
index 0000000..a55d393
--- /dev/null
+++ b/modules/core/src/test/java/org/apache/ignite/internal/binary/BinaryObjectToStringTest.java
@@ -0,0 +1,231 @@
+/*
+ * 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.ignite.internal.binary;
+
+import com.google.common.collect.ImmutableMap;
+import java.io.Externalizable;
+import java.io.IOException;
+import java.io.ObjectInput;
+import java.io.ObjectOutput;
+import java.lang.reflect.Constructor;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.HashSet;
+import javassist.ClassPool;
+import javassist.CtClass;
+import javassist.CtField;
+import javassist.CtNewConstructor;
+import javassist.CtNewMethod;
+import org.apache.ignite.IgniteCache;
+import org.apache.ignite.configuration.CacheConfiguration;
+import org.apache.ignite.internal.IgniteEx;
+import org.apache.ignite.testframework.junits.common.GridCommonAbstractTest;
+import org.junit.Test;
+
+import static java.util.Collections.singletonList;
+
+/** */
+public class BinaryObjectToStringTest extends GridCommonAbstractTest {
+    /** */
+    @Test
+    public void testToStringInaccessibleOptimizedMarshallerClass() throws Exception {
+        IgniteEx ign = startGrid(0);
+
+        ign.createCache(new CacheConfiguration<>(DEFAULT_CACHE_NAME));
+
+        assertStringFormContains(new TestContainer(new CustomTestClass()), "CustomTestClass");
+        assertStringFormContains(new TestContainer(new ArrayList<>(singletonList(new CustomTestClass()))),
+            "ArrayList", "CustomTestClass");
+        assertStringFormContains(new TestContainer("abc"), "x=abc");
+        assertStringFormContains(new TestContainer(123), "x=123");
+        assertStringFormContains(new TestIntContainer(123), "i=123");
+
+        assertStringFormContains(new TestContainer(new int[]{1, 2}), "x=[1, 2]");
+        assertStringFormContains(new TestContainer(new Integer[]{1, 2}), "x=[1, 2]");
+        assertStringFormContains(new TestContainer(new ArrayList<>(Arrays.asList(1, 2))), "x=ArrayList {1, 2}");
+        assertStringFormContains(new TestContainer(new HashSet<>(Arrays.asList(1, 2))), "x=HashSet {1, 2}");
+        assertStringFormContains(new TestContainer(new HashMap<>(ImmutableMap.of(1, 2))), "x=HashMap {1=2}");
+
+        ArrayList<Object> nestedList = new ArrayList<>(Arrays.asList(
+            new ArrayList<>(Arrays.asList(1, 2)),
+            new ArrayList<>(Arrays.asList(3, 4))
+        ));
+        assertStringFormContains(new TestContainer(nestedList), "x=ArrayList {ArrayList {1, 2}, ArrayList {3, 4}}");
+
+        assertStringFormMatches(new TestContainer(newExtInstance1()), failedStrPattern("ExternalTestClass1"));
+        assertStringFormMatches(new TestContainer(newExtInstance2()), failedStrPattern("ExternalTestClass2"));
+
+        assertStringFormMatches(new TestContainer(new Object[] {newExtInstance1()}),
+            failedStrPattern("ExternalTestClass1"));
+        assertStringFormMatches(new TestContainer(new ArrayList<>(singletonList(newExtInstance1()))),
+            failedStrPattern("ExternalTestClass1"));
+        assertStringFormMatches(new TestContainer(new HashSet<>(singletonList(newExtInstance1()))),
+            failedStrPattern("ExternalTestClass1"));
+        assertStringFormMatches(new TestContainer(new HashMap<>(ImmutableMap.of(newExtInstance1(), newExtInstance2()))),
+            failedStrPattern("ExternalTestClass1"));
+
+        ArrayList<Object> nestedList2 = new ArrayList<>(singletonList(new ArrayList<>(singletonList(newExtInstance1()))));
+        assertStringFormMatches(new TestContainer(nestedList2), failedStrPattern("ExternalTestClass1"));
+
+        assertStringFormMatches(new TestContainer(new TestExternalizable(newExtInstance1())),
+            failedStrPattern("ExternalTestClass1"));
+    }
+
+    /** */
+    private String failedStrPattern(String className) {
+        return "org.apache.ignite.internal.binary.BinaryObjectToStringTest\\$TestContainer " +
+            "\\[idHash=-?\\d+, hash=-?\\d+, " +
+            "x=\\(Failed to create a string representation: class not found " + className + "\\)]";
+    }
+
+    /** */
+    private static class CustomTestClass {
+    }
+
+    /** */
+    private static class TestExternalizable implements Externalizable {
+        /** */
+        private Object x;
+
+        /** */
+        private TestExternalizable() {
+        }
+
+        /** */
+        private TestExternalizable(Object x) {
+            this.x = x;
+        }
+
+        /** */
+        @Override public void writeExternal(ObjectOutput out) throws IOException {
+            out.writeObject(x);
+        }
+
+        /** */
+        @Override public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {
+            x = in.readObject();
+        }
+    }
+
+    /** */
+    private void assertStringFormContains(Object o, String s0, String... ss) {
+        IgniteCache<Object, Object> cache = grid(0).cache(DEFAULT_CACHE_NAME);
+
+        String str = asBinaryObjectString(cache, o);
+
+        assertTrue(str.contains(s0));
+
+        for (String s : ss)
+            assertTrue(str.contains(s));
+    }
+
+    /** */
+    private void assertStringFormMatches(Object o, String pattern) {
+        IgniteCache<Object, Object> cache = grid(0).cache(DEFAULT_CACHE_NAME);
+
+        assertTrue(asBinaryObjectString(cache, o).matches(pattern));
+    }
+
+    /** */
+    private static String asBinaryObjectString(IgniteCache<Object, Object> cache, Object obj) {
+        cache.put(1, obj);
+
+        return cache.withKeepBinary().get(1).toString();
+    }
+
+    /** */
+    private Object newExtInstance1() throws Exception {
+        ClassPool classPool = new ClassPool(ClassPool.getDefault());
+
+        CtClass aClass = classPool.makeClass("ExternalTestClass1");
+        aClass.addInterface(classPool.get("java.io.Externalizable"));
+        aClass.addField(CtField.make("private int x;", aClass));
+        aClass.addConstructor(CtNewConstructor.make("public ExternalTestClass1() {}", aClass));
+        aClass.addConstructor(CtNewConstructor.make("public ExternalTestClass1(int x0) { x = x0; }", aClass));
+        aClass.addMethod(CtNewMethod.make(
+            "public void writeExternal(java.io.ObjectOutput out) throws java.io.IOException { out.writeInt(x); }",
+            aClass));
+        aClass.addMethod(CtNewMethod.make(
+            "public void readExternal(java.io.ObjectInput in) throws java.io.IOException { x = in.readInt(); }",
+            aClass));
+
+        ClassLoader extClsLdr = new ClassLoader() {{
+            byte[] bytecode = aClass.toBytecode();
+
+            defineClass("ExternalTestClass1", bytecode, 0, bytecode.length);
+        }};
+
+        Class<?> extClass = extClsLdr.loadClass("ExternalTestClass1");
+
+        Constructor<?> ctor = extClass.getConstructor(int.class);
+
+        return ctor.newInstance(42);
+    }
+
+    /** */
+    private Object newExtInstance2() throws Exception {
+        ClassPool classPool = new ClassPool(ClassPool.getDefault());
+
+        CtClass aClass = classPool.makeClass("ExternalTestClass2");
+        aClass.addInterface(classPool.get("java.io.Serializable"));
+        aClass.addField(CtField.make("private int x;", aClass));
+        aClass.addConstructor(CtNewConstructor.make("public ExternalTestClass2() {}", aClass));
+        aClass.addConstructor(CtNewConstructor.make("public ExternalTestClass2(int x0) { x = x0; }", aClass));
+        aClass.addMethod(CtNewMethod.make(
+            "private void writeObject(java.io.ObjectOutputStream out) throws java.io.IOException { out.writeInt(x); }",
+            aClass));
+        aClass.addMethod(CtNewMethod.make(
+            "private void readObject(java.io.ObjectInputStream in) throws java.io.IOException { x = in.readInt(); }",
+            aClass));
+
+        ClassLoader extClsLdr = new ClassLoader() {{
+            byte[] bytecode = aClass.toBytecode();
+
+            defineClass("ExternalTestClass2", bytecode, 0, bytecode.length);
+        }};
+
+        Class<?> extClass = extClsLdr.loadClass("ExternalTestClass2");
+
+        Constructor<?> ctor = extClass.getConstructor(int.class);
+
+        return ctor.newInstance(42);
+    }
+
+    /** */
+    private static class TestContainer {
+        /** */
+        private final Object x;
+
+        /** */
+        private TestContainer(Object x) {
+            this.x = x;
+        }
+    }
+
+    /** */
+    private static class TestIntContainer {
+        /** */
+        private final int i;
+
+        /** */
+        private TestIntContainer(int i) {
+            this.i = i;
+        }
+    }
+}
diff --git a/modules/core/src/test/java/org/apache/ignite/testsuites/IgniteBinaryObjectsTestSuite.java b/modules/core/src/test/java/org/apache/ignite/testsuites/IgniteBinaryObjectsTestSuite.java
index 6f818d9..9df13cc 100644
--- a/modules/core/src/test/java/org/apache/ignite/testsuites/IgniteBinaryObjectsTestSuite.java
+++ b/modules/core/src/test/java/org/apache/ignite/testsuites/IgniteBinaryObjectsTestSuite.java
@@ -34,6 +34,7 @@
 import org.apache.ignite.internal.binary.BinaryObjectBuilderSimpleNameLowerCaseMappersSelfTest;
 import org.apache.ignite.internal.binary.BinaryObjectExceptionSelfTest;
 import org.apache.ignite.internal.binary.BinaryObjectToStringSelfTest;
+import org.apache.ignite.internal.binary.BinaryObjectToStringTest;
 import org.apache.ignite.internal.binary.BinaryObjectTypeCompatibilityTest;
 import org.apache.ignite.internal.binary.BinarySerialiedFieldComparatorSelfTest;
 import org.apache.ignite.internal.binary.BinarySimpleNameTestPropertySelfTest;
@@ -110,6 +111,7 @@
     GridBinaryAffinityKeySelfTest.class,
     GridBinaryWildcardsSelfTest.class,
     BinaryObjectToStringSelfTest.class,
+    BinaryObjectToStringTest.class,
     BinaryObjectTypeCompatibilityTest.class,
 
     // Tests for objects with non-compact footers.