ReflectionAccessFilterTest.java

/*
 * Copyright (C) 2022 Google Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package com.google.gson.functional;

import static com.google.common.truth.Truth.assertThat;
import static org.junit.Assert.assertThrows;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.InstanceCreator;
import com.google.gson.JsonElement;
import com.google.gson.JsonIOException;
import com.google.gson.JsonPrimitive;
import com.google.gson.JsonSerializationContext;
import com.google.gson.JsonSerializer;
import com.google.gson.ReflectionAccessFilter;
import com.google.gson.ReflectionAccessFilter.FilterResult;
import com.google.gson.TypeAdapter;
import com.google.gson.internal.ConstructorConstructor;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonWriter;
import java.io.File;
import java.io.IOException;
import java.io.Reader;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;
import org.junit.AssumptionViolatedException;
import org.junit.Test;

public class ReflectionAccessFilterTest {
  // Reader has protected `lock` field which cannot be accessed
  private static class ClassExtendingJdkClass extends Reader {
    @Override
    public int read(char[] cbuf, int off, int len) throws IOException {
      return 0;
    }

    @Override
    public void close() throws IOException {}
  }

  @Test
  public void testBlockInaccessibleJava() {
    Gson gson =
        new GsonBuilder()
            .addReflectionAccessFilter(ReflectionAccessFilter.BLOCK_INACCESSIBLE_JAVA)
            .create();

    // Serialization should fail for classes with non-public fields
    // Note: This test is rather brittle and depends on the JDK implementation
    var e =
        assertThrows(
            "Expected exception; test needs to be run with Java >= 9",
            JsonIOException.class,
            () -> gson.toJson(new File("a")));
    assertThat(e)
        .hasMessageThat()
        .isEqualTo(
            "Field 'java.io.File#path' is not accessible and ReflectionAccessFilter does not"
                + " permit making it accessible. Register a TypeAdapter for the declaring type,"
                + " adjust the access filter or increase the visibility of the element and its"
                + " declaring type.");
  }

  @Test
  public void testDontBlockAccessibleJava() throws ReflectiveOperationException {
    Gson gson =
        new GsonBuilder()
            .addReflectionAccessFilter(ReflectionAccessFilter.BLOCK_INACCESSIBLE_JAVA)
            .create();

    // Serialization should succeed for classes with only public fields.
    // Not many JDK classes have mutable public fields, thank goodness, but java.awt.Point does.
    Class<?> pointClass;
    try {
      pointClass = Class.forName("java.awt.Point");
    } catch (ClassNotFoundException e) {
      // If not found then we don't have AWT and the rest of the test can be skipped.
      throw new AssumptionViolatedException("java.awt.Point not present", e);
    }
    Constructor<?> pointConstructor = pointClass.getConstructor(int.class, int.class);
    Object point = pointConstructor.newInstance(1, 2);
    String json = gson.toJson(point);
    assertThat(json).isEqualTo("{\"x\":1,\"y\":2}");
  }

  @Test
  public void testBlockInaccessibleJavaExtendingJdkClass() {
    Gson gson =
        new GsonBuilder()
            .addReflectionAccessFilter(ReflectionAccessFilter.BLOCK_INACCESSIBLE_JAVA)
            .create();

    var e =
        assertThrows(
            "Expected exception; test needs to be run with Java >= 9",
            JsonIOException.class,
            () -> gson.toJson(new ClassExtendingJdkClass()));
    assertThat(e)
        .hasMessageThat()
        .isEqualTo(
            "Field 'java.io.Reader#lock' is not accessible and ReflectionAccessFilter does not"
                + " permit making it accessible. Register a TypeAdapter for the declaring type,"
                + " adjust the access filter or increase the visibility of the element and its"
                + " declaring type.");
  }

  @Test
  public void testBlockAllJava() {
    Gson gson =
        new GsonBuilder().addReflectionAccessFilter(ReflectionAccessFilter.BLOCK_ALL_JAVA).create();

    // Serialization should fail for any Java class without custom adapter
    var e = assertThrows(JsonIOException.class, () -> gson.toJson(Thread.currentThread()));
    assertThat(e)
        .hasMessageThat()
        .isEqualTo(
            "ReflectionAccessFilter does not permit using reflection for class java.lang.Thread."
                + " Register a TypeAdapter for this type or adjust the access filter.");
  }

  @Test
  public void testBlockAllJavaExtendingJdkClass() {
    Gson gson =
        new GsonBuilder().addReflectionAccessFilter(ReflectionAccessFilter.BLOCK_ALL_JAVA).create();

    var e = assertThrows(JsonIOException.class, () -> gson.toJson(new ClassExtendingJdkClass()));
    assertThat(e)
        .hasMessageThat()
        .isEqualTo(
            "ReflectionAccessFilter does not permit using reflection for class java.io.Reader"
                + " (supertype of class"
                + " com.google.gson.functional.ReflectionAccessFilterTest$ClassExtendingJdkClass)."
                + " Register a TypeAdapter for this type or adjust the access filter.");
  }

  private static class ClassWithStaticField {
    @SuppressWarnings({"unused", "NonFinalStaticField"})
    private static int i = 1;
  }

  @Test
  public void testBlockInaccessibleStaticField() {
    Gson gson =
        new GsonBuilder()
            .addReflectionAccessFilter(
                new ReflectionAccessFilter() {
                  @Override
                  public FilterResult check(Class<?> rawClass) {
                    return FilterResult.BLOCK_INACCESSIBLE;
                  }
                })
            // Include static fields
            .excludeFieldsWithModifiers(0)
            .create();

    var e =
        assertThrows(
            "Expected exception; test needs to be run with Java >= 9",
            JsonIOException.class,
            () -> gson.toJson(new ClassWithStaticField()));
    assertThat(e)
        .hasMessageThat()
        .isEqualTo(
            "Field 'com.google.gson.functional.ReflectionAccessFilterTest$ClassWithStaticField#i'"
                + " is not accessible and ReflectionAccessFilter does not permit making it"
                + " accessible. Register a TypeAdapter for the declaring type, adjust the access"
                + " filter or increase the visibility of the element and its declaring type.");
  }

  private static class SuperTestClass {}

  private static class SubTestClass extends SuperTestClass {
    @SuppressWarnings("unused")
    public int i = 1;
  }

  private static class OtherClass {
    @SuppressWarnings("unused")
    public int i = 2;
  }

  @Test
  public void testDelegation() {
    Gson gson =
        new GsonBuilder()
            .addReflectionAccessFilter(
                new ReflectionAccessFilter() {
                  @Override
                  public FilterResult check(Class<?> rawClass) {
                    // INDECISIVE in last filter should act like ALLOW
                    return SuperTestClass.class.isAssignableFrom(rawClass)
                        ? FilterResult.BLOCK_ALL
                        : FilterResult.INDECISIVE;
                  }
                })
            .addReflectionAccessFilter(
                new ReflectionAccessFilter() {
                  @Override
                  public FilterResult check(Class<?> rawClass) {
                    // INDECISIVE should delegate to previous filter
                    return rawClass == SubTestClass.class
                        ? FilterResult.ALLOW
                        : FilterResult.INDECISIVE;
                  }
                })
            .create();

    // Filter disallows SuperTestClass
    var e = assertThrows(JsonIOException.class, () -> gson.toJson(new SuperTestClass()));
    assertThat(e)
        .hasMessageThat()
        .isEqualTo(
            "ReflectionAccessFilter does not permit using reflection for class"
                + " com.google.gson.functional.ReflectionAccessFilterTest$SuperTestClass."
                + " Register a TypeAdapter for this type or adjust the access filter.");

    // But registration order is reversed, so filter for SubTestClass allows reflection
    String json = gson.toJson(new SubTestClass());
    assertThat(json).isEqualTo("{\"i\":1}");

    // And unrelated class should not be affected
    json = gson.toJson(new OtherClass());
    assertThat(json).isEqualTo("{\"i\":2}");
  }

  private static class ClassWithPrivateField {
    @SuppressWarnings("unused")
    private int i = 1;
  }

  private static class ExtendingClassWithPrivateField extends ClassWithPrivateField {}

  @Test
  public void testAllowForSupertype() {
    Gson gson =
        new GsonBuilder()
            .addReflectionAccessFilter(
                new ReflectionAccessFilter() {
                  @Override
                  public FilterResult check(Class<?> rawClass) {
                    return FilterResult.BLOCK_INACCESSIBLE;
                  }
                })
            .create();

    // First make sure test is implemented correctly and access is blocked
    var e =
        assertThrows(
            "Expected exception; test needs to be run with Java >= 9",
            JsonIOException.class,
            () -> gson.toJson(new ExtendingClassWithPrivateField()));
    assertThat(e)
        .hasMessageThat()
        .isEqualTo(
            "Field"
                + " 'com.google.gson.functional.ReflectionAccessFilterTest$ClassWithPrivateField#i'"
                + " is not accessible and ReflectionAccessFilter does not permit making it"
                + " accessible. Register a TypeAdapter for the declaring type, adjust the access"
                + " filter or increase the visibility of the element and its declaring type.");

    Gson gson2 =
        gson.newBuilder()
            // Allow reflective access for supertype
            .addReflectionAccessFilter(
                new ReflectionAccessFilter() {
                  @Override
                  public FilterResult check(Class<?> rawClass) {
                    return rawClass == ClassWithPrivateField.class
                        ? FilterResult.ALLOW
                        : FilterResult.INDECISIVE;
                  }
                })
            .create();

    // Inherited (inaccessible) private field should have been made accessible
    String json = gson2.toJson(new ExtendingClassWithPrivateField());
    assertThat(json).isEqualTo("{\"i\":1}");
  }

  private static class ClassWithPrivateNoArgsConstructor {
    private ClassWithPrivateNoArgsConstructor() {}
  }

  @Test
  public void testInaccessibleNoArgsConstructor() {
    Gson gson =
        new GsonBuilder()
            .addReflectionAccessFilter(
                new ReflectionAccessFilter() {
                  @Override
                  public FilterResult check(Class<?> rawClass) {
                    return FilterResult.BLOCK_INACCESSIBLE;
                  }
                })
            .create();

    var e =
        assertThrows(
            "Expected exception; test needs to be run with Java >= 9",
            JsonIOException.class,
            () -> gson.fromJson("{}", ClassWithPrivateNoArgsConstructor.class));
    assertThat(e)
        .hasMessageThat()
        .isEqualTo(
            "Unable to invoke no-args constructor of class"
                + " com.google.gson.functional.ReflectionAccessFilterTest$ClassWithPrivateNoArgsConstructor;"
                + " constructor is not accessible and ReflectionAccessFilter does not permit making"
                + " it accessible. Register an InstanceCreator or a TypeAdapter for this type,"
                + " change the visibility of the constructor or adjust the access filter.");
  }

  private static class ClassWithoutNoArgsConstructor {
    public String s;

    public ClassWithoutNoArgsConstructor(String s) {
      this.s = s;
    }
  }

  @Test
  public void testClassWithoutNoArgsConstructor() {
    GsonBuilder gsonBuilder =
        new GsonBuilder()
            .addReflectionAccessFilter(
                new ReflectionAccessFilter() {
                  @Override
                  public FilterResult check(Class<?> rawClass) {
                    // Even BLOCK_INACCESSIBLE should prevent usage of Unsafe for object creation
                    return FilterResult.BLOCK_INACCESSIBLE;
                  }
                });
    Gson gson = gsonBuilder.create();

    var e =
        assertThrows(
            JsonIOException.class, () -> gson.fromJson("{}", ClassWithoutNoArgsConstructor.class));
    assertThat(e)
        .hasMessageThat()
        .isEqualTo(
            "Unable to create instance of class"
                + " com.google.gson.functional.ReflectionAccessFilterTest$ClassWithoutNoArgsConstructor;"
                + " ReflectionAccessFilter does not permit using reflection or Unsafe. Register an"
                + " InstanceCreator or a TypeAdapter for this type or adjust the access filter to"
                + " allow using reflection.");

    // But should not fail when custom TypeAdapter is specified
    Gson gson2 =
        gson.newBuilder()
            .registerTypeAdapter(
                ClassWithoutNoArgsConstructor.class,
                new TypeAdapter<ClassWithoutNoArgsConstructor>() {
                  @Override
                  public ClassWithoutNoArgsConstructor read(JsonReader in) throws IOException {
                    in.skipValue();
                    return new ClassWithoutNoArgsConstructor("TypeAdapter");
                  }

                  @Override
                  public void write(JsonWriter out, ClassWithoutNoArgsConstructor value) {
                    throw new AssertionError("Not needed for test");
                  }
                })
            .create();
    ClassWithoutNoArgsConstructor deserialized =
        gson2.fromJson("{}", ClassWithoutNoArgsConstructor.class);
    assertThat(deserialized.s).isEqualTo("TypeAdapter");

    // But should not fail when custom InstanceCreator is specified
    gson2 =
        gsonBuilder
            .registerTypeAdapter(
                ClassWithoutNoArgsConstructor.class,
                new InstanceCreator<ClassWithoutNoArgsConstructor>() {
                  @Override
                  public ClassWithoutNoArgsConstructor createInstance(Type type) {
                    return new ClassWithoutNoArgsConstructor("InstanceCreator");
                  }
                })
            .create();
    deserialized = gson2.fromJson("{}", ClassWithoutNoArgsConstructor.class);
    assertThat(deserialized.s).isEqualTo("InstanceCreator");
  }

  /**
   * When using {@link FilterResult#BLOCK_ALL}, registering only a {@link JsonSerializer} but not
   * performing any deserialization should not throw any exception.
   */
  @Test
  public void testBlockAllPartial() {
    Gson gson =
        new GsonBuilder()
            .addReflectionAccessFilter(
                new ReflectionAccessFilter() {
                  @Override
                  public FilterResult check(Class<?> rawClass) {
                    return FilterResult.BLOCK_ALL;
                  }
                })
            .registerTypeAdapter(
                OtherClass.class,
                new JsonSerializer<OtherClass>() {
                  @Override
                  public JsonElement serialize(
                      OtherClass src, Type typeOfSrc, JsonSerializationContext context) {
                    return new JsonPrimitive(123);
                  }
                })
            .create();

    String json = gson.toJson(new OtherClass());
    assertThat(json).isEqualTo("123");

    // But deserialization should fail
    var e = assertThrows(JsonIOException.class, () -> gson.fromJson("{}", OtherClass.class));
    assertThat(e)
        .hasMessageThat()
        .isEqualTo(
            "ReflectionAccessFilter does not permit using reflection for class"
                + " com.google.gson.functional.ReflectionAccessFilterTest$OtherClass. Register a"
                + " TypeAdapter for this type or adjust the access filter.");
  }

  /**
   * Should not fail when deserializing collection interface (even though this goes through {@link
   * ConstructorConstructor} as well)
   */
  @Test
  public void testBlockAllCollectionInterface() {
    Gson gson =
        new GsonBuilder()
            .addReflectionAccessFilter(
                new ReflectionAccessFilter() {
                  @Override
                  public FilterResult check(Class<?> rawClass) {
                    return FilterResult.BLOCK_ALL;
                  }
                })
            .create();
    List<?> deserialized = gson.fromJson("[1.0]", List.class);
    assertThat(deserialized.get(0)).isEqualTo(1.0);
  }

  /**
   * Should not fail when deserializing specific collection implementation (even though this goes
   * through {@link ConstructorConstructor} as well)
   */
  @Test
  public void testBlockAllCollectionImplementation() {
    Gson gson =
        new GsonBuilder()
            .addReflectionAccessFilter(
                new ReflectionAccessFilter() {
                  @Override
                  public FilterResult check(Class<?> rawClass) {
                    return FilterResult.BLOCK_ALL;
                  }
                })
            .create();
    List<?> deserialized = gson.fromJson("[1.0]", LinkedList.class);
    assertThat(deserialized.get(0)).isEqualTo(1.0);
  }

  /**
   * When trying to deserialize interface, an exception for the missing adapter should be thrown,
   * even if {@link FilterResult#BLOCK_INACCESSIBLE} is used.
   */
  @Test
  public void testBlockInaccessibleInterface() {
    Gson gson =
        new GsonBuilder()
            .addReflectionAccessFilter(
                new ReflectionAccessFilter() {
                  @Override
                  public FilterResult check(Class<?> rawClass) {
                    return FilterResult.BLOCK_INACCESSIBLE;
                  }
                })
            .create();

    var e = assertThrows(JsonIOException.class, () -> gson.fromJson("{}", Runnable.class));
    assertThat(e)
        .hasMessageThat()
        .isEqualTo(
            "Interfaces can't be instantiated! Register an InstanceCreator or a TypeAdapter for"
                + " this type. Interface name: java.lang.Runnable");
  }

  /**
   * Verifies that the predefined filter constants have meaningful {@code toString()} output to make
   * debugging easier.
   */
  @Test
  public void testConstantsToString() throws Exception {
    List<Field> constantFields = new ArrayList<>();

    for (Field f : ReflectionAccessFilter.class.getFields()) {
      // Only include ReflectionAccessFilter constants (in case any other fields are added in the
      // future)
      if (f.getType() == ReflectionAccessFilter.class) {
        constantFields.add(f);
      }
    }

    assertThat(constantFields).isNotEmpty();
    for (Field f : constantFields) {
      Object constant = f.get(null);
      assertThat(constant.toString()).isEqualTo("ReflectionAccessFilter#" + f.getName());
    }
  }
}