diff --git a/checker/src/test/java/io/github/eisop/checker/nullness/NullnessDirectoryTest.java b/checker/src/test/java/io/github/eisop/checker/nullness/NullnessDirectoryTest.java index 83148c6..c83b10f 100644 --- a/checker/src/test/java/io/github/eisop/checker/nullness/NullnessDirectoryTest.java +++ b/checker/src/test/java/io/github/eisop/checker/nullness/NullnessDirectoryTest.java @@ -21,6 +21,14 @@ public void testInvokeScenarios() throws Exception { false); } + @Test + public void testInterfaceScenarios() throws Exception { + runDirectoryTest( + "nullness-interface", + "io.github.eisop.runtimeframework.checker.nullness.NullnessRuntimeChecker", + false); + } + @Test public void testFieldReadScenarios() throws Exception { runDirectoryTest( diff --git a/checker/src/test/resources/test-cases/nullness-array-gradual/ArrayBoundaryReturnElements.java b/checker/src/test/resources/test-cases/nullness-array-gradual/ArrayBoundaryReturnElements.java index 4d361b1..fda1e5d 100644 --- a/checker/src/test/resources/test-cases/nullness-array-gradual/ArrayBoundaryReturnElements.java +++ b/checker/src/test/resources/test-cases/nullness-array-gradual/ArrayBoundaryReturnElements.java @@ -17,6 +17,5 @@ public static void main(String[] args) { @Nullable String[] values = UncheckedLib.getNullableElementArray(); values[0] = null; accept(values[0]); - // :: error: (Parameter 0 must be NonNull) } } diff --git a/checker/src/test/resources/test-cases/nullness-array-gradual/ArrayFieldReadElements.java b/checker/src/test/resources/test-cases/nullness-array-gradual/ArrayFieldReadElements.java index 8ad532a..2e4119a 100644 --- a/checker/src/test/resources/test-cases/nullness-array-gradual/ArrayFieldReadElements.java +++ b/checker/src/test/resources/test-cases/nullness-array-gradual/ArrayFieldReadElements.java @@ -15,6 +15,5 @@ public static void main(String[] args) { UncheckedLib lib = new UncheckedLib(); lib.values[0] = null; accept(lib.values[0]); - // :: error: (Parameter 0 must be NonNull) } } diff --git a/checker/src/test/resources/test-cases/nullness-array/NullableCellsTwoDimensional.java b/checker/src/test/resources/test-cases/nullness-array/NullableCellsTwoDimensional.java index ca06b8a..749995a 100644 --- a/checker/src/test/resources/test-cases/nullness-array/NullableCellsTwoDimensional.java +++ b/checker/src/test/resources/test-cases/nullness-array/NullableCellsTwoDimensional.java @@ -11,6 +11,5 @@ public static void main(String[] args) { @Nullable String[][] grid = new @Nullable String[1][1]; grid[0][0] = null; accept(grid[0][0]); - // :: error: (Parameter 0 must be NonNull) } } diff --git a/checker/src/test/resources/test-cases/nullness-array/NullableElementsFromField.java b/checker/src/test/resources/test-cases/nullness-array/NullableElementsFromField.java index 87dcdaf..649b2fd 100644 --- a/checker/src/test/resources/test-cases/nullness-array/NullableElementsFromField.java +++ b/checker/src/test/resources/test-cases/nullness-array/NullableElementsFromField.java @@ -13,6 +13,5 @@ public static void main(String[] args) { NullableElementsFromField holder = new NullableElementsFromField(); holder.values[0] = null; accept(holder.values[0]); - // :: error: (Parameter 0 must be NonNull) } } diff --git a/checker/src/test/resources/test-cases/nullness-array/NullableElementsFromMethodReturn.java b/checker/src/test/resources/test-cases/nullness-array/NullableElementsFromMethodReturn.java index e30ad7e..42e2565 100644 --- a/checker/src/test/resources/test-cases/nullness-array/NullableElementsFromMethodReturn.java +++ b/checker/src/test/resources/test-cases/nullness-array/NullableElementsFromMethodReturn.java @@ -17,6 +17,5 @@ public static void main(String[] args) { @Nullable String[] values = UncheckedLib.makeValues(); values[0] = null; accept(values[0]); - // :: error: (Parameter 0 must be NonNull) } } diff --git a/checker/src/test/resources/test-cases/nullness-array/NullableElementsPassNullToNonNullParameter.java b/checker/src/test/resources/test-cases/nullness-array/NullableElementsPassNullToNonNullParameter.java index 980610d..4ac1184 100644 --- a/checker/src/test/resources/test-cases/nullness-array/NullableElementsPassNullToNonNullParameter.java +++ b/checker/src/test/resources/test-cases/nullness-array/NullableElementsPassNullToNonNullParameter.java @@ -11,6 +11,5 @@ public static void main(String[] args) { @Nullable String[] values = new @Nullable String[1]; values[0] = null; accept(values[0]); - // :: error: (Parameter 0 must be NonNull) } } diff --git a/checker/src/test/resources/test-cases/nullness-array/NullableRowsAndCellsPassNullToNonNullParameter.java b/checker/src/test/resources/test-cases/nullness-array/NullableRowsAndCellsPassNullToNonNullParameter.java index 44678d0..aa21264 100644 --- a/checker/src/test/resources/test-cases/nullness-array/NullableRowsAndCellsPassNullToNonNullParameter.java +++ b/checker/src/test/resources/test-cases/nullness-array/NullableRowsAndCellsPassNullToNonNullParameter.java @@ -12,6 +12,5 @@ public static void main(String[] args) { grid[0] = new @Nullable String[1]; grid[0][0] = null; accept(grid[0][0]); - // :: error: (Parameter 0 must be NonNull) } } diff --git a/checker/src/test/resources/test-cases/nullness-bridge/InheritanceBridgeTest.java b/checker/src/test/resources/test-cases/nullness-bridge/InheritanceBridgeTest.java index 5658b17..d35a1c3 100644 --- a/checker/src/test/resources/test-cases/nullness-bridge/InheritanceBridgeTest.java +++ b/checker/src/test/resources/test-cases/nullness-bridge/InheritanceBridgeTest.java @@ -14,7 +14,6 @@ public static void main(String[] args) { test.overrideMe("safe", "safe"); - // :: error: (Parameter 0 must be NonNull) test.overrideMe(null, "unsafe"); test.overrideMe("safe", "null"); diff --git a/checker/src/test/resources/test-cases/nullness-interface/CheckedBridgeInterfaceDispatch.java b/checker/src/test/resources/test-cases/nullness-interface/CheckedBridgeInterfaceDispatch.java new file mode 100644 index 0000000..b519462 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-interface/CheckedBridgeInterfaceDispatch.java @@ -0,0 +1,29 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class CheckedBridgeInterfaceDispatch { + public static void main(String[] args) { + BridgeInterfaceSink checked = new CheckedStringBridgeInterfaceSink(); + checked.accept(null); + + UncheckedBridgeInterfaceCaller.call(new CheckedStringBridgeInterfaceSink()); + } +} + +@AnnotatedFor("nullness") +interface BridgeInterfaceSink { + void accept(T value); +} + +@AnnotatedFor("nullness") +class CheckedStringBridgeInterfaceSink implements BridgeInterfaceSink { + public void accept(String value) { + } +} + +class UncheckedBridgeInterfaceCaller { + static void call(BridgeInterfaceSink sink) { + // :: error: (Parameter 0 must be NonNull) + sink.accept(null); + } +} diff --git a/checker/src/test/resources/test-cases/nullness-interface/CheckedDefaultBridgeInterfaceDispatch.java b/checker/src/test/resources/test-cases/nullness-interface/CheckedDefaultBridgeInterfaceDispatch.java new file mode 100644 index 0000000..8754793 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-interface/CheckedDefaultBridgeInterfaceDispatch.java @@ -0,0 +1,31 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class CheckedDefaultBridgeInterfaceDispatch implements CheckedDefaultBridgeChild { + public static void main(String[] args) { + CheckedDefaultBridgeParent checked = + new CheckedDefaultBridgeInterfaceDispatch(); + checked.accept(null); + + UncheckedDefaultBridgeCaller.call(new CheckedDefaultBridgeInterfaceDispatch()); + } +} + +@AnnotatedFor("nullness") +interface CheckedDefaultBridgeParent { + default void accept(T value) { + } +} + +@AnnotatedFor("nullness") +interface CheckedDefaultBridgeChild extends CheckedDefaultBridgeParent { + default void accept(String value) { + } +} + +class UncheckedDefaultBridgeCaller { + static void call(CheckedDefaultBridgeParent target) { + // :: error: (Parameter 0 must be NonNull) + target.accept(null); + } +} diff --git a/checker/src/test/resources/test-cases/nullness-interface/CheckedInheritedBridgeInterfaceDispatch.java b/checker/src/test/resources/test-cases/nullness-interface/CheckedInheritedBridgeInterfaceDispatch.java new file mode 100644 index 0000000..3884d86 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-interface/CheckedInheritedBridgeInterfaceDispatch.java @@ -0,0 +1,35 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class CheckedInheritedBridgeInterfaceDispatch { + public static void main(String[] args) { + InheritedBridgeSink checked = new CheckedInheritedBridgeChild(); + checked.accept(null); + + UncheckedInheritedBridgeCaller.call(new CheckedInheritedBridgeChild()); + } +} + +@AnnotatedFor("nullness") +interface InheritedBridgeSink { + void accept(T value); +} + +@AnnotatedFor("nullness") +class CheckedInheritedBridgeBase { + public void accept(String value) { + } +} + +@AnnotatedFor("nullness") +class CheckedInheritedBridgeChild + extends CheckedInheritedBridgeBase + implements InheritedBridgeSink { +} + +class UncheckedInheritedBridgeCaller { + static void call(InheritedBridgeSink sink) { + // :: error: (Parameter 0 must be NonNull) + sink.accept(null); + } +} diff --git a/checker/src/test/resources/test-cases/nullness-interface/CheckedInheritedInterfaceDispatch.java b/checker/src/test/resources/test-cases/nullness-interface/CheckedInheritedInterfaceDispatch.java new file mode 100644 index 0000000..b8f96d4 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-interface/CheckedInheritedInterfaceDispatch.java @@ -0,0 +1,33 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class CheckedInheritedInterfaceDispatch { + public static void main(String[] args) { + CheckedInheritedChildInterface checked = new CheckedInheritedInterfaceImpl(); + checked.accept(null); + + UncheckedInheritedInterfaceCaller.call(new CheckedInheritedInterfaceImpl()); + } +} + +@AnnotatedFor("nullness") +interface CheckedInheritedParentInterface { + void accept(Object value); +} + +@AnnotatedFor("nullness") +interface CheckedInheritedChildInterface extends CheckedInheritedParentInterface { +} + +@AnnotatedFor("nullness") +class CheckedInheritedInterfaceImpl implements CheckedInheritedChildInterface { + public void accept(Object value) { + } +} + +class UncheckedInheritedInterfaceCaller { + static void call(CheckedInheritedChildInterface target) { + // :: error: (Parameter 0 must be NonNull) + target.accept(null); + } +} diff --git a/checker/src/test/resources/test-cases/nullness-interface/CheckedInheritedUncheckedInterfaceReturn.java b/checker/src/test/resources/test-cases/nullness-interface/CheckedInheritedUncheckedInterfaceReturn.java new file mode 100644 index 0000000..fa68faf --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-interface/CheckedInheritedUncheckedInterfaceReturn.java @@ -0,0 +1,24 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class CheckedInheritedUncheckedInterfaceReturn { + public static void main(String[] args) { + CheckedReturnChildInterface receiver = new UncheckedReturnInterfaceImpl(); + receiver.produce(); + // :: error: (Return value of produce (Boundary) must be NonNull) + } +} + +interface UncheckedReturnParentInterface { + Object produce(); +} + +@AnnotatedFor("nullness") +interface CheckedReturnChildInterface extends UncheckedReturnParentInterface { +} + +class UncheckedReturnInterfaceImpl implements CheckedReturnChildInterface { + public Object produce() { + return null; + } +} diff --git a/checker/src/test/resources/test-cases/nullness-interface/CheckedInterfaceDefaultMethod.java b/checker/src/test/resources/test-cases/nullness-interface/CheckedInterfaceDefaultMethod.java new file mode 100644 index 0000000..aca3949 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-interface/CheckedInterfaceDefaultMethod.java @@ -0,0 +1,32 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class CheckedInterfaceDefaultMethod implements CheckedDefaultContract { + + static class Poison { + static String VALUE = null; + } + + public static void main(String[] args) { + CheckedDefaultContract checked = new CheckedInterfaceDefaultMethod(); + checked.defaultConsume(null); + + UncheckedDefaultCaller.call(new CheckedInterfaceDefaultMethod()); + + // :: error: (Read Field 'VALUE' must be NonNull) + System.out.println(Poison.VALUE); + } +} + +class UncheckedDefaultCaller { + static void call(CheckedDefaultContract receiver) { + // :: error: (Parameter 0 must be NonNull) + receiver.defaultConsume(null); + } +} + +@AnnotatedFor("nullness") +interface CheckedDefaultContract { + default void defaultConsume(Object value) { + } +} diff --git a/checker/src/test/resources/test-cases/nullness-interface/CheckedInterfaceDispatch.java b/checker/src/test/resources/test-cases/nullness-interface/CheckedInterfaceDispatch.java new file mode 100644 index 0000000..ef0a55d --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-interface/CheckedInterfaceDispatch.java @@ -0,0 +1,36 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class CheckedInterfaceDispatch { + + static class Poison { + static String VALUE = null; + } + + public static void main(String[] args) { + CheckedApi checked = new CheckedImpl(); + checked.accept(null); + + CheckedApi unchecked = new UncheckedImpl(); + unchecked.accept(null); + + // :: error: (Read Field 'VALUE' must be NonNull) + System.out.println(Poison.VALUE); + } +} + +@AnnotatedFor("nullness") +interface CheckedApi { + void accept(Object value); +} + +@AnnotatedFor("nullness") +class CheckedImpl implements CheckedApi { + public void accept(Object value) { + } +} + +class UncheckedImpl implements CheckedApi { + public void accept(Object value) { + } +} diff --git a/checker/src/test/resources/test-cases/nullness-interface/CheckedInterfaceReturnBoundary.java b/checker/src/test/resources/test-cases/nullness-interface/CheckedInterfaceReturnBoundary.java new file mode 100644 index 0000000..3faaced --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-interface/CheckedInterfaceReturnBoundary.java @@ -0,0 +1,46 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; +import org.checkerframework.checker.nullness.qual.Nullable; + +@AnnotatedFor("nullness") +public class CheckedInterfaceReturnBoundary { + public static void main(String[] args) { + ReturningContract checked = new CheckedReturningImpl(); + checked.produce(); + + ReturningContract unchecked = new UncheckedReturningImpl(); + unchecked.produce(); + // :: error: (Return value of produce (Boundary) must be NonNull) + + NullableReturningContract nullable = new UncheckedNullableReturningImpl(); + nullable.produceNullable(); + } +} + +@AnnotatedFor("nullness") +interface ReturningContract { + Object produce(); +} + +@AnnotatedFor("nullness") +class CheckedReturningImpl implements ReturningContract { + public Object produce() { + return new Object(); + } +} + +class UncheckedReturningImpl implements ReturningContract { + public Object produce() { + return null; + } +} + +@AnnotatedFor("nullness") +interface NullableReturningContract { + @Nullable Object produceNullable(); +} + +class UncheckedNullableReturningImpl implements NullableReturningContract { + public Object produceNullable() { + return null; + } +} diff --git a/checker/src/test/resources/test-cases/nullness-interface/CheckedNativeInterfaceDispatch.java b/checker/src/test/resources/test-cases/nullness-interface/CheckedNativeInterfaceDispatch.java new file mode 100644 index 0000000..da2779e --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-interface/CheckedNativeInterfaceDispatch.java @@ -0,0 +1,34 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class CheckedNativeInterfaceDispatch { + static class Poison { + static String VALUE = null; + } + + public static void main(String[] args) { + CheckedNativeContract receiver = new CheckedNativeInterfaceImpl(); + boolean reachedNative = false; + try { + receiver.produce(new Object()); + } catch (UnsatisfiedLinkError expected) { + reachedNative = true; + } catch (AssertionError wrongSafeStub) { + reachedNative = false; + } + + if (!reachedNative) { + System.out.println(Poison.VALUE); + } + } +} + +@AnnotatedFor("nullness") +interface CheckedNativeContract { + Object produce(Object value); +} + +@AnnotatedFor("nullness") +class CheckedNativeInterfaceImpl implements CheckedNativeContract { + public native Object produce(Object value); +} diff --git a/checker/src/test/resources/test-cases/nullness-interface/UncheckedInterfaceCaller.java b/checker/src/test/resources/test-cases/nullness-interface/UncheckedInterfaceCaller.java new file mode 100644 index 0000000..764ebb9 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-interface/UncheckedInterfaceCaller.java @@ -0,0 +1,33 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class UncheckedInterfaceCaller { + static class Poison { + static String VALUE = null; + } + + public static void main(String[] args) { + UncheckedCaller.call(new CheckedReceiver()); + + // :: error: (Read Field 'VALUE' must be NonNull) + System.out.println(Poison.VALUE); + } +} + +class UncheckedCaller { + static void call(CheckedContract receiver) { + // :: error: (Parameter 0 must be NonNull) + receiver.consume(null); + } +} + +@AnnotatedFor("nullness") +interface CheckedContract { + void consume(Object value); +} + +@AnnotatedFor("nullness") +class CheckedReceiver implements CheckedContract { + public void consume(Object value) { + } +} diff --git a/checker/src/test/resources/test-cases/nullness-interface/UncheckedInterfaceOwner.java b/checker/src/test/resources/test-cases/nullness-interface/UncheckedInterfaceOwner.java new file mode 100644 index 0000000..62adc08 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-interface/UncheckedInterfaceOwner.java @@ -0,0 +1,28 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class UncheckedInterfaceOwner { + static class Poison { + static String VALUE = null; + } + + public static void main(String[] args) { + UncheckedContract receiver = new CheckedOwnerReceiver(); + + // :: error: (Parameter 0 must be NonNull) + receiver.consume(null); + + // :: error: (Read Field 'VALUE' must be NonNull) + System.out.println(Poison.VALUE); + } +} + +interface UncheckedContract { + void consume(Object value); +} + +@AnnotatedFor("nullness") +class CheckedOwnerReceiver implements UncheckedContract { + public void consume(Object value) { + } +} diff --git a/checker/src/test/resources/test-cases/nullness-invoke/CheckedAbstractVirtualDispatch.java b/checker/src/test/resources/test-cases/nullness-invoke/CheckedAbstractVirtualDispatch.java new file mode 100644 index 0000000..77a12f3 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-invoke/CheckedAbstractVirtualDispatch.java @@ -0,0 +1,135 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class CheckedAbstractVirtualDispatch { + public static void main(String[] args) { + CheckedAbstractSink checked = new CheckedAbstractSinkImpl(); + checked.accept(null); + + UncheckedAbstractCaller.call(new CheckedAbstractSinkImpl()); + + CheckedPackageAbstractSink packageChecked = new CheckedPackageAbstractSinkImpl(); + packageChecked.packageAccept(null); + + UncheckedPackageAbstractCaller.call(new CheckedPackageAbstractSinkImpl()); + + CheckedProtectedAbstractSink protectedChecked = new CheckedProtectedAbstractSinkImpl(); + protectedChecked.protectedAccept(null); + + UncheckedProtectedAbstractCaller.call(new CheckedProtectedAbstractSinkImpl()); + + CheckedConcreteSink inheritedUnchecked = new UncheckedConcreteSinkChild(); + inheritedUnchecked.accept(null); + // :: error: (Parameter 0 must be NonNull) + + CheckedAbstractProducer unchecked = new UncheckedAbstractProducerImpl(); + unchecked.produce(); + // :: error: (Return value of produce (Boundary) must be NonNull) + + CheckedPackageAbstractProducer uncheckedPackage = + new UncheckedPackageAbstractProducerImpl(); + uncheckedPackage.packageProduce(); + // :: error: (Return value of packageProduce (Boundary) must be NonNull) + + CheckedProtectedAbstractProducer uncheckedProtected = + new UncheckedProtectedAbstractProducerImpl(); + uncheckedProtected.protectedProduce(); + // :: error: (Return value of protectedProduce (Boundary) must be NonNull) + } +} + +@AnnotatedFor("nullness") +abstract class CheckedAbstractSink { + public abstract void accept(Object value); +} + +@AnnotatedFor("nullness") +class CheckedAbstractSinkImpl extends CheckedAbstractSink { + public void accept(Object value) { + } +} + +class UncheckedAbstractCaller { + static void call(CheckedAbstractSink target) { + // :: error: (Parameter 0 must be NonNull) + target.accept(null); + } +} + +@AnnotatedFor("nullness") +abstract class CheckedPackageAbstractSink { + abstract void packageAccept(Object value); +} + +@AnnotatedFor("nullness") +class CheckedPackageAbstractSinkImpl extends CheckedPackageAbstractSink { + void packageAccept(Object value) { + } +} + +class UncheckedPackageAbstractCaller { + static void call(CheckedPackageAbstractSink target) { + // :: error: (Parameter 0 must be NonNull) + target.packageAccept(null); + } +} + +@AnnotatedFor("nullness") +abstract class CheckedProtectedAbstractSink { + protected abstract void protectedAccept(Object value); +} + +@AnnotatedFor("nullness") +class CheckedProtectedAbstractSinkImpl extends CheckedProtectedAbstractSink { + protected void protectedAccept(Object value) { + } +} + +class UncheckedProtectedAbstractCaller { + static void call(CheckedProtectedAbstractSink target) { + // :: error: (Parameter 0 must be NonNull) + target.protectedAccept(null); + } +} + +@AnnotatedFor("nullness") +class CheckedConcreteSink { + public void accept(Object value) { + } +} + +class UncheckedConcreteSinkChild extends CheckedConcreteSink { +} + +@AnnotatedFor("nullness") +abstract class CheckedAbstractProducer { + public abstract Object produce(); +} + +class UncheckedAbstractProducerImpl extends CheckedAbstractProducer { + public Object produce() { + return null; + } +} + +@AnnotatedFor("nullness") +abstract class CheckedPackageAbstractProducer { + abstract Object packageProduce(); +} + +class UncheckedPackageAbstractProducerImpl extends CheckedPackageAbstractProducer { + Object packageProduce() { + return null; + } +} + +@AnnotatedFor("nullness") +abstract class CheckedProtectedAbstractProducer { + protected abstract Object protectedProduce(); +} + +class UncheckedProtectedAbstractProducerImpl extends CheckedProtectedAbstractProducer { + protected Object protectedProduce() { + return null; + } +} diff --git a/checker/src/test/resources/test-cases/nullness-invoke/CheckedBridgeVirtualDispatch.java b/checker/src/test/resources/test-cases/nullness-invoke/CheckedBridgeVirtualDispatch.java new file mode 100644 index 0000000..3741212 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-invoke/CheckedBridgeVirtualDispatch.java @@ -0,0 +1,30 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class CheckedBridgeVirtualDispatch { + public static void main(String[] args) { + CheckedBridgeBase checked = new CheckedBridgeChild(); + checked.accept(null); + + UncheckedBridgeVirtualCaller.call(new CheckedBridgeChild()); + } +} + +@AnnotatedFor("nullness") +class CheckedBridgeBase { + public void accept(T value) { + } +} + +@AnnotatedFor("nullness") +class CheckedBridgeChild extends CheckedBridgeBase { + public void accept(String value) { + } +} + +class UncheckedBridgeVirtualCaller { + static void call(CheckedBridgeBase sink) { + // :: error: (Parameter 0 must be NonNull) + sink.accept(null); + } +} diff --git a/checker/src/test/resources/test-cases/nullness-invoke/CheckedClassTypedInterfaceDefaultDispatch.java b/checker/src/test/resources/test-cases/nullness-invoke/CheckedClassTypedInterfaceDefaultDispatch.java new file mode 100644 index 0000000..7623b99 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-invoke/CheckedClassTypedInterfaceDefaultDispatch.java @@ -0,0 +1,26 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class CheckedClassTypedInterfaceDefaultDispatch + implements CheckedClassTypedDefaultContract { + public static void main(String[] args) { + CheckedClassTypedInterfaceDefaultDispatch checked = + new CheckedClassTypedInterfaceDefaultDispatch(); + checked.consume(null); + + UncheckedClassTypedDefaultCaller.call(new CheckedClassTypedInterfaceDefaultDispatch()); + } +} + +@AnnotatedFor("nullness") +interface CheckedClassTypedDefaultContract { + default void consume(Object value) { + } +} + +class UncheckedClassTypedDefaultCaller { + static void call(CheckedClassTypedInterfaceDefaultDispatch receiver) { + // :: error: (Parameter 0 must be NonNull) + receiver.consume(null); + } +} diff --git a/checker/src/test/resources/test-cases/nullness-invoke/CheckedClassTypedInterfaceDefaultReturnBoundary.java b/checker/src/test/resources/test-cases/nullness-invoke/CheckedClassTypedInterfaceDefaultReturnBoundary.java new file mode 100644 index 0000000..d9ea9d3 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-invoke/CheckedClassTypedInterfaceDefaultReturnBoundary.java @@ -0,0 +1,22 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class CheckedClassTypedInterfaceDefaultReturnBoundary + implements CheckedChildDefaultReturnContract { + public static void main(String[] args) { + CheckedClassTypedInterfaceDefaultReturnBoundary receiver = + new CheckedClassTypedInterfaceDefaultReturnBoundary(); + receiver.produce(); + // :: error: (Return value of produce (Boundary) must be NonNull) + } +} + +interface UncheckedParentDefaultReturnContract { + default Object produce() { + return null; + } +} + +@AnnotatedFor("nullness") +interface CheckedChildDefaultReturnContract extends UncheckedParentDefaultReturnContract { +} diff --git a/checker/src/test/resources/test-cases/nullness-invoke/CheckedFinalVirtualDispatch.java b/checker/src/test/resources/test-cases/nullness-invoke/CheckedFinalVirtualDispatch.java new file mode 100644 index 0000000..53ebef8 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-invoke/CheckedFinalVirtualDispatch.java @@ -0,0 +1,23 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class CheckedFinalVirtualDispatch { + public static void main(String[] args) { + CheckedFinalBase receiver = new CheckedFinalSub(); + receiver.produce(new Object()); + } +} + +@AnnotatedFor("nullness") +class CheckedFinalBase { + public Object produce(Object value) { + return null; + } +} + +@AnnotatedFor("nullness") +class CheckedFinalSub extends CheckedFinalBase { + public final Object produce(Object value) { + return new Object(); + } +} diff --git a/checker/src/test/resources/test-cases/nullness-invoke/CheckedInheritedStaticDispatch.java b/checker/src/test/resources/test-cases/nullness-invoke/CheckedInheritedStaticDispatch.java new file mode 100644 index 0000000..01062ac --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-invoke/CheckedInheritedStaticDispatch.java @@ -0,0 +1,24 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class CheckedInheritedStaticDispatch { + public static void main(String[] args) { + CheckedInheritedStaticChild.accept(null); + + UncheckedInheritedStaticChild.accept(null); + // :: error: (Parameter 0 must be NonNull) + } +} + +@AnnotatedFor("nullness") +class CheckedInheritedStaticBase { + public static void accept(Object value) { + } +} + +@AnnotatedFor("nullness") +class CheckedInheritedStaticChild extends CheckedInheritedStaticBase { +} + +class UncheckedInheritedStaticChild extends CheckedInheritedStaticBase { +} diff --git a/checker/src/test/resources/test-cases/nullness-invoke/CheckedInheritedUncheckedReturn.java b/checker/src/test/resources/test-cases/nullness-invoke/CheckedInheritedUncheckedReturn.java new file mode 100644 index 0000000..0d5268d --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-invoke/CheckedInheritedUncheckedReturn.java @@ -0,0 +1,20 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class CheckedInheritedUncheckedReturn { + public static void main(String[] args) { + CheckedReturnChild receiver = new CheckedReturnChild(); + receiver.produce(); + // :: error: (Return value of produce (Boundary) must be NonNull) + } +} + +class UncheckedReturnBase { + public final Object produce() { + return null; + } +} + +@AnnotatedFor("nullness") +class CheckedReturnChild extends UncheckedReturnBase { +} diff --git a/checker/src/test/resources/test-cases/nullness-invoke/CheckedInheritedVirtualDispatch.java b/checker/src/test/resources/test-cases/nullness-invoke/CheckedInheritedVirtualDispatch.java new file mode 100644 index 0000000..415a5db --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-invoke/CheckedInheritedVirtualDispatch.java @@ -0,0 +1,28 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class CheckedInheritedVirtualDispatch { + public static void main(String[] args) { + CheckedInheritedChild checked = new CheckedInheritedChild(); + checked.accept(null); + + UncheckedInheritedVirtualCaller.call(checked); + } +} + +@AnnotatedFor("nullness") +class CheckedInheritedBase { + public void accept(Object value) { + } +} + +@AnnotatedFor("nullness") +class CheckedInheritedChild extends CheckedInheritedBase { +} + +class UncheckedInheritedVirtualCaller { + static void call(CheckedInheritedChild target) { + // :: error: (Parameter 0 must be NonNull) + target.accept(null); + } +} diff --git a/checker/src/test/resources/test-cases/nullness-invoke/CheckedNativeVirtualDispatch.java b/checker/src/test/resources/test-cases/nullness-invoke/CheckedNativeVirtualDispatch.java new file mode 100644 index 0000000..40d2273 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-invoke/CheckedNativeVirtualDispatch.java @@ -0,0 +1,34 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class CheckedNativeVirtualDispatch { + static class Poison { + static String VALUE = null; + } + + public static void main(String[] args) { + CheckedNativeBase receiver = new CheckedNativeSub(); + boolean reachedNative = false; + try { + receiver.produce(new Object()); + } catch (UnsatisfiedLinkError expected) { + reachedNative = true; + } + + if (!reachedNative) { + System.out.println(Poison.VALUE); + } + } +} + +@AnnotatedFor("nullness") +class CheckedNativeBase { + public Object produce(Object value) { + return null; + } +} + +@AnnotatedFor("nullness") +class CheckedNativeSub extends CheckedNativeBase { + public native Object produce(Object value); +} diff --git a/checker/src/test/resources/test-cases/nullness-invoke/CheckedNonPublicStaticDispatch.java b/checker/src/test/resources/test-cases/nullness-invoke/CheckedNonPublicStaticDispatch.java new file mode 100644 index 0000000..fb5c650 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-invoke/CheckedNonPublicStaticDispatch.java @@ -0,0 +1,30 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class CheckedNonPublicStaticDispatch { + public static void main(String[] args) { + CheckedNonPublicStaticTarget.protectedAccept(null); + CheckedNonPublicStaticTarget.packageAccept(null); + + UncheckedNonPublicStaticCaller.call(); + } +} + +@AnnotatedFor("nullness") +class CheckedNonPublicStaticTarget { + protected static void protectedAccept(Object value) { + } + + static void packageAccept(Object value) { + } +} + +class UncheckedNonPublicStaticCaller { + static void call() { + // :: error: (Parameter 0 must be NonNull) + CheckedNonPublicStaticTarget.protectedAccept(null); + + // :: error: (Parameter 0 must be NonNull) + CheckedNonPublicStaticTarget.packageAccept(null); + } +} diff --git a/checker/src/test/resources/test-cases/nullness-invoke/CheckedNonPublicVirtualDispatch.java b/checker/src/test/resources/test-cases/nullness-invoke/CheckedNonPublicVirtualDispatch.java new file mode 100644 index 0000000..ab63876 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-invoke/CheckedNonPublicVirtualDispatch.java @@ -0,0 +1,31 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class CheckedNonPublicVirtualDispatch { + public static void main(String[] args) { + CheckedNonPublicVirtualTarget target = new CheckedNonPublicVirtualTarget(); + target.protectedAccept(null); + target.packageAccept(null); + + UncheckedNonPublicVirtualCaller.call(target); + } +} + +@AnnotatedFor("nullness") +class CheckedNonPublicVirtualTarget { + protected void protectedAccept(Object value) { + } + + void packageAccept(Object value) { + } +} + +class UncheckedNonPublicVirtualCaller { + static void call(CheckedNonPublicVirtualTarget target) { + // :: error: (Parameter 0 must be NonNull) + target.protectedAccept(null); + + // :: error: (Parameter 0 must be NonNull) + target.packageAccept(null); + } +} diff --git a/checker/src/test/resources/test-cases/nullness-invoke/CheckedSynchronizedVirtualDispatch.java b/checker/src/test/resources/test-cases/nullness-invoke/CheckedSynchronizedVirtualDispatch.java new file mode 100644 index 0000000..c27788a --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-invoke/CheckedSynchronizedVirtualDispatch.java @@ -0,0 +1,36 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class CheckedSynchronizedVirtualDispatch { + public static void main(String[] args) { + CheckedSynchronizedBase receiver = new CheckedSynchronizedSub(); + receiver.produce(new Object()); + + CheckedSynchronizedBase locked = new CheckedSynchronizedLockSub(); + locked.produce(new Object()); + } +} + +@AnnotatedFor("nullness") +class CheckedSynchronizedBase { + public Object produce(Object value) { + return null; + } +} + +@AnnotatedFor("nullness") +class CheckedSynchronizedSub extends CheckedSynchronizedBase { + public synchronized Object produce(Object value) { + return new Object(); + } +} + +@AnnotatedFor("nullness") +class CheckedSynchronizedLockSub extends CheckedSynchronizedBase { + public synchronized Object produce(Object value) { + if (!Thread.holdsLock(this)) { + return null; + } + return new Object(); + } +} diff --git a/checker/src/test/resources/test-cases/nullness-invoke/CheckedVirtualFallbackReturn.java b/checker/src/test/resources/test-cases/nullness-invoke/CheckedVirtualFallbackReturn.java new file mode 100644 index 0000000..1aea31a --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-invoke/CheckedVirtualFallbackReturn.java @@ -0,0 +1,43 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; +import org.checkerframework.checker.nullness.qual.Nullable; + +@AnnotatedFor("nullness") +public class CheckedVirtualFallbackReturn { + public static void main(String[] args) { + CheckedVirtualBase checked = new CheckedVirtualBase(); + checked.produce(); + + CheckedVirtualBase unchecked = new UncheckedVirtualOverride(); + unchecked.produce(); + // :: error: (Return value of produce (Boundary) must be NonNull) + + CheckedNullableVirtualBase nullable = new UncheckedNullableVirtualOverride(); + nullable.produceNullable(); + } +} + +@AnnotatedFor("nullness") +class CheckedVirtualBase { + public Object produce() { + return new Object(); + } +} + +class UncheckedVirtualOverride extends CheckedVirtualBase { + public Object produce() { + return null; + } +} + +@AnnotatedFor("nullness") +class CheckedNullableVirtualBase { + @Nullable Object produceNullable() { + return null; + } +} + +class UncheckedNullableVirtualOverride extends CheckedNullableVirtualBase { + Object produceNullable() { + return null; + } +} diff --git a/checker/src/test/resources/test-cases/nullness-invoke/CrossPackageDispatch.java b/checker/src/test/resources/test-cases/nullness-invoke/CrossPackageDispatch.java new file mode 100644 index 0000000..eefcb37 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-invoke/CrossPackageDispatch.java @@ -0,0 +1,44 @@ +package indyaccess.base; + +import indyaccess.child.CrossPackagePackageShadowChild; +import indyaccess.child.CrossPackageProtectedChild; +import indyaccess.child.CrossPackageUncheckedPackageShadowChild; +import indyaccess.child.CrossPackageUncheckedProtectedProducerChild; +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class CrossPackageDispatch { + public static void main(String[] args) { + CheckedAccessBase checkedProtected = new CrossPackageProtectedChild(); + checkedProtected.protectedAccept(null); + + CheckedAccessBase checkedPackageShadow = new CrossPackagePackageShadowChild(); + checkedPackageShadow.packageAccept(null); + + CheckedAccessBase uncheckedPackageShadow = + new CrossPackageUncheckedPackageShadowChild(); + uncheckedPackageShadow.packageAccept(null); + // :: error: (Parameter 0 must be NonNull) + + CheckedProducerBase uncheckedProtectedProducer = + new CrossPackageUncheckedProtectedProducerChild(); + uncheckedProtectedProducer.protectedProduce(); + // :: error: (Return value of protectedProduce (Boundary) must be NonNull) + } + + @AnnotatedFor("nullness") + public static class CheckedAccessBase { + protected void protectedAccept(Object value) { + } + + void packageAccept(Object value) { + } + } + + @AnnotatedFor("nullness") + public static class CheckedProducerBase { + protected Object protectedProduce() { + return new Object(); + } + } +} diff --git a/checker/src/test/resources/test-cases/nullness-invoke/CrossPackagePackageShadowChild.java b/checker/src/test/resources/test-cases/nullness-invoke/CrossPackagePackageShadowChild.java new file mode 100644 index 0000000..1a13e84 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-invoke/CrossPackagePackageShadowChild.java @@ -0,0 +1,11 @@ +package indyaccess.child; + +import indyaccess.base.CrossPackageDispatch.CheckedAccessBase; +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class CrossPackagePackageShadowChild extends CheckedAccessBase { + void packageAccept(Object value) { + throw new AssertionError("package-private shadow should not override base package method"); + } +} diff --git a/checker/src/test/resources/test-cases/nullness-invoke/CrossPackageProtectedChild.java b/checker/src/test/resources/test-cases/nullness-invoke/CrossPackageProtectedChild.java new file mode 100644 index 0000000..afef937 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-invoke/CrossPackageProtectedChild.java @@ -0,0 +1,11 @@ +package indyaccess.child; + +import indyaccess.base.CrossPackageDispatch.CheckedAccessBase; +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class CrossPackageProtectedChild extends CheckedAccessBase { + @Override + protected void protectedAccept(Object value) { + } +} diff --git a/checker/src/test/resources/test-cases/nullness-invoke/CrossPackageUncheckedPackageShadowChild.java b/checker/src/test/resources/test-cases/nullness-invoke/CrossPackageUncheckedPackageShadowChild.java new file mode 100644 index 0000000..d2e49e6 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-invoke/CrossPackageUncheckedPackageShadowChild.java @@ -0,0 +1,8 @@ +package indyaccess.child; + +import indyaccess.base.CrossPackageDispatch.CheckedAccessBase; + +public class CrossPackageUncheckedPackageShadowChild extends CheckedAccessBase { + void packageAccept(Object value) { + } +} diff --git a/checker/src/test/resources/test-cases/nullness-invoke/CrossPackageUncheckedProtectedProducerChild.java b/checker/src/test/resources/test-cases/nullness-invoke/CrossPackageUncheckedProtectedProducerChild.java new file mode 100644 index 0000000..07f3ed1 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-invoke/CrossPackageUncheckedProtectedProducerChild.java @@ -0,0 +1,10 @@ +package indyaccess.child; + +import indyaccess.base.CrossPackageDispatch.CheckedProducerBase; + +public class CrossPackageUncheckedProtectedProducerChild extends CheckedProducerBase { + @Override + protected Object protectedProduce() { + return null; + } +} diff --git a/checker/src/test/resources/test-cases/nullness-invoke/InterfaceDefaultReflectionWalkDispatch.java b/checker/src/test/resources/test-cases/nullness-invoke/InterfaceDefaultReflectionWalkDispatch.java new file mode 100644 index 0000000..b26a17e --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-invoke/InterfaceDefaultReflectionWalkDispatch.java @@ -0,0 +1,47 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class InterfaceDefaultReflectionWalkDispatch + implements CheckedReflectionWalkParent, UncheckedReflectionWalkChild { + public static void main(String[] args) { + CheckedReflectionWalkParent receiver = new InterfaceDefaultReflectionWalkDispatch(); + receiver.produce(); + // :: error: (Return value of produce (Boundary) must be NonNull) + + CheckedSpecificDefaultReceiver checkedSpecific = + new CheckedSpecificDefaultReceiver(); + checkedSpecific.consume(null); + } +} + +@AnnotatedFor("nullness") +interface CheckedReflectionWalkParent { + default Object produce() { + return new Object(); + } +} + +interface UncheckedReflectionWalkChild extends CheckedReflectionWalkParent { + @Override + default Object produce() { + return null; + } +} + +@AnnotatedFor("nullness") +class CheckedSpecificDefaultReceiver + implements UncheckedReflectionWalkBase, CheckedReflectionWalkChild { +} + +interface UncheckedReflectionWalkBase { + default void consume(Object value) { + throw new AssertionError("unchecked parent default should not be selected"); + } +} + +@AnnotatedFor("nullness") +interface CheckedReflectionWalkChild extends UncheckedReflectionWalkBase { + @Override + default void consume(Object value) { + } +} diff --git a/checker/src/test/resources/test-cases/nullness-parameter/FieldArgument.java b/checker/src/test/resources/test-cases/nullness-parameter/FieldArgument.java index 4050956..80cd301 100644 --- a/checker/src/test/resources/test-cases/nullness-parameter/FieldArgument.java +++ b/checker/src/test/resources/test-cases/nullness-parameter/FieldArgument.java @@ -11,7 +11,6 @@ static class UncheckedLib { public static void main(String[] args) { // :: error: (Read Field 'POISON' must be NonNull) - // :: error: (Parameter 0 must be NonNull) consume(UncheckedLib.POISON); // :: error: (Read Field 'POISON' must be NonNull) @@ -23,4 +22,4 @@ public static void consume(@NonNull String arg) { public static void nullableConsume(@Nullable String arg) { } -} \ No newline at end of file +} diff --git a/checker/src/test/resources/test-cases/nullness-parameter/MixedMethods.java b/checker/src/test/resources/test-cases/nullness-parameter/MixedMethods.java index 2685d4d..2487b46 100644 --- a/checker/src/test/resources/test-cases/nullness-parameter/MixedMethods.java +++ b/checker/src/test/resources/test-cases/nullness-parameter/MixedMethods.java @@ -7,19 +7,15 @@ public class MixedMethods { public static void main(String[] args) { // 1. Explicit NonNull - // :: error: (Parameter 0 must be NonNull) checkExplicit(null); // 2. Implicit NonNull (Strict Default) - // :: error: (Parameter 0 must be NonNull) checkImplicit(null); // 3. Explicit Nullable (Should NOT error) checkNullable(null); // 4. Multiple Parameters - // :: error: (Parameter 1 must be NonNull) - // :: error: (Parameter 2 must be NonNull) checkMultiple(null,null,null,null); } @@ -34,4 +30,4 @@ public static void checkNullable(@Nullable String s) { public static void checkMultiple(@Nullable String s, String q, String r, @Nullable String v) { } -} \ No newline at end of file +} diff --git a/checker/src/test/resources/test-cases/nullness-parameter/Primitives.java b/checker/src/test/resources/test-cases/nullness-parameter/Primitives.java index 2b5b8fa..3e785ce 100644 --- a/checker/src/test/resources/test-cases/nullness-parameter/Primitives.java +++ b/checker/src/test/resources/test-cases/nullness-parameter/Primitives.java @@ -7,6 +7,5 @@ public static void main(String[] args) { } public static void testPrimitives(int a, String b, boolean c) { - // :: error: (Parameter 1 must be NonNull) } } diff --git a/docs/initialization-examples.org b/docs/initialization-examples.org new file mode 100644 index 0000000..8b54cad --- /dev/null +++ b/docs/initialization-examples.org @@ -0,0 +1,302 @@ +# TITLE: Runtime initialization Examples + +The following examples illustrate situations where unchecked code can expose +objects whose invariants have not yet been established. These scenarios motivate +runtime initialization checks in a gradual type system that combines checked +and unchecked code. + +Two (maybe 3?) distinct kinds of initialization guarantees are relevant: + +**Shallow initialization** + +Shallow initialization concerns the *receiver object itself*. +A shallow initialization violation occurs when a reference to an object is used +before the object's own invariants have been established. + +In these cases, the object itself is still under construction and its fields may +not yet satisfy their declared invariants. + +**Deep initialization** + +Deep initialization extends the guarantee transitively to objects reachable +through fields. If an object is considered initialized, then any references +stored in its invariant fields should also point to initialized objects. + +Think forward reachability + +**Global initialization??** + +if x is committed then objects pointing to x must also be committed + +Think forward + reverse reachability + +KeyFor? +is ownership global? +can you extend JOL for uniqueness + +* Example 1: Instance method invoked during construction where the receiver must be fully initialized + +This example demonstrates a classic Java hazard: a superclass constructor invokes a method +that is overridden in a subclass. Because method dispatch is virtual, the subclass method +may execute before the subclass constructor has established its invariants. + +#+BEGIN_SRC javac :classname Unchecked :dir /tmp/cf-init-demo + public class Unchecked { + public Unchecked() { + init(); + } + protected void init() { } + } +#+END_SRC + +#+RESULTS: +: OK: javac /tmp/cf-init-demo/Unchecked.java 2>&1 + + + #+BEGIN_SRC checker-javac :classname Checked :dir /tmp/cf-init-demo :sources "Unchecked.java" :flags "-processor org.checkerframework.checker.nullness.NullnessChecker" + import org.checkerframework.checker.nullness.qual.NonNull; + + public class Checked extends Unchecked { + @NonNull String name; + + public Checked(String n) { + super(); + this.name = n; + } + + @Override protected void init() { + // expects fully initialized receiver + // the implicit 'this' is technically non-null so a runtime null check + // doesn't help here + // you could either inject a check in an override such as: + // RuntimeInitChecks.assertInitialized(this); + // or similar to bworld, enforce super calls after fields are initialized + System.out.println(name.length()); + } + + public static void main(String[] args) { + Checked willFail = new Checked("test"); + } + } +#+END_SRC + +#+RESULTS: +: FAILED (/home/alex/eisop/checker-framework/checker/bin/javac -processor org.checkerframework.checker.nullness.NullnessChecker /tmp/cf-init-demo/Checked.java /tmp/cf-init-demo/Unchecked.java 2>&1) +: /tmp/cf-init-demo/Unchecked.java:3: error: [method.invocation.invalid] call to init() not allowed on the given receiver. +: init(); +: ^ +: found : @UnderInitialization(Unchecked.class) Unchecked +: required: @Initialized Unchecked +: 1 error + + +* Example 2: Callback invoked before object initialization completes + +Another way objects may escape before their invariants are established is through callbacks. +Unchecked code may accept a callback and invoke it before completing initialization. + +#+BEGIN_SRC java + public class Unchecked { + String randomString; + + @FunctionalInterface + public interface Callback { + void accept(Unchecked u); + } + + public Unchecked(Callback cb) { + // could inject here: + // RuntimeInitChecks.assertInitialized(this); + cb.accept(this); // escape under construction (before initializing fields) + this.randomString = "ok"; + } + } +#+END_SRC + +#+BEGIN_SRC java + public class Checked { + public static void main(String[] args) { + new Unchecked(u -> { + // could inject here: + // RuntimeInitChecks.assertInitialized(u); + System.out.println(u.randomString.length()); // NPE at runtime + }); + } +#+END_SRC + +The constructor of Unchecked invokes the callback before assigning randomString. +As a result the callback receives an object whose invariant field has not yet been initialized. +The lambda therefore dereferences a null field. + + +* Example 3: Deep initialization violation via invariant field + +A direct deep initialization violation occurs when a checked object stores a reference to an unchecked object whose invariants have not been established. + +#+BEGIN_SRC java + public class UncheckedDep { + String inner; // default null + // a field could also be a reference type which would require further traversal of + // object graph + + public UncheckedDep() { + // does not initialize inner + } + } +#+END_SRC + +#+BEGIN_SRC java + import org.checkerframework.checker.nullness.qual.NonNull; + + public class Checked { + @NonNull UncheckedDep dep; // non-null reference, but deep init is not guaranteed + + public Checked(@NonNull UncheckedDep dep) { + this.dep = dep; // shallow invariant satisfied: dep != null + } + + public void use() { + // Deep initialization would require dep.inner to be non-null (and initialized). + System.out.println(dep.inner.length()); // NPE at runtime + } + + public static void main(String[] args) { + UncheckedDep d = new UncheckedDep(); + Checked c = new Checked(d); + c.use(); + } + } + + +#+END_SRC + + +* Example 4: Heap publication before initialization completes + +#+BEGIN_SRC java + public class CheckedUse { + public static void main(String[] args) { + // Application registers a listener with the library. + UncheckedFactory.setListener(new UncheckedFactory.Listener() { + @Override public void onPublished() { + // Re-entrant callback from unchecked library code. + Unchecked u = UncheckedFactory.last; + + // Runtime nullness checks could fail at u.value, + // but the semantic bug is "use before invariant establishment". + // A runtime init checker would insert: + // RuntimeInitChecks.assertDeepInitialized(u); + System.out.println(u.value.length()); + } + }); + + UncheckedFactory.get(); + } + } +#+END_SRC + + +#+BEGIN_SRC java + public class UncheckedFactory { + static Unchecked last; + + interface Listener { void onPublished(); } + private static Listener listener; + + public static void setListener(Listener l) { + listener = l; + } + + public static Unchecked get() { + Unchecked u = new Unchecked(); // allocation + last = u; // publish early + + // library notifies observers; this is plausible in real systems + if (listener != null) listener.onPublished(); + + u.value = "ready"; // finish invariant + return u; + } + } + + class Unchecked { + String value; // default null + } +#+END_SRC + + +Unlike the UncheckedDep example, the root object here is not stored inside a checked object’s field. Instead, unchecked code publishes a reference to a global heap location, and then re-enters checked code, which reads that root and immediately traverses its fields. Deep initialization is still the relevant guarantee: checked code wants to assume that an initialized root implies initialized reachable state. The only difference is the source of the root: a heap location controlled by unchecked code rather than a checked-field assignment. + + +* Example 5: Global init? + +#+BEGIN_SRC java + public class UncheckedDep { + String inner; // default null + + public UncheckedDep() { + // does not initialize inner + } + + public static UncheckedDep makeReady() { + UncheckedDep d = new UncheckedDep(); + d.inner = "ok"; + return d; + } +} +#+END_SRC + +#+BEGIN_SRC java + public class UncheckedMutator { + + CheckedRoot r; + + public static void corrupt(CheckedRoot r) { + // Create an under-initialized object... + UncheckedDep d = new UncheckedDep(); + // ...and store it into a field of an already-constructed checked object. + r.dep = d; // <-- pollutes committed object graph + // here d is nonnull, but not deeply initialized! + + } + } +#+END_SRC + +#+BEGIN_SRC java + import org.checkerframework.checker.nullness.qual.NonNull; + + public class CheckedRoot { + + // Checked invariant: dep is non-null + public @NonNull UncheckedDep dep; + + public CheckedRoot() { + // Assume unchecked factory returns a "ready" object. + // In gradual system, you'd insert a boundary deep-init check here: + // RuntimeInitChecks.assertDeepInitialized(dep); + dep = UncheckedDep.makeReady(); + } + + public int use() { + // If unchecked code mutated dep to point at an under-init object, + // boundary-only checks will not fire at this dereference. + return dep.inner.length(); // NPE if inner is null + // where does dep come from? depending on where, you may or may not inject a check + } + + public static void main(String[] args) { + CheckedRoot r = new CheckedRoot(); + + // Boundary: checked -> unchecked (passing an initialized root to unchecked code) + UncheckedMutator.corrupt(r); + + // Later, checked code uses r again *without crossing a boundary* + System.out.println(r.use()); + } + } +#+END_SRC + +UncheckedMutator + | + v + r ───► dep ───► "ok" diff --git a/docs/injection-outline.org b/docs/injection-outline.org new file mode 100644 index 0000000..3e8ef98 --- /dev/null +++ b/docs/injection-outline.org @@ -0,0 +1,189 @@ +| Check | Flow event | Injection point | Injected when | Blame | +|---------------------------------------------+-------------------------+-------------------------------+--------------------------------------------+----------| +| Checked method parameter | =MethodParameter= | method entry | Enclosing class is checked | =CALLER= | +| Checked method return | =MethodReturn= | before normal return | Enclosing class is checked | =LOCAL= | +| Checked caller receives unchecked return | =BoundaryCallReturn= | after invoke | Checked caller invokes unchecked owner | =LOCAL= | +| Checked method field read | =FieldRead= | after =GETFIELD=/=GETSTATIC= | Checked method reads same/unchecked owner | =LOCAL= | +| Write into checked field | =FieldWrite= | before =PUTFIELD=/=PUTSTATIC= | Global mode writes into checked owner | =LOCAL= | +| Checked method array load | =ArrayLoad= | after =AALOAD= | Enclosing method is checked | =LOCAL= | +| Array store | =ArrayStore= | before =AASTORE= | Any transformed class | =LOCAL= | +| Checked local reference store | =LocalStore= | before =ASTORE= | Enclosing method is checked | =LOCAL= | +| Synthetic inherited-method bridge parameter | bridge parameter action | bridge entry | Checked class inherits unchecked method | =CALLER= | +| Synthetic inherited-method bridge return | bridge return action | bridge exit | Checked class inherits unchecked method | =CALLER= | +| Unchecked override parameter | =OverrideParameter= | method entry | Global mode, unchecked override of checked | =CALLER= | +| Unchecked override return | =OverrideReturn= | before normal return | Global mode, unchecked override of checked | =LOCAL= | + +* First invokedynamic experiment: checked parameter boundary elision + +| Scenario | What we instrument | What happens | +|-------------------------------------------+--------------------------------------+------------------------------------------------------------| +| Checked caller -> checked receiver | Rewrite call site to =invokedynamic= | Indy calls the checked receiver's safe body | +| Checked caller -> unchecked receiver | Rewrite call site to =invokedynamic= | Indy calls the original method and applies return checks | +| Unchecked caller -> checked receiver | Do not rewrite call site | Normal dispatch reaches the public wrapper; params checked | +| Unchecked caller -> unchecked receiver | Do not rewrite call site | Normal dispatch runs as today | +| Uninstrumented caller -> checked receiver | Cannot rewrite call site | Normal dispatch reaches the public wrapper; params checked | +| Checked static/final/private call | Leave alone for first experiment | No dynamic dispatch; direct safe calls can come later | +| Constructor call | Leave unchanged | Constructor/object initialization rules stay untouched | + + +* Note on syntheic bridge methods + +- Checked receiver exposing unchecked implementation: protect checked clients from unchecked inherited behavior. +- Unchecked receiver calling checked implementation: protect checked code from unchecked arguments. + +One nuance: these bridges are only valid when Java lets us override the inherited method. They work for ordinary inherited instance methods. They do not work for `final`, `static`, `private`, or constructor behavior, which is why the call-site return check is still needed in some cases. + +So I think the bridge design is defensible. It is basically making inheritance itself an explicit checked/unchecked boundary instead of letting unchecked superclass code leak through a checked subclass API invisibly. + +* Virtual dispatch + +For any checked call site rewritten to the safe branch, every checked runtime receiver that can satisfy the original dispatch must also dispatch the corresponding `$runtimeframework$safe` call to a safe method containing the same body as that receiver’s original dispatch target. + +This means we also need to method split on final methods, as they will need a corresponding $safe version. + +* invoke dynamic + +An ~invokedynamic~ instruction names a dynamically linked call site rather than a concrete method. The meaning of the call site is determined by a bootstrap method, which links the call site to a target ~MethodHandle~. + +A call site can be used to implement many different language-level operations, such as lambda expressions, string concatenation, dynamic-language dispatch, or custom runtime dispatch logic. In our case, we are interested in using it to represent an instance-like call to ~process~: + +#+BEGIN_SRC text +invokedynamic process:(LCheckedService;Ljava/lang/String;)V +#+END_SRC + +Here, ~process~ is the call-site name, and the descriptor gives the complete call-site type: + +#+BEGIN_SRC text +(CheckedService, String) -> void +#+END_SRC + +The first parameter, ~CheckedService~, represents the receiver object. This is necessary because this particular call site is being used in an instance-like way. Unlike ~invokevirtual~, ~invokedynamic~ does not have a symbolic method owner that separately identifies the receiver type. The call-site descriptor must therefore include every value consumed by the call site, including the receiver. + +For an ~invokevirtual~ call: + +#+BEGIN_SRC text +invokevirtual CheckedService.process:(Ljava/lang/String;)V +#+END_SRC + +the receiver type is encoded in the symbolic method reference owner, ~CheckedService~, and is omitted from the method descriptor itself. The descriptor only lists the ordinary source-level method parameters: + +#+BEGIN_SRC text +(String) -> void +#+END_SRC + +The descriptor only lists the ordinary source-level method parameters: + +#+BEGIN_SRC text +(String) -> void +#+END_SRC + +However, both instructions consume the same runtime values from the operand stack. Just before the call, the stack has the shape: + +#+BEGIN_SRC text +... +receiver +arg1 +arg2 +... +argk +#+END_SRC + +where ~argk~ is at the top of the stack. + +The difference is therefore not the runtime stack shape. The difference is how the receiver is represented in the bytecode metadata. With ~invokevirtual~, the receiver type is represented by the symbolic method owner. With ~invokedynamic~, there is no such owner, so if the call site is instance-like, the receiver must be represented as an explicit parameter in the call-site descriptor. + +The first time a particular dynamic call site is resolved, the JVM invokes its associated bootstrap method. Conceptually, the JVM passes three leading arguments: + +#+BEGIN_SRC text +MethodHandles.Lookup callerLookup +String invokedName +MethodType invokedType +#+END_SRC + +The ~callerLookup~ object represents the class containing the ~invokedynamic~ instruction and controls access-sensitive lookup. The ~invokedName~ is the call-site name, such as ~"process"~. The ~invokedType~ is the call-site type, such as: + +#+BEGIN_SRC text +(CheckedService, String) -> void +#+END_SRC + +The JVM then appends any static bootstrap arguments encoded for that call site. For example, in our setting these might include the checked target owner, the original method name, the safe method name, the original method type, and sometimes a fallback return-filter handle. + +~MethodHandles~ is a Java API in ~java.lang.invoke~ for working with method handles. A method handle is a strongly typed, directly executable reference to some computation. It can refer to things such as: + +#+BEGIN_SRC java +someObject.instanceMethod(...) +SomeClass.staticMethod(...) +someObject.field +SomeClass.staticField +new SomeClass(...) +array[index] +#+END_SRC + +In some ways, a method handle is like a safer, more structured function pointer. The ~MethodHandles~ API acts as a factory/helper API used to find, adapt, and compose method handles. + + +For example: + +#+BEGIN_SRC java +MethodHandles.Lookup lookup = MethodHandles.lookup(); + +MethodHandle mh = + lookup.findVirtual( + CheckedService.class, + "process", + MethodType.methodType(void.class, String.class) + ); +#+END_SRC + +This finds a handle to the instance method: + +#+BEGIN_SRC java +void CheckedService.process(String) +#+END_SRC + +The resulting method handle has type: + +#+BEGIN_SRC text +(CheckedService, String) -> void +#+END_SRC + +The linkage information for an ~invokedynamic~ instruction is stored in the class file. Conceptually, it looks something like: + +#+BEGIN_SRC text +InvokeDynamic: + name: process + descriptor: (LCheckedService;Ljava/lang/String;)V + bootstrap method: MyBootstrap.bootstrap + static bootstrap args: ... +#+END_SRC + +More precisely, the ~invokedynamic~ instruction uses two operand bytes as an index into the run-time constant pool of the current class. That entry is a symbolic reference to a call site specifier. Resolving that call site specifier produces the bootstrap method handle, the call-site ~MethodType~, and any static bootstrap arguments. + +The bootstrap method is invoked as if through ~MethodHandle.invoke~. The JVM supplies the bootstrap method handle itself, the ~Lookup~ object, the call-site name, the call-site ~MethodType~, and then the static bootstrap arguments. The static bootstrap arguments may be classes, method handles, method types, strings, or primitive numeric constants. + +A bootstrap method must return a ~CallSite~. A ~CallSite~ is an object that holds the target ~MethodHandle~ for this particular linked ~invokedynamic~ instruction. The call-site object's type descriptor must be semantically equal to the ~MethodType~ obtained from the call-site descriptor, and the target method handle is invoked as if by ~MethodHandle.invokeExact~ with that descriptor. + +For example, a bootstrap might return: + +#+BEGIN_SRC java +return new ConstantCallSite(target); +#+END_SRC + +A ~ConstantCallSite~ means that the call site's target is fixed after linkage. After successful resolution, the resulting call site object is permanently bound to that dynamic call site. + +The target method handle can also be built by composing other method handles. For example: + +#+BEGIN_SRC java +return new ConstantCallSite( + MethodHandles.guardWithTest(test, safe, original) +); +#+END_SRC + +Here, ~guardWithTest~ is a method-handle combinator. It creates a new method handle that first runs ~test~. If the test succeeds, it invokes ~safe~; otherwise, it invokes ~original~. + +This is useful when an ~invokedynamic~ call site should dispatch between two possible targets. For example, in a gradual nullness implementation, the test might determine whether the call can use a fast checked path or must fall back to a guarded path with runtime checks. + + +later improvement: + +2. **More efficient later:** use a polymorphic inline cache with a `MutableCallSite`, specializing the call site for observed receiver classes. That can avoid a `ClassValue` lookup on hot monomorphic sites, but it is more complex and still needs a correct resolver. diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeAgent.java b/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeAgent.java index 4502efa..bef4e9f 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeAgent.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeAgent.java @@ -1,5 +1,6 @@ package io.github.eisop.runtimeframework.agent; +import io.github.eisop.runtimeframework.config.RuntimeOptions; import io.github.eisop.runtimeframework.core.RuntimeChecker; import io.github.eisop.runtimeframework.filter.ClassInfo; import io.github.eisop.runtimeframework.filter.ClassListFilter; @@ -16,44 +17,37 @@ public final class RuntimeAgent { public static void premain(String args, Instrumentation inst) { + RuntimeOptions options = RuntimeOptions.fromSystemProperties(); Filter safeFilter = new FrameworkSafetyFilter(); - String checkedClasses = System.getProperty("runtime.classes"); - boolean isGlobalMode = Boolean.getBoolean("runtime.global"); - boolean trustAnnotatedFor = Boolean.getBoolean("runtime.trustAnnotatedFor"); Filter checkedScopeFilter = - (checkedClasses != null && !checkedClasses.isBlank()) - ? new ClassListFilter(Arrays.asList(checkedClasses.split(","))) + options.hasCheckedClasses() + ? new ClassListFilter(Arrays.asList(options.checkedClasses().split(","))) : Filter.rejectAll(); - // 3. Configure Violation Handler - String handlerClassName = System.getProperty("runtime.handler"); - if (handlerClassName != null && !handlerClassName.isBlank()) { + // Configure ViolationHandler before instrumented checks can run. + if (options.hasHandlerClassName()) { try { - System.out.println("[RuntimeAgent] Setting ViolationHandler: " + handlerClassName); - Class handlerClass = Class.forName(handlerClassName); + System.out.println( + "[RuntimeAgent] Setting ViolationHandler: " + options.handlerClassName()); + Class handlerClass = Class.forName(options.handlerClassName()); ViolationHandler handler = (ViolationHandler) handlerClass.getConstructor().newInstance(); RuntimeVerifier.setViolationHandler(handler); } catch (Exception e) { System.err.println( - "[RuntimeAgent] ERROR: Could not instantiate handler: " + handlerClassName); + "[RuntimeAgent] ERROR: Could not instantiate handler: " + options.handlerClassName()); e.printStackTrace(); } } - String checkerClassName = - System.getProperty( - "runtime.checker", - "io.github.eisop.runtimeframework.checker.nullness.NullnessRuntimeChecker"); - RuntimeChecker checker; try { - System.out.println("[RuntimeAgent] Loading checker: " + checkerClassName); - Class clazz = Class.forName(checkerClassName); + System.out.println("[RuntimeAgent] Loading checker: " + options.checkerClassName()); + Class clazz = Class.forName(options.checkerClassName()); checker = (RuntimeChecker) clazz.getConstructor().newInstance(); } catch (Exception e) { System.err.println( - "[RuntimeAgent] FATAL: Could not instantiate checker: " + checkerClassName); + "[RuntimeAgent] FATAL: Could not instantiate checker: " + options.checkerClassName()); e.printStackTrace(); return; } @@ -62,19 +56,20 @@ public static void premain(String args, Instrumentation inst) { new ScopeAwareRuntimePolicy( safeFilter, checkedScopeFilter, - isGlobalMode, - trustAnnotatedFor, + options.globalMode(), + options.trustAnnotatedFor(), checker.getName(), ResolutionEnvironment.system()); - System.out.println("[RuntimeAgent] Policy mode: " + (isGlobalMode ? "GLOBAL" : "STANDARD")); - if (checkedClasses != null && !checkedClasses.isBlank()) { - System.out.println("[RuntimeAgent] Checked scope list: " + checkedClasses); + System.out.println( + "[RuntimeAgent] Policy mode: " + (options.globalMode() ? "GLOBAL" : "STANDARD")); + if (options.hasCheckedClasses()) { + System.out.println("[RuntimeAgent] Checked scope list: " + options.checkedClasses()); } - if (trustAnnotatedFor) { + if (options.trustAnnotatedFor()) { System.out.println("[RuntimeAgent] Checked scope includes @AnnotatedFor classes."); } - inst.addTransformer(new RuntimeTransformer(policy, checker), false); + inst.addTransformer(new RuntimeTransformer(policy, checker, options), false); } } diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java b/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java index 3812f81..1f77b54 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java @@ -1,5 +1,6 @@ package io.github.eisop.runtimeframework.agent; +import io.github.eisop.runtimeframework.config.RuntimeOptions; import io.github.eisop.runtimeframework.core.RuntimeChecker; import io.github.eisop.runtimeframework.filter.ClassInfo; import io.github.eisop.runtimeframework.instrumentation.RuntimeInstrumenter; @@ -16,8 +17,12 @@ public class RuntimeTransformer implements ClassFileTransformer { private final RuntimeInstrumenter instrumenter; public RuntimeTransformer(RuntimePolicy policy, RuntimeChecker checker) { + this(policy, checker, RuntimeOptions.fromSystemProperties()); + } + + public RuntimeTransformer(RuntimePolicy policy, RuntimeChecker checker, RuntimeOptions options) { this.policy = policy; - this.instrumenter = checker.createInstrumenter(policy); + this.instrumenter = checker.createInstrumenter(policy, options); } @Override diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/config/RuntimeOptions.java b/framework/src/main/java/io/github/eisop/runtimeframework/config/RuntimeOptions.java new file mode 100644 index 0000000..54bb067 --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/config/RuntimeOptions.java @@ -0,0 +1,81 @@ +package io.github.eisop.runtimeframework.config; + +import java.util.Objects; +import java.util.Properties; + +/** Immutable runtime configuration parsed from JVM system properties. */ +public record RuntimeOptions( + String checkedClasses, + boolean globalMode, + boolean trustAnnotatedFor, + String handlerClassName, + String checkerClassName, + boolean indyBoundaryEnabled) { + + public static final String CHECKED_CLASSES_PROPERTY = "runtime.classes"; + public static final String GLOBAL_MODE_PROPERTY = "runtime.global"; + public static final String TRUST_ANNOTATED_FOR_PROPERTY = "runtime.trustAnnotatedFor"; + public static final String HANDLER_CLASS_PROPERTY = "runtime.handler"; + public static final String CHECKER_CLASS_PROPERTY = "runtime.checker"; + public static final String INDY_BOUNDARY_PROPERTY = "runtime.indy.boundary"; + + public static final String DEFAULT_CHECKED_CLASSES = ""; + public static final boolean DEFAULT_GLOBAL_MODE = false; + public static final boolean DEFAULT_TRUST_ANNOTATED_FOR = false; + public static final String DEFAULT_HANDLER_CLASS = ""; + public static final String DEFAULT_CHECKER_CLASS = + "io.github.eisop.runtimeframework.checker.nullness.NullnessRuntimeChecker"; + public static final boolean DEFAULT_INDY_BOUNDARY_ENABLED = true; + + public RuntimeOptions { + checkedClasses = Objects.requireNonNull(checkedClasses, "checkedClasses").trim(); + handlerClassName = Objects.requireNonNull(handlerClassName, "handlerClassName").trim(); + checkerClassName = Objects.requireNonNull(checkerClassName, "checkerClassName").trim(); + if (checkerClassName.isEmpty()) { + checkerClassName = DEFAULT_CHECKER_CLASS; + } + } + + public static RuntimeOptions defaults() { + return new RuntimeOptions( + DEFAULT_CHECKED_CLASSES, + DEFAULT_GLOBAL_MODE, + DEFAULT_TRUST_ANNOTATED_FOR, + DEFAULT_HANDLER_CLASS, + DEFAULT_CHECKER_CLASS, + DEFAULT_INDY_BOUNDARY_ENABLED); + } + + public static RuntimeOptions fromSystemProperties() { + return fromProperties(System.getProperties()); + } + + public static RuntimeOptions fromProperties(Properties properties) { + Objects.requireNonNull(properties, "properties"); + return new RuntimeOptions( + stringProperty(properties, CHECKED_CLASSES_PROPERTY, DEFAULT_CHECKED_CLASSES), + booleanProperty(properties, GLOBAL_MODE_PROPERTY, DEFAULT_GLOBAL_MODE), + booleanProperty(properties, TRUST_ANNOTATED_FOR_PROPERTY, DEFAULT_TRUST_ANNOTATED_FOR), + stringProperty(properties, HANDLER_CLASS_PROPERTY, DEFAULT_HANDLER_CLASS), + stringProperty(properties, CHECKER_CLASS_PROPERTY, DEFAULT_CHECKER_CLASS), + booleanProperty(properties, INDY_BOUNDARY_PROPERTY, DEFAULT_INDY_BOUNDARY_ENABLED)); + } + + public boolean hasCheckedClasses() { + return !checkedClasses.isBlank(); + } + + public boolean hasHandlerClassName() { + return !handlerClassName.isBlank(); + } + + private static String stringProperty(Properties properties, String key, String defaultValue) { + String value = properties.getProperty(key); + return value == null || value.isBlank() ? defaultValue : value; + } + + private static boolean booleanProperty(Properties properties, String key, boolean defaultValue) { + String value = properties.getProperty(key); + return (value == null || value.isBlank()) ? defaultValue : Boolean.parseBoolean(value); + } +} diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeChecker.java b/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeChecker.java index b9334f7..aa65dc6 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeChecker.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeChecker.java @@ -1,5 +1,6 @@ package io.github.eisop.runtimeframework.core; +import io.github.eisop.runtimeframework.config.RuntimeOptions; import io.github.eisop.runtimeframework.instrumentation.EnforcementInstrumenter; import io.github.eisop.runtimeframework.instrumentation.RuntimeInstrumenter; import io.github.eisop.runtimeframework.planning.ContractEnforcementPlanner; @@ -22,18 +23,31 @@ public abstract class RuntimeChecker { public abstract CheckerSemantics getSemantics(); public final RuntimeInstrumenter createInstrumenter(RuntimePolicy policy) { - return createInstrumenter(policy, ResolutionEnvironment.system()); + return createInstrumenter(policy, RuntimeOptions.fromSystemProperties()); + } + + public final RuntimeInstrumenter createInstrumenter( + RuntimePolicy policy, RuntimeOptions options) { + return createInstrumenter(policy, ResolutionEnvironment.system(), options); } public RuntimeInstrumenter createInstrumenter( RuntimePolicy policy, ResolutionEnvironment resolutionEnvironment) { + return createInstrumenter(policy, resolutionEnvironment, RuntimeOptions.fromSystemProperties()); + } + + public RuntimeInstrumenter createInstrumenter( + RuntimePolicy policy, ResolutionEnvironment resolutionEnvironment, RuntimeOptions options) { CheckerSemantics semantics = getSemantics(); HierarchyResolver resolver = new BytecodeHierarchyResolver(info -> policy.isChecked(info), resolutionEnvironment); return new EnforcementInstrumenter( new ContractEnforcementPlanner(policy, semantics, resolutionEnvironment), resolver, - semantics.emitter()); + semantics.emitter(), + policy, + resolutionEnvironment, + options); } /** diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/instrumentation/EnforcementInstrumenter.java b/framework/src/main/java/io/github/eisop/runtimeframework/instrumentation/EnforcementInstrumenter.java index a28db52..f6a9558 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/instrumentation/EnforcementInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/instrumentation/EnforcementInstrumenter.java @@ -1,30 +1,59 @@ package io.github.eisop.runtimeframework.instrumentation; +import io.github.eisop.runtimeframework.config.RuntimeOptions; import io.github.eisop.runtimeframework.filter.ClassInfo; import io.github.eisop.runtimeframework.planning.BridgePlan; +import io.github.eisop.runtimeframework.planning.BytecodeLocation; import io.github.eisop.runtimeframework.planning.ClassContext; import io.github.eisop.runtimeframework.planning.EnforcementPlanner; +import io.github.eisop.runtimeframework.planning.InjectionPoint.Kind; import io.github.eisop.runtimeframework.planning.InstrumentationAction; +import io.github.eisop.runtimeframework.planning.MethodPlan; import io.github.eisop.runtimeframework.policy.ClassClassification; +import io.github.eisop.runtimeframework.policy.RuntimePolicy; import io.github.eisop.runtimeframework.resolution.HierarchyResolver; import io.github.eisop.runtimeframework.resolution.ParentMethod; +import io.github.eisop.runtimeframework.resolution.ResolutionEnvironment; +import io.github.eisop.runtimeframework.runtime.BoundaryBootstraps; import io.github.eisop.runtimeframework.semantics.PropertyEmitter; import java.lang.classfile.ClassBuilder; +import java.lang.classfile.ClassElement; import java.lang.classfile.ClassModel; +import java.lang.classfile.ClassTransform; import java.lang.classfile.CodeBuilder; +import java.lang.classfile.CodeElement; import java.lang.classfile.CodeTransform; +import java.lang.classfile.MethodElement; import java.lang.classfile.MethodModel; +import java.lang.classfile.Opcode; import java.lang.classfile.TypeKind; +import java.lang.classfile.attribute.CodeAttribute; +import java.lang.classfile.instruction.InvokeInstruction; import java.lang.constant.ClassDesc; +import java.lang.constant.ConstantDescs; +import java.lang.constant.DirectMethodHandleDesc; +import java.lang.constant.MethodHandleDesc; import java.lang.constant.MethodTypeDesc; +import java.lang.reflect.AccessFlag; import java.lang.reflect.Modifier; +import java.util.ArrayList; import java.util.List; +import java.util.Objects; +import java.util.Optional; public class EnforcementInstrumenter extends RuntimeInstrumenter { + private static final ClassDesc ASSERTION_ERROR = ClassDesc.of("java.lang.AssertionError"); + private static final MethodTypeDesc ASSERTION_ERROR_STRING_CTOR = + MethodTypeDesc.of(ConstantDescs.CD_void, ConstantDescs.CD_String); + private static final String RETURN_FILTER_PREFIX = "$runtimeframework$indyReturnCheck$"; + private final EnforcementPlanner planner; private final HierarchyResolver hierarchyResolver; private final PropertyEmitter propertyEmitter; + private final RuntimePolicy policy; + private final ResolutionEnvironment resolutionEnvironment; + private final RuntimeOptions options; public EnforcementInstrumenter(EnforcementPlanner planner, HierarchyResolver hierarchyResolver) { this(planner, hierarchyResolver, null); @@ -34,16 +63,633 @@ public EnforcementInstrumenter( EnforcementPlanner planner, HierarchyResolver hierarchyResolver, PropertyEmitter propertyEmitter) { + this( + planner, + hierarchyResolver, + propertyEmitter, + null, + ResolutionEnvironment.system(), + RuntimeOptions.fromSystemProperties()); + } + + public EnforcementInstrumenter( + EnforcementPlanner planner, + HierarchyResolver hierarchyResolver, + PropertyEmitter propertyEmitter, + RuntimePolicy policy, + ResolutionEnvironment resolutionEnvironment) { + this( + planner, + hierarchyResolver, + propertyEmitter, + policy, + resolutionEnvironment, + RuntimeOptions.fromSystemProperties()); + } + + public EnforcementInstrumenter( + EnforcementPlanner planner, + HierarchyResolver hierarchyResolver, + PropertyEmitter propertyEmitter, + RuntimePolicy policy, + ResolutionEnvironment resolutionEnvironment, + RuntimeOptions options) { this.planner = planner; this.hierarchyResolver = hierarchyResolver; this.propertyEmitter = propertyEmitter; + this.policy = policy; + this.resolutionEnvironment = resolutionEnvironment; + this.options = Objects.requireNonNull(options, "options"); } @Override protected CodeTransform createCodeTransform( ClassModel classModel, MethodModel methodModel, boolean isCheckedScope, ClassLoader loader) { + return createCodeTransform(classModel, methodModel, isCheckedScope, loader, null); + } + + private CodeTransform createCodeTransform( + ClassModel classModel, + MethodModel methodModel, + boolean isCheckedScope, + ClassLoader loader, + EnforcementTransform.IndyReturnCheckRegistry returnCheckRegistry) { return new EnforcementTransform( - planner, propertyEmitter, classModel, methodModel, isCheckedScope, loader); + planner, + propertyEmitter, + classModel, + methodModel, + isCheckedScope, + loader, + policy, + resolutionEnvironment, + options.indyBoundaryEnabled(), + true, + returnCheckRegistry); + } + + @Override + public ClassTransform asClassTransform( + ClassModel classModel, ClassLoader loader, boolean isCheckedScope) { + if (!options.indyBoundaryEnabled() || !isCheckedScope) { + return super.asClassTransform(classModel, loader, isCheckedScope); + } + + List returnFilters = new ArrayList<>(); + EnforcementTransform.IndyReturnCheckRegistry returnCheckRegistry = + newReturnFilterRegistry(classModel, returnFilters); + + if (isInterface(classModel)) { + return asCheckedInterfaceTransform( + classModel, loader, isCheckedScope, returnFilters, returnCheckRegistry); + } + + return new ClassTransform() { + @Override + public void accept(ClassBuilder classBuilder, ClassElement classElement) { + if (classElement instanceof MethodModel methodModel && methodModel.code().isPresent()) { + if (isSplitCandidate(methodModel) && !hasSafeMethodCollision(classModel, methodModel)) { + emitSplitMethodByKind( + classBuilder, classModel, methodModel, loader, returnCheckRegistry); + } else { + transformMethod( + classBuilder, classModel, methodModel, loader, isCheckedScope, returnCheckRegistry); + } + } else if (classElement instanceof MethodModel methodModel) { + classBuilder.with(classElement); + if (isAbstractClassSafeStubCandidate(methodModel) + && !hasSafeMethodCollision(classModel, methodModel)) { + emitSafeStub( + classBuilder, + methodModel, + "Checked abstract safe method has no checked implementation"); + } + } else { + classBuilder.with(classElement); + } + } + + @Override + public void atEnd(ClassBuilder builder) { + emitReturnFilterMethods(builder, returnFilters); + emitCheckedClassMarker(builder, classModel); + generateBridgeMethods(builder, classModel, loader); + } + }; + } + + private ClassTransform asCheckedInterfaceTransform( + ClassModel classModel, + ClassLoader loader, + boolean isCheckedScope, + List returnFilters, + EnforcementTransform.IndyReturnCheckRegistry returnCheckRegistry) { + return new ClassTransform() { + @Override + public void accept(ClassBuilder classBuilder, ClassElement classElement) { + if (classElement instanceof MethodModel methodModel) { + boolean hasSafeCollision = hasSafeMethodCollision(classModel, methodModel); + if (methodModel.code().isPresent()) { + if (isSplitCandidate(methodModel) && !hasSafeCollision) { + emitSplitMethodByKind( + classBuilder, classModel, methodModel, loader, returnCheckRegistry); + } else { + transformMethod( + classBuilder, + classModel, + methodModel, + loader, + isCheckedScope, + returnCheckRegistry); + } + } else { + classBuilder.with(classElement); + if (isInterfaceSafeStubCandidate(methodModel) && !hasSafeCollision) { + emitSafeStub( + classBuilder, + methodModel, + "Checked interface safe method has no checked implementation"); + } + } + } else { + classBuilder.with(classElement); + } + } + + @Override + public void atEnd(ClassBuilder builder) { + emitReturnFilterMethods(builder, returnFilters); + } + }; + } + + private void transformMethod( + ClassBuilder classBuilder, + ClassModel classModel, + MethodModel methodModel, + ClassLoader loader, + boolean isCheckedScope, + EnforcementTransform.IndyReturnCheckRegistry returnCheckRegistry) { + classBuilder.transformMethod( + methodModel, + (methodBuilder, methodElement) -> { + if (methodElement instanceof CodeAttribute codeModel) { + methodBuilder.transformCode( + codeModel, + createCodeTransform( + classModel, methodModel, isCheckedScope, loader, returnCheckRegistry)); + } else { + methodBuilder.with(methodElement); + } + }); + } + + private void emitSplitMethodByKind( + ClassBuilder builder, + ClassModel classModel, + MethodModel methodModel, + ClassLoader loader, + EnforcementTransform.IndyReturnCheckRegistry returnCheckRegistry) { + if (isBridgeSplitCandidate(methodModel)) { + emitSplitBridgeMethod(builder, classModel, methodModel, loader); + } else { + emitSplitMethod(builder, classModel, methodModel, loader, returnCheckRegistry); + } + } + + private boolean hasSafeMethodCollision(ClassModel classModel, MethodModel methodModel) { + String safeName = safeMethodName(methodModel.methodName().stringValue()); + String descriptor = methodModel.methodType().stringValue(); + return classModel.methods().stream() + .anyMatch( + candidate -> + candidate.methodName().stringValue().equals(safeName) + && candidate.methodType().stringValue().equals(descriptor)); + } + + private void emitSplitMethod( + ClassBuilder builder, + ClassModel classModel, + MethodModel methodModel, + ClassLoader loader, + EnforcementTransform.IndyReturnCheckRegistry returnCheckRegistry) { + String originalName = methodModel.methodName().stringValue(); + MethodTypeDesc desc = methodModel.methodTypeSymbol(); + int originalFlags = methodModel.flags().flagsMask(); + int safeFlags = originalFlags | AccessFlag.SYNTHETIC.mask(); + String safeName = safeMethodName(originalName); + + builder.withMethod( + safeName, + desc, + safeFlags, + safeBuilder -> { + methodModel + .code() + .ifPresent( + codeModel -> + safeBuilder.transformCode( + codeModel, + new EnforcementTransform( + planner, + propertyEmitter, + classModel, + methodModel, + true, + loader, + policy, + resolutionEnvironment, + options.indyBoundaryEnabled(), + false, + returnCheckRegistry))); + }); + + builder.withMethod( + originalName, + desc, + originalFlags, + wrapperBuilder -> { + for (MethodElement element : methodModel) { + if (!(element instanceof CodeAttribute)) { + wrapperBuilder.with(element); + } + } + wrapperBuilder.withCode( + codeBuilder -> emitWrapperBody(codeBuilder, classModel, methodModel, loader)); + }); + } + + private void emitSplitBridgeMethod( + ClassBuilder builder, ClassModel classModel, MethodModel methodModel, ClassLoader loader) { + String originalName = methodModel.methodName().stringValue(); + MethodTypeDesc desc = methodModel.methodTypeSymbol(); + int originalFlags = methodModel.flags().flagsMask(); + int safeFlags = originalFlags | AccessFlag.SYNTHETIC.mask(); + String safeName = safeMethodName(originalName); + + builder.withMethod( + safeName, + desc, + safeFlags, + safeBuilder -> { + methodModel + .code() + .ifPresent( + codeModel -> + safeBuilder.transformCode( + codeModel, new BridgeSafeTransform(methodModel, loader))); + }); + + builder.withMethod( + originalName, + desc, + originalFlags, + wrapperBuilder -> { + for (MethodElement element : methodModel) { + if (!(element instanceof CodeAttribute)) { + wrapperBuilder.with(element); + } + } + wrapperBuilder.withCode( + codeBuilder -> emitWrapperBody(codeBuilder, classModel, methodModel, loader)); + }); + } + + private void emitWrapperBody( + CodeBuilder builder, ClassModel classModel, MethodModel methodModel, ClassLoader loader) { + new EnforcementTransform( + planner, + propertyEmitter, + classModel, + methodModel, + true, + loader, + policy, + resolutionEnvironment, + options.indyBoundaryEnabled(), + false) + .emitParameterChecks(builder); + + boolean isStatic = Modifier.isStatic(methodModel.flags().flagsMask()); + if (!isStatic) { + builder.aload(0); + } + + int slotIndex = isStatic ? 0 : 1; + for (ClassDesc parameterType : methodModel.methodTypeSymbol().parameterList()) { + TypeKind type = TypeKind.from(parameterType); + loadLocal(builder, type, slotIndex); + slotIndex += type.slotSize(); + } + + ClassDesc owner = ClassDesc.ofInternalName(classModel.thisClass().asInternalName()); + String safeName = safeMethodName(methodModel.methodName().stringValue()); + if (isStatic) { + builder.invokestatic( + owner, + safeName, + methodModel.methodTypeSymbol(), + Modifier.isInterface(classModel.flags().flagsMask())); + } else if (isInterface(classModel)) { + builder.invokeinterface(owner, safeName, methodModel.methodTypeSymbol()); + } else { + builder.invokevirtual(owner, safeName, methodModel.methodTypeSymbol()); + } + returnResult( + builder, + ClassDesc.ofDescriptor(methodModel.methodTypeSymbol().returnType().descriptorString())); + } + + private void emitSafeStub(ClassBuilder builder, MethodModel methodModel, String message) { + String originalName = methodModel.methodName().stringValue(); + MethodTypeDesc desc = methodModel.methodTypeSymbol(); + int stubFlags = + (methodModel.flags().flagsMask() | AccessFlag.SYNTHETIC.mask()) + & ~AccessFlag.ABSTRACT.mask(); + + builder.withMethod( + safeMethodName(originalName), + desc, + stubFlags, + methodBuilder -> + methodBuilder.withCode( + codeBuilder -> + codeBuilder + .new_(ASSERTION_ERROR) + .dup() + .ldc(message) + .invokespecial(ASSERTION_ERROR, "", ASSERTION_ERROR_STRING_CTOR) + .athrow())); + } + + private EnforcementTransform.IndyReturnCheckRegistry newReturnFilterRegistry( + ClassModel classModel, List returnFilters) { + ClassDesc owner = ClassDesc.ofInternalName(classModel.thisClass().asInternalName()); + return (returnType, plan, location) -> { + MethodTypeDesc descriptor = MethodTypeDesc.of(returnType, returnType); + String name = nextReturnFilterName(classModel, returnFilters, descriptor); + returnFilters.add(new GeneratedReturnFilter(name, descriptor, plan, location)); + return MethodHandleDesc.ofMethod(DirectMethodHandleDesc.Kind.STATIC, owner, name, descriptor); + }; + } + + private String nextReturnFilterName( + ClassModel classModel, List returnFilters, MethodTypeDesc descriptor) { + int index = returnFilters.size(); + while (true) { + String candidate = RETURN_FILTER_PREFIX + index; + boolean existsInClass = + classModel.methods().stream() + .anyMatch( + method -> + method.methodName().stringValue().equals(candidate) + && method + .methodTypeSymbol() + .descriptorString() + .equals(descriptor.descriptorString())); + boolean existsInGenerated = + returnFilters.stream() + .anyMatch( + filter -> + filter.name().equals(candidate) + && filter + .descriptor() + .descriptorString() + .equals(descriptor.descriptorString())); + if (!existsInClass && !existsInGenerated) { + return candidate; + } + index++; + } + } + + private void emitReturnFilterMethods( + ClassBuilder builder, List returnFilters) { + for (GeneratedReturnFilter filter : returnFilters) { + builder.withMethod( + filter.name(), + filter.descriptor(), + AccessFlag.PRIVATE.mask() | AccessFlag.STATIC.mask() | AccessFlag.SYNTHETIC.mask(), + methodBuilder -> + methodBuilder.withCode( + codeBuilder -> { + if (filter.location().hasSourceLine()) { + codeBuilder.lineNumber(filter.location().sourceLine()); + } + ClassDesc returnType = filter.descriptor().returnType(); + loadLocal(codeBuilder, TypeKind.from(returnType), 0); + emitReturnFilterActions(codeBuilder, filter.plan()); + returnResult(codeBuilder, returnType); + })); + } + } + + private void emitReturnFilterActions(CodeBuilder builder, MethodPlan plan) { + for (InstrumentationAction action : plan.actions()) { + switch (action) { + case InstrumentationAction.ValueCheckAction valueCheckAction -> + emitValueCheckAction(builder, valueCheckAction); + case InstrumentationAction.LifecycleHookAction ignored -> + throw new IllegalStateException("LifecycleHookAction emission is not implemented yet"); + } + } + } + + private void emitCheckedClassMarker(ClassBuilder builder, ClassModel classModel) { + boolean markerExists = + classModel.fields().stream() + .anyMatch( + field -> + field + .fieldName() + .stringValue() + .equals(BoundaryBootstraps.CHECKED_CLASS_MARKER)); + if (!markerExists) { + builder.withField( + BoundaryBootstraps.CHECKED_CLASS_MARKER, + ClassDesc.ofDescriptor("Z"), + AccessFlag.PUBLIC.mask() | AccessFlag.STATIC.mask() | AccessFlag.SYNTHETIC.mask()); + } + } + + static boolean isSplitCandidate(MethodModel method) { + return isRegularSplitCandidate(method) || isBridgeSplitCandidate(method); + } + + private static boolean isRegularSplitCandidate(MethodModel method) { + String methodName = method.methodName().stringValue(); + int flags = method.flags().flagsMask(); + return method.code().isPresent() + && !methodName.equals("") + && !methodName.equals("") + && !methodName.contains("$runtimeframework$safe") + && !Modifier.isPrivate(flags) + && !Modifier.isNative(flags) + && !Modifier.isAbstract(flags) + && (flags & AccessFlag.BRIDGE.mask()) == 0 + && (flags & AccessFlag.SYNTHETIC.mask()) == 0; + } + + private static boolean isBridgeSplitCandidate(MethodModel method) { + String methodName = method.methodName().stringValue(); + int flags = method.flags().flagsMask(); + return method.code().isPresent() + && !methodName.equals("") + && !methodName.equals("") + && !methodName.contains("$runtimeframework$safe") + && !Modifier.isPrivate(flags) + && !Modifier.isStatic(flags) + && !Modifier.isNative(flags) + && !Modifier.isAbstract(flags) + && (flags & AccessFlag.BRIDGE.mask()) != 0 + && (flags & AccessFlag.SYNTHETIC.mask()) != 0; + } + + static boolean isInterfaceSafeStubCandidate(MethodModel method) { + return isAbstractInstanceMethodWithoutCode(method) + && Modifier.isPublic(method.flags().flagsMask()); + } + + static boolean isAbstractClassSafeStubCandidate(MethodModel method) { + return isAbstractInstanceMethodWithoutCode(method) + && !Modifier.isPrivate(method.flags().flagsMask()); + } + + private static boolean isAbstractInstanceMethodWithoutCode(MethodModel method) { + String methodName = method.methodName().stringValue(); + int flags = method.flags().flagsMask(); + return method.code().isEmpty() + && !methodName.equals("") + && !methodName.equals("") + && !methodName.contains("$runtimeframework$safe") + && Modifier.isAbstract(flags) + && !Modifier.isStatic(flags) + && !Modifier.isNative(flags) + && !Modifier.isFinal(flags) + && (flags & AccessFlag.BRIDGE.mask()) == 0 + && (flags & AccessFlag.SYNTHETIC.mask()) == 0; + } + + static String safeMethodName(String methodName) { + return methodName + "$runtimeframework$safe"; + } + + private static boolean isInterface(ClassModel classModel) { + return Modifier.isInterface(classModel.flags().flagsMask()); + } + + private record GeneratedReturnFilter( + String name, MethodTypeDesc descriptor, MethodPlan plan, BytecodeLocation location) {} + + private final class BridgeSafeTransform implements CodeTransform { + private final MethodModel bridgeMethod; + private final ClassLoader loader; + + BridgeSafeTransform(MethodModel bridgeMethod, ClassLoader loader) { + this.bridgeMethod = bridgeMethod; + this.loader = loader; + } + + @Override + public void accept(CodeBuilder builder, CodeElement element) { + if (element instanceof InvokeInstruction invoke && maybeEmitSafeForward(builder, invoke)) { + return; + } + builder.with(element); + } + + private boolean maybeEmitSafeForward(CodeBuilder builder, InvokeInstruction invoke) { + Opcode opcode = invoke.opcode(); + if (opcode != Opcode.INVOKEVIRTUAL + && opcode != Opcode.INVOKEINTERFACE + && opcode != Opcode.INVOKESPECIAL + && opcode != Opcode.INVOKESTATIC) { + return false; + } + + String methodName = invoke.name().stringValue(); + if (!methodName.equals(bridgeMethod.methodName().stringValue()) + || methodName.contains("$runtimeframework$safe") + || invoke + .typeSymbol() + .descriptorString() + .equals(bridgeMethod.methodTypeSymbol().descriptorString())) { + return false; + } + + String ownerInternalName = invoke.owner().asInternalName(); + if (policy == null || !policy.isChecked(new ClassInfo(ownerInternalName, loader, null))) { + return false; + } + + Optional safeForwardTarget = + safeForwardTarget(ownerInternalName, methodName, invoke.typeSymbol(), opcode); + if (safeForwardTarget.isEmpty()) { + return false; + } + + ResolutionEnvironment.ResolvedMethod target = safeForwardTarget.get(); + ClassDesc owner = ClassDesc.ofInternalName(target.ownerInternalName()); + String safeName = safeMethodName(methodName); + if (opcode == Opcode.INVOKESTATIC) { + builder.invokestatic( + owner, safeName, invoke.typeSymbol(), isInterface(target.ownerModel())); + } else if (opcode == Opcode.INVOKEINTERFACE) { + builder.invokeinterface(owner, safeName, invoke.typeSymbol()); + } else if (opcode == Opcode.INVOKESPECIAL) { + builder.invokespecial(owner, safeName, invoke.typeSymbol()); + } else { + builder.invokevirtual(owner, safeName, invoke.typeSymbol()); + } + return true; + } + + private Optional safeForwardTarget( + String ownerInternalName, String methodName, MethodTypeDesc descriptor, Opcode opcode) { + return resolveSafeForwardTarget(ownerInternalName, methodName, descriptor, opcode) + .filter( + method -> + policy.isChecked( + new ClassInfo(method.ownerInternalName(), loader, null), method.ownerModel())) + .filter(method -> EnforcementInstrumenter.isSplitCandidate(method.method())) + .filter(method -> methodMatchesOpcode(method.method(), opcode)); + } + + private Optional resolveSafeForwardTarget( + String ownerInternalName, String methodName, MethodTypeDesc descriptor, Opcode opcode) { + return switch (opcode) { + case INVOKEVIRTUAL -> + resolutionEnvironment.findResolvedVirtualMethod( + ownerInternalName, methodName, descriptor.descriptorString(), loader); + case INVOKEINTERFACE -> + resolutionEnvironment.findResolvedInterfaceMethod( + ownerInternalName, methodName, descriptor.descriptorString(), loader); + case INVOKESTATIC -> + resolutionEnvironment.findResolvedStaticMethod( + ownerInternalName, methodName, descriptor.descriptorString(), loader); + case INVOKESPECIAL -> + resolutionEnvironment + .loadClass(ownerInternalName, loader) + .flatMap( + model -> + resolutionEnvironment + .findDeclaredMethod( + ownerInternalName, + methodName, + descriptor.descriptorString(), + loader) + .map( + method -> + new ResolutionEnvironment.ResolvedMethod( + ownerInternalName, model, method))); + default -> Optional.empty(); + }; + } + + private boolean methodMatchesOpcode(MethodModel method, Opcode opcode) { + boolean methodIsStatic = Modifier.isStatic(method.flags().flagsMask()); + return (opcode == Opcode.INVOKESTATIC) == methodIsStatic; + } } @Override @@ -157,12 +803,8 @@ private enum BridgeActionTiming { private boolean matches(InstrumentationAction action) { return switch (this) { - case ENTRY -> - action.injectionPoint().kind() - == io.github.eisop.runtimeframework.planning.InjectionPoint.Kind.BRIDGE_ENTRY; - case EXIT -> - action.injectionPoint().kind() - == io.github.eisop.runtimeframework.planning.InjectionPoint.Kind.BRIDGE_EXIT; + case ENTRY -> action.injectionPoint().kind() == Kind.BRIDGE_ENTRY; + case EXIT -> action.injectionPoint().kind() == Kind.BRIDGE_EXIT; }; } } diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/instrumentation/EnforcementTransform.java b/framework/src/main/java/io/github/eisop/runtimeframework/instrumentation/EnforcementTransform.java index 9729dab..d9ef6af 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/instrumentation/EnforcementTransform.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/instrumentation/EnforcementTransform.java @@ -10,6 +10,10 @@ import io.github.eisop.runtimeframework.planning.MethodPlan; import io.github.eisop.runtimeframework.planning.TargetRef; import io.github.eisop.runtimeframework.policy.ClassClassification; +import io.github.eisop.runtimeframework.policy.RuntimePolicy; +import io.github.eisop.runtimeframework.resolution.ParentMethod; +import io.github.eisop.runtimeframework.resolution.ResolutionEnvironment; +import io.github.eisop.runtimeframework.runtime.BoundaryBootstraps; import io.github.eisop.runtimeframework.semantics.PropertyEmitter; import java.lang.classfile.ClassModel; import java.lang.classfile.CodeBuilder; @@ -25,9 +29,17 @@ import java.lang.classfile.instruction.LineNumber; import java.lang.classfile.instruction.ReturnInstruction; import java.lang.classfile.instruction.StoreInstruction; +import java.lang.constant.ClassDesc; +import java.lang.constant.ConstantDescs; +import java.lang.constant.DirectMethodHandleDesc; +import java.lang.constant.DynamicCallSiteDesc; +import java.lang.constant.MethodHandleDesc; import java.lang.constant.MethodTypeDesc; +import java.lang.reflect.AccessFlag; +import java.lang.reflect.Modifier; import java.util.ArrayList; import java.util.List; +import java.util.Optional; /** A CodeTransform that injects runtime checks based on an {@link EnforcementPlanner}. */ public class EnforcementTransform implements CodeTransform { @@ -36,10 +48,46 @@ public class EnforcementTransform implements CodeTransform { private final PropertyEmitter propertyEmitter; private final MethodContext methodContext; private final boolean isCheckedScope; + private final RuntimePolicy policy; + private final ResolutionEnvironment resolutionEnvironment; + private final boolean enableIndyBoundary; + private final boolean emitEntryChecks; + private final IndyReturnCheckRegistry returnCheckRegistry; private final ReferenceValueTracker valueTracker; private boolean entryChecksEmitted; private int currentBytecodeOffset; private int currentSourceLine; + private static final ClassDesc BOUNDARY_BOOTSTRAPS = + ClassDesc.of(BoundaryBootstraps.class.getName()); + private static final DirectMethodHandleDesc CHECKED_VIRTUAL_BOOTSTRAP = + MethodHandleDesc.ofMethod( + DirectMethodHandleDesc.Kind.STATIC, + BOUNDARY_BOOTSTRAPS, + "checkedVirtual", + MethodTypeDesc.of( + ConstantDescs.CD_CallSite, + ConstantDescs.CD_MethodHandles_Lookup, + ConstantDescs.CD_String, + ConstantDescs.CD_MethodType, + ConstantDescs.CD_Class, + ConstantDescs.CD_String, + ConstantDescs.CD_String, + ConstantDescs.CD_MethodType)); + private static final DirectMethodHandleDesc CHECKED_VIRTUAL_WITH_FALLBACK_RETURN_CHECK_BOOTSTRAP = + MethodHandleDesc.ofMethod( + DirectMethodHandleDesc.Kind.STATIC, + BOUNDARY_BOOTSTRAPS, + "checkedVirtualWithFallbackReturnCheck", + MethodTypeDesc.of( + ConstantDescs.CD_CallSite, + ConstantDescs.CD_MethodHandles_Lookup, + ConstantDescs.CD_String, + ConstantDescs.CD_MethodType, + ConstantDescs.CD_Class, + ConstantDescs.CD_String, + ConstantDescs.CD_String, + ConstantDescs.CD_MethodType, + ConstantDescs.CD_MethodHandle)); public EnforcementTransform( EnforcementPlanner planner, @@ -48,6 +96,79 @@ public EnforcementTransform( MethodModel methodModel, boolean isCheckedScope, ClassLoader loader) { + this( + planner, + propertyEmitter, + classModel, + methodModel, + isCheckedScope, + loader, + null, + ResolutionEnvironment.system(), + false); + } + + public EnforcementTransform( + EnforcementPlanner planner, + PropertyEmitter propertyEmitter, + ClassModel classModel, + MethodModel methodModel, + boolean isCheckedScope, + ClassLoader loader, + RuntimePolicy policy, + ResolutionEnvironment resolutionEnvironment, + boolean enableIndyBoundary) { + this( + planner, + propertyEmitter, + classModel, + methodModel, + isCheckedScope, + loader, + policy, + resolutionEnvironment, + enableIndyBoundary, + true, + null); + } + + public EnforcementTransform( + EnforcementPlanner planner, + PropertyEmitter propertyEmitter, + ClassModel classModel, + MethodModel methodModel, + boolean isCheckedScope, + ClassLoader loader, + RuntimePolicy policy, + ResolutionEnvironment resolutionEnvironment, + boolean enableIndyBoundary, + boolean emitEntryChecks) { + this( + planner, + propertyEmitter, + classModel, + methodModel, + isCheckedScope, + loader, + policy, + resolutionEnvironment, + enableIndyBoundary, + emitEntryChecks, + null); + } + + public EnforcementTransform( + EnforcementPlanner planner, + PropertyEmitter propertyEmitter, + ClassModel classModel, + MethodModel methodModel, + boolean isCheckedScope, + ClassLoader loader, + RuntimePolicy policy, + ResolutionEnvironment resolutionEnvironment, + boolean enableIndyBoundary, + boolean emitEntryChecks, + IndyReturnCheckRegistry returnCheckRegistry) { this.planner = planner; this.propertyEmitter = propertyEmitter; ClassContext classContext = @@ -57,6 +178,11 @@ public EnforcementTransform( isCheckedScope ? ClassClassification.CHECKED : ClassClassification.UNCHECKED); this.methodContext = new MethodContext(classContext, methodModel); this.isCheckedScope = isCheckedScope; + this.policy = policy; + this.resolutionEnvironment = resolutionEnvironment; + this.enableIndyBoundary = enableIndyBoundary; + this.emitEntryChecks = emitEntryChecks; + this.returnCheckRegistry = returnCheckRegistry; this.valueTracker = new ReferenceValueTracker(ownerInternalName(), methodModel); this.entryChecksEmitted = false; this.currentBytecodeOffset = 0; @@ -98,6 +224,10 @@ public void accept(CodeBuilder builder, CodeElement element) { } private boolean maybeEmitEntryChecks(CodeBuilder builder, CodeElement element) { + if (!emitEntryChecks) { + return false; + } + if (entryChecksEmitted) { return false; } @@ -169,18 +299,202 @@ private void handleReturn(CodeBuilder b, ReturnInstruction r, BytecodeLocation l } private void handleInvoke(CodeBuilder b, InvokeInstruction i, BytecodeLocation location) { - b.with(i); + boolean rewritten = maybeEmitCheckedBoundaryCall(b, i, location); + if (!rewritten) { + b.with(i); + } if (isCheckedScope) { FlowEvent.BoundaryCallReturn event = - new FlowEvent.BoundaryCallReturn( - methodContext, - location, - new TargetRef.InvokedMethod( - i.owner().asInternalName(), i.name().stringValue(), i.typeSymbol())); + new FlowEvent.BoundaryCallReturn(methodContext, location, returnBoundaryTarget(i)); emitPlannedActions(b, event, ActionTiming.AFTER_INSTRUCTION); } } + private boolean maybeEmitCheckedBoundaryCall( + CodeBuilder builder, InvokeInstruction instruction, BytecodeLocation location) { + if (!enableIndyBoundary || !isCheckedScope || policy == null) { + return false; + } + + Opcode opcode = instruction.opcode(); + if (opcode != Opcode.INVOKEVIRTUAL + && opcode != Opcode.INVOKESTATIC + && opcode != Opcode.INVOKEINTERFACE) { + return false; + } + + String methodName = instruction.name().stringValue(); + if (methodName.equals("") || methodName.contains("$runtimeframework$safe")) { + return false; + } + + String ownerInternalName = instruction.owner().asInternalName(); + ClassInfo ownerInfo = + new ClassInfo(ownerInternalName, methodContext.classContext().classInfo().loader(), null); + if (!policy.isChecked(ownerInfo)) { + return false; + } + + Optional resolvedTarget = + resolveBoundaryTarget(ownerInternalName, methodName, instruction.typeSymbol(), opcode); + if (resolvedTarget.isEmpty()) { + return false; + } + + ClassDesc invocationOwnerDesc = ClassDesc.ofInternalName(ownerInternalName); + ClassDesc targetOwnerDesc = ClassDesc.ofInternalName(resolvedTarget.get().ownerInternalName()); + if (opcode == Opcode.INVOKESTATIC) { + builder.invokestatic( + targetOwnerDesc, + EnforcementInstrumenter.safeMethodName(methodName), + instruction.typeSymbol(), + instruction.isInterface()); + return true; + } + + MethodTypeDesc invocationType = + instruction.typeSymbol().insertParameterTypes(0, invocationOwnerDesc); + MethodPlan fallbackReturnPlan = + planner.planUncheckedReceiverFallbackReturn( + methodContext, + location, + new TargetRef.InvokedMethod( + resolvedTarget.get().ownerInternalName(), methodName, instruction.typeSymbol())); + if (fallbackReturnPlan.isEmpty() || returnCheckRegistry == null) { + builder.invokedynamic( + DynamicCallSiteDesc.of( + CHECKED_VIRTUAL_BOOTSTRAP, + methodName, + invocationType, + targetOwnerDesc, + methodName, + EnforcementInstrumenter.safeMethodName(methodName), + instruction.typeSymbol())); + return true; + } + + MethodHandleDesc fallbackReturnFilter = + returnCheckRegistry.register( + instruction.typeSymbol().returnType(), fallbackReturnPlan, location); + builder.invokedynamic( + DynamicCallSiteDesc.of( + CHECKED_VIRTUAL_WITH_FALLBACK_RETURN_CHECK_BOOTSTRAP, + methodName, + invocationType, + targetOwnerDesc, + methodName, + EnforcementInstrumenter.safeMethodName(methodName), + instruction.typeSymbol(), + fallbackReturnFilter)); + return true; + } + + private Optional resolveBoundaryTarget( + String ownerInternalName, String methodName, MethodTypeDesc descriptor, Opcode opcode) { + ClassLoader loader = methodContext.classContext().classInfo().loader(); + return resolveInvokeTarget(ownerInternalName, methodName, descriptor, opcode) + .filter( + method -> + policy.isChecked( + new ClassInfo(method.ownerInternalName(), loader, null), method.ownerModel())) + .filter(method -> targetMatchesCallOpcode(method.method(), opcode)); + } + + private TargetRef.InvokedMethod returnBoundaryTarget(InvokeInstruction instruction) { + String ownerInternalName = instruction.owner().asInternalName(); + String methodName = instruction.name().stringValue(); + MethodTypeDesc descriptor = instruction.typeSymbol(); + String resolvedOwner = + resolveInvokeTarget(ownerInternalName, methodName, descriptor, instruction.opcode()) + .filter(resolved -> !generatedBridgeWillHandle(ownerInternalName, resolved)) + .map(ResolutionEnvironment.ResolvedMethod::ownerInternalName) + .orElse(ownerInternalName); + return new TargetRef.InvokedMethod(resolvedOwner, methodName, descriptor); + } + + private boolean generatedBridgeWillHandle( + String ownerInternalName, ResolutionEnvironment.ResolvedMethod resolved) { + if (ownerInternalName.equals(resolved.ownerInternalName())) { + return false; + } + + ClassLoader loader = methodContext.classContext().classInfo().loader(); + if (policy == null + || policy.isChecked( + new ClassInfo(resolved.ownerInternalName(), loader, null), resolved.ownerModel()) + || Modifier.isInterface(resolved.ownerModel().flags().flagsMask()) + || !isGeneratedBridgeCandidate(resolved.method())) { + return false; + } + + return resolutionEnvironment + .loadClass(ownerInternalName, loader) + .filter(model -> policy.isChecked(new ClassInfo(ownerInternalName, loader, null), model)) + .filter(model -> !Modifier.isInterface(model.flags().flagsMask())) + .map( + model -> + planner.shouldGenerateBridge( + new ClassContext( + new ClassInfo(ownerInternalName, loader, null), + model, + ClassClassification.CHECKED), + new ParentMethod(resolved.ownerModel(), resolved.method()))) + .orElse(false); + } + + private boolean isGeneratedBridgeCandidate(MethodModel method) { + int flags = method.flags().flagsMask(); + return !Modifier.isPrivate(flags) + && !Modifier.isStatic(flags) + && !Modifier.isFinal(flags) + && (flags & AccessFlag.SYNTHETIC.mask()) == 0 + && (flags & AccessFlag.BRIDGE.mask()) == 0; + } + + private Optional resolveInvokeTarget( + String ownerInternalName, String methodName, MethodTypeDesc descriptor, Opcode opcode) { + ClassLoader loader = methodContext.classContext().classInfo().loader(); + return switch (opcode) { + case INVOKEVIRTUAL -> + resolutionEnvironment.findResolvedVirtualMethod( + ownerInternalName, methodName, descriptor.descriptorString(), loader); + case INVOKEINTERFACE -> + resolutionEnvironment.findResolvedInterfaceMethod( + ownerInternalName, methodName, descriptor.descriptorString(), loader); + case INVOKESTATIC -> + resolutionEnvironment.findResolvedStaticMethod( + ownerInternalName, methodName, descriptor.descriptorString(), loader); + case INVOKESPECIAL -> + resolutionEnvironment + .loadClass(ownerInternalName, loader) + .flatMap( + model -> + resolutionEnvironment + .findDeclaredMethod( + ownerInternalName, methodName, descriptor.descriptorString(), loader) + .map( + method -> + new ResolutionEnvironment.ResolvedMethod( + ownerInternalName, model, method))); + default -> Optional.empty(); + }; + } + + private boolean targetMatchesCallOpcode(MethodModel target, Opcode opcode) { + boolean targetIsStatic = Modifier.isStatic(target.flags().flagsMask()); + if (opcode == Opcode.INVOKEINTERFACE) { + return !targetIsStatic + && (EnforcementInstrumenter.isSplitCandidate(target) + || EnforcementInstrumenter.isInterfaceSafeStubCandidate(target)); + } + if (!EnforcementInstrumenter.isSplitCandidate(target)) { + return opcode == Opcode.INVOKEVIRTUAL + && !targetIsStatic + && EnforcementInstrumenter.isAbstractClassSafeStubCandidate(target); + } + return (opcode == Opcode.INVOKESTATIC) == targetIsStatic; + } + private void handleArrayStore(CodeBuilder b, ArrayStoreInstruction a, BytecodeLocation location) { if (a.opcode() == Opcode.AASTORE) { FlowEvent.ArrayStore event = @@ -305,6 +619,10 @@ private String ownerInternalName() { return methodContext.classContext().classInfo().internalName(); } + interface IndyReturnCheckRegistry { + MethodHandleDesc register(ClassDesc returnType, MethodPlan plan, BytecodeLocation location); + } + private enum ActionTiming { METHOD_ENTRY, BEFORE_INSTRUCTION, diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/planning/ContractEnforcementPlanner.java b/framework/src/main/java/io/github/eisop/runtimeframework/planning/ContractEnforcementPlanner.java index 05bae11..481ce4c 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/planning/ContractEnforcementPlanner.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/planning/ContractEnforcementPlanner.java @@ -53,6 +53,21 @@ public boolean shouldGenerateBridge(ClassContext classContext, ParentMethod pare return !planBridge(classContext, parentMethod).isEmpty(); } + @Override + public MethodPlan planUncheckedReceiverFallbackReturn( + MethodContext methodContext, BytecodeLocation location, TargetRef.InvokedMethod target) { + ResolutionContext resolutionContext = + ResolutionContext.forMethod(methodContext, resolutionEnvironment); + return new MethodPlan( + planResolvedTarget( + target, + resolutionContext, + InjectionPoint.afterInstruction(location.bytecodeIndex()), + new ValueAccess.OperandStack(0), + AttributionKind.LOCAL, + DiagnosticSpec.of("Return value of " + target.methodName() + " (Boundary)"))); + } + @Override public BridgePlan planBridge(ClassContext classContext, ParentMethod parentMethod) { ResolutionContext resolutionContext = diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/planning/EnforcementPlanner.java b/framework/src/main/java/io/github/eisop/runtimeframework/planning/EnforcementPlanner.java index 2e514ba..52237b0 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/planning/EnforcementPlanner.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/planning/EnforcementPlanner.java @@ -12,6 +12,9 @@ default MethodPlan planMethod(MethodContext methodContext, FlowEvent... events) return planMethod(methodContext, List.of(events)); } + MethodPlan planUncheckedReceiverFallbackReturn( + MethodContext methodContext, BytecodeLocation location, TargetRef.InvokedMethod target); + boolean shouldGenerateBridge(ClassContext classContext, ParentMethod parentMethod); BridgePlan planBridge(ClassContext classContext, ParentMethod parentMethod); diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/resolution/ResolutionEnvironment.java b/framework/src/main/java/io/github/eisop/runtimeframework/resolution/ResolutionEnvironment.java index f8f2f35..37dd323 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/resolution/ResolutionEnvironment.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/resolution/ResolutionEnvironment.java @@ -5,8 +5,12 @@ import java.lang.classfile.Label; import java.lang.classfile.MethodModel; import java.lang.classfile.TypeAnnotation; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Optional; +import java.util.Set; /** * Shared environment for bytecode metadata lookup. @@ -51,6 +55,221 @@ default Optional findDeclaredMethod( .findFirst()); } + default Optional findResolvedVirtualMethod( + String ownerInternalName, String methodName, String descriptor, ClassLoader loader) { + List hierarchy = new ArrayList<>(); + Optional current = loadClass(ownerInternalName, loader); + while (current.isPresent()) { + ClassModel model = current.get(); + hierarchy.add(model); + Optional method = findMethod(model, methodName, descriptor); + if (method.isPresent()) { + return Optional.of( + new ResolvedMethod(model.thisClass().asInternalName(), model, method.get())); + } + current = loadSuperclass(model, loader); + } + + List interfaceCandidates = new ArrayList<>(); + Set visitedInterfaces = new HashSet<>(); + for (ClassModel model : hierarchy) { + collectResolvedInterfaceMethodsFromClass( + model, methodName, descriptor, loader, visitedInterfaces, interfaceCandidates); + } + + return selectMaximallySpecificDefault(interfaceCandidates, loader); + } + + default Optional findResolvedStaticMethod( + String ownerInternalName, String methodName, String descriptor, ClassLoader loader) { + Optional current = loadClass(ownerInternalName, loader); + while (current.isPresent()) { + ClassModel model = current.get(); + Optional method = + findMethod(model, methodName, descriptor) + .filter(candidate -> Modifier.isStatic(candidate.flags().flagsMask())); + if (method.isPresent()) { + return Optional.of( + new ResolvedMethod(model.thisClass().asInternalName(), model, method.get())); + } + if (Modifier.isInterface(model.flags().flagsMask())) { + return Optional.empty(); + } + current = loadSuperclass(model, loader); + } + return Optional.empty(); + } + + default Optional findResolvedInterfaceMethod( + String ownerInternalName, String methodName, String descriptor, ClassLoader loader) { + return loadClass(ownerInternalName, loader) + .flatMap( + model -> + findResolvedInterfaceMethod( + model, methodName, descriptor, loader, new HashSet<>())); + } + + private Optional findResolvedInterfaceMethod( + ClassModel model, + String methodName, + String descriptor, + ClassLoader loader, + Set visited) { + String internalName = model.thisClass().asInternalName(); + if (!visited.add(internalName)) { + return Optional.empty(); + } + + Optional method = findMethod(model, methodName, descriptor); + if (method.isPresent()) { + return Optional.of(new ResolvedMethod(internalName, model, method.get())); + } + + for (var parent : model.interfaces()) { + Optional resolved = + loadClass(parent.asInternalName(), loader) + .flatMap( + parentModel -> + findResolvedInterfaceMethod( + parentModel, methodName, descriptor, loader, visited)); + if (resolved.isPresent()) { + return resolved; + } + } + return Optional.empty(); + } + + private Optional findMethod(ClassModel model, String methodName, String descriptor) { + return model.methods().stream() + .filter(method -> method.methodName().stringValue().equals(methodName)) + .filter(method -> method.methodTypeSymbol().descriptorString().equals(descriptor)) + .findFirst(); + } + + private void collectResolvedInterfaceMethodsFromClass( + ClassModel classModel, + String methodName, + String descriptor, + ClassLoader loader, + Set visitedInterfaces, + List candidates) { + for (var interfaceEntry : classModel.interfaces()) { + loadClass(interfaceEntry.asInternalName(), loader) + .ifPresent( + interfaceModel -> + collectResolvedInterfaceMethods( + interfaceModel, + methodName, + descriptor, + loader, + visitedInterfaces, + candidates)); + } + } + + private void collectResolvedInterfaceMethods( + ClassModel interfaceModel, + String methodName, + String descriptor, + ClassLoader loader, + Set visitedInterfaces, + List candidates) { + String internalName = interfaceModel.thisClass().asInternalName(); + if (!visitedInterfaces.add(internalName)) { + return; + } + + Optional candidate = findMethod(interfaceModel, methodName, descriptor); + if (candidate.isPresent() && isInterfaceInstanceMethod(candidate.get())) { + candidates.add(new ResolvedMethod(internalName, interfaceModel, candidate.get())); + } + + for (var parent : interfaceModel.interfaces()) { + loadClass(parent.asInternalName(), loader) + .ifPresent( + parentModel -> + collectResolvedInterfaceMethods( + parentModel, methodName, descriptor, loader, visitedInterfaces, candidates)); + } + } + + private Optional selectMaximallySpecificDefault( + List candidates, ClassLoader loader) { + Optional selected = Optional.empty(); + for (ResolvedMethod candidate : maximallySpecific(candidates, loader)) { + if (!isInterfaceDefaultMethod(candidate.method())) { + continue; + } + if (selected.isPresent()) { + return Optional.empty(); + } + selected = Optional.of(candidate); + } + return selected; + } + + private List maximallySpecific( + List candidates, ClassLoader loader) { + List maximallySpecific = new ArrayList<>(); + for (ResolvedMethod candidate : candidates) { + if (isLessSpecificThanAnotherCandidate(candidate, candidates, loader)) { + continue; + } + maximallySpecific.add(candidate); + } + return maximallySpecific; + } + + private boolean isLessSpecificThanAnotherCandidate( + ResolvedMethod candidate, List candidates, ClassLoader loader) { + for (ResolvedMethod other : candidates) { + if (!candidate.ownerInternalName().equals(other.ownerInternalName()) + && interfaceExtends( + other.ownerInternalName(), candidate.ownerInternalName(), loader, new HashSet<>())) { + return true; + } + } + return false; + } + + private boolean interfaceExtends( + String childInternalName, + String parentInternalName, + ClassLoader loader, + Set visited) { + if (childInternalName.equals(parentInternalName)) { + return true; + } + if (!visited.add(childInternalName)) { + return false; + } + Optional child = loadClass(childInternalName, loader); + if (child.isEmpty()) { + return false; + } + for (var parent : child.get().interfaces()) { + String parentName = parent.asInternalName(); + if (parentName.equals(parentInternalName) + || interfaceExtends(parentName, parentInternalName, loader, visited)) { + return true; + } + } + return false; + } + + private boolean isInterfaceInstanceMethod(MethodModel method) { + int flags = method.flags().flagsMask(); + return !Modifier.isStatic(flags) && !Modifier.isPrivate(flags); + } + + private boolean isInterfaceDefaultMethod(MethodModel method) { + int flags = method.flags().flagsMask(); + return method.code().isPresent() + && !Modifier.isStatic(flags) + && !Modifier.isPrivate(flags) + && !Modifier.isAbstract(flags); + } + /** * Returns local-variable type annotations for a specific slot. * @@ -99,6 +318,8 @@ public boolean contains(int bytecodeOffset, MethodModel method) { } } + record ResolvedMethod(String ownerInternalName, ClassModel ownerModel, MethodModel method) {} + final class Holder { private static final ResolutionEnvironment INSTANCE = new CachingResolutionEnvironment(); diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/runtime/BoundaryBootstraps.java b/framework/src/main/java/io/github/eisop/runtimeframework/runtime/BoundaryBootstraps.java new file mode 100644 index 0000000..957c2fc --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/runtime/BoundaryBootstraps.java @@ -0,0 +1,254 @@ +package io.github.eisop.runtimeframework.runtime; + +import java.lang.invoke.CallSite; +import java.lang.invoke.ConstantCallSite; +import java.lang.invoke.MethodHandle; +import java.lang.invoke.MethodHandles; +import java.lang.invoke.MethodType; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** Bootstrap methods used by invokedynamic. */ +public final class BoundaryBootstraps { + + public static final String CHECKED_CLASS_MARKER = "$runtimeframework$checked"; + + private static final MethodHandles.Lookup LOOKUP = MethodHandles.lookup(); + + private static final ClassValue CHECKED_CLASSES = + new ClassValue<>() { + @Override + protected Boolean computeValue(Class type) { + try { + Field marker = type.getDeclaredField(CHECKED_CLASS_MARKER); + int modifiers = marker.getModifiers(); + return marker.getType() == boolean.class && Modifier.isStatic(modifiers); + } catch (NoSuchFieldException ignored) { + return false; + } + } + }; + + private BoundaryBootstraps() {} + + public static CallSite checkedVirtual( + MethodHandles.Lookup callerLookup, + String invokedName, + MethodType invokedType, + Class owner, + String originalName, + String safeName, + MethodType originalType) + throws NoSuchMethodException, IllegalAccessException { + MethodHandle safe = callerLookup.findVirtual(owner, safeName, originalType).asType(invokedType); + MethodHandle original = + callerLookup.findVirtual(owner, originalName, originalType).asType(invokedType); + MethodHandle test = safeDispatchTest(owner, originalName, safeName, originalType, invokedType); + + return new ConstantCallSite(MethodHandles.guardWithTest(test, safe, original)); + } + + public static CallSite checkedVirtualWithFallbackReturnCheck( + MethodHandles.Lookup callerLookup, + String invokedName, + MethodType invokedType, + Class owner, + String originalName, + String safeName, + MethodType originalType, + MethodHandle fallbackReturnFilter) + throws NoSuchMethodException, IllegalAccessException { + MethodHandle safe = callerLookup.findVirtual(owner, safeName, originalType).asType(invokedType); + MethodHandle original = callerLookup.findVirtual(owner, originalName, originalType); + original = MethodHandles.filterReturnValue(original, fallbackReturnFilter).asType(invokedType); + MethodHandle test = safeDispatchTest(owner, originalName, safeName, originalType, invokedType); + + return new ConstantCallSite(MethodHandles.guardWithTest(test, safe, original)); + } + + public static boolean isCheckedReceiver(Object receiver) { + return receiver != null && CHECKED_CLASSES.get(receiver.getClass()); + } + + private static MethodHandle safeDispatchTest( + Class owner, + String originalName, + String safeName, + MethodType originalType, + MethodType invokedType) + throws NoSuchMethodException, IllegalAccessException { + SafeDispatchGuard guard = new SafeDispatchGuard(owner, originalName, safeName, originalType); + MethodHandle test = + LOOKUP + .findVirtual( + SafeDispatchGuard.class, "test", MethodType.methodType(boolean.class, Object.class)) + .bindTo(guard); + + test = test.asType(MethodType.methodType(boolean.class, invokedType.parameterType(0))); + if (invokedType.parameterCount() > 1) { + test = + MethodHandles.dropArguments( + test, 1, invokedType.parameterList().subList(1, invokedType.parameterCount())); + } + return test; + } + + private record DispatchTarget(Class owner, Method method) {} + + private static final class SafeDispatchGuard { + private final Class owner; + private final String originalName; + private final String safeName; + private final MethodType originalType; + private final ClassValue safeDispatchClasses = + new ClassValue<>() { + @Override + protected Boolean computeValue(Class receiverClass) { + return computeSafeDispatch(receiverClass); + } + }; + + SafeDispatchGuard( + Class owner, String originalName, String safeName, MethodType originalType) { + this.owner = owner; + this.originalName = originalName; + this.safeName = safeName; + this.originalType = originalType; + } + + @SuppressWarnings("UnusedMethod") + boolean test(Object receiver) { + return receiver != null && safeDispatchClasses.get(receiver.getClass()); + } + + private boolean computeSafeDispatch(Class receiverClass) { + if (!CHECKED_CLASSES.get(receiverClass)) { + return false; + } + + DispatchTarget original = dispatchTarget(receiverClass, originalName); + DispatchTarget safe = dispatchTarget(receiverClass, safeName); + if (original == null || safe == null) { + return false; + } + + Method safeMethod = safe.method(); + int safeModifiers = safeMethod.getModifiers(); + return original.owner() == safe.owner() + && safeMethod.isSynthetic() + && !Modifier.isAbstract(safeModifiers) + && !Modifier.isNative(safeModifiers) + && !Modifier.isStatic(safeModifiers); + } + + private DispatchTarget dispatchTarget(Class receiverClass, String name) { + DispatchTarget classTarget = classDispatchTarget(receiverClass, name); + if (classTarget != null || !owner.isInterface()) { + return classTarget; + } + return interfaceDefaultTarget(receiverClass, name); + } + + private DispatchTarget classDispatchTarget(Class receiverClass, String name) { + for (Class current = receiverClass; current != null; current = current.getSuperclass()) { + Method method = declaredMethod(current, name); + if (method != null && isInstanceDispatchMethod(method)) { + return new DispatchTarget(current, method); + } + } + return null; + } + + private DispatchTarget interfaceDefaultTarget(Class receiverClass, String name) { + List candidates = new ArrayList<>(); + Set> visited = new HashSet<>(); + for (Class current = receiverClass; current != null; current = current.getSuperclass()) { + for (Class candidate : current.getInterfaces()) { + collectInterfaceTargets(candidate, name, visited, candidates); + } + } + collectInterfaceTargets(owner, name, visited, candidates); + return selectMaximallySpecificDefault(candidates); + } + + private void collectInterfaceTargets( + Class interfaceClass, + String name, + Set> visited, + List candidates) { + if (!interfaceClass.isInterface() || !visited.add(interfaceClass)) { + return; + } + + Method method = declaredMethod(interfaceClass, name); + if (method != null && isInstanceDispatchMethod(method)) { + candidates.add(new DispatchTarget(interfaceClass, method)); + } + + for (Class parent : interfaceClass.getInterfaces()) { + collectInterfaceTargets(parent, name, visited, candidates); + } + } + + private DispatchTarget selectMaximallySpecificDefault(List candidates) { + DispatchTarget selected = null; + for (DispatchTarget candidate : maximallySpecific(candidates)) { + if (!candidate.method().isDefault()) { + continue; + } + if (selected != null) { + return null; + } + selected = candidate; + } + return selected; + } + + private List maximallySpecific(List candidates) { + List maximallySpecific = new ArrayList<>(); + for (DispatchTarget candidate : candidates) { + if (isLessSpecificThanAnotherCandidate(candidate, candidates)) { + continue; + } + maximallySpecific.add(candidate); + } + return maximallySpecific; + } + + private boolean isLessSpecificThanAnotherCandidate( + DispatchTarget candidate, List candidates) { + for (DispatchTarget other : candidates) { + if (!candidate.equals(other) && candidate.owner().isAssignableFrom(other.owner())) { + return true; + } + } + return false; + } + + private Method declaredMethod(Class declaringClass, String name) { + for (Method method : declaringClass.getDeclaredMethods()) { + if (matches(method, name)) { + return method; + } + } + return null; + } + + private boolean matches(Method method, String name) { + return method.getName().equals(name) + && method.getReturnType() == originalType.returnType() + && Arrays.equals(method.getParameterTypes(), originalType.parameterArray()); + } + + private boolean isInstanceDispatchMethod(Method method) { + int modifiers = method.getModifiers(); + return !Modifier.isStatic(modifiers) && !Modifier.isPrivate(modifiers); + } + } +} diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/runtime/RuntimeVerifier.java b/framework/src/main/java/io/github/eisop/runtimeframework/runtime/RuntimeVerifier.java index 124be27..1f804e4 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/runtime/RuntimeVerifier.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/runtime/RuntimeVerifier.java @@ -1,5 +1,7 @@ package io.github.eisop.runtimeframework.runtime; +import io.github.eisop.runtimeframework.config.RuntimeOptions; + /** * The abstract base class for all runtime verifiers. * @@ -11,19 +13,18 @@ public abstract class RuntimeVerifier { private static volatile ViolationHandler handler; static { - // 1. Try to load from System Property - String handlerClass = System.getProperty("runtime.handler"); - if (handlerClass != null && !handlerClass.isBlank()) { + RuntimeOptions options = RuntimeOptions.fromSystemProperties(); + if (options.hasHandlerClassName()) { try { - Class clazz = Class.forName(handlerClass); + Class clazz = Class.forName(options.handlerClassName()); handler = (ViolationHandler) clazz.getConstructor().newInstance(); } catch (Exception e) { - System.err.println("[RuntimeFramework] Failed to instantiate handler: " + handlerClass); + System.err.println( + "[RuntimeFramework] Failed to instantiate handler: " + options.handlerClassName()); e.printStackTrace(); } } - // 2. Fallback to Default if (handler == null) { handler = new ThrowingViolationHandler(); } diff --git a/test-utils/src/main/java/io/github/eisop/testutils/AgentTestHarness.java b/test-utils/src/main/java/io/github/eisop/testutils/AgentTestHarness.java index a84c717..ebb5299 100644 --- a/test-utils/src/main/java/io/github/eisop/testutils/AgentTestHarness.java +++ b/test-utils/src/main/java/io/github/eisop/testutils/AgentTestHarness.java @@ -1,5 +1,6 @@ package io.github.eisop.testutils; +import io.github.eisop.runtimeframework.config.RuntimeOptions; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; @@ -138,7 +139,7 @@ protected TestResult runAgent(String mainClass, boolean isGlobal, String... agen cmd.add("-javaagent:" + frameworkJar.toAbsolutePath()); if (isGlobal) { - cmd.add("-Druntime.global=true"); + cmd.add(systemProperty(RuntimeOptions.GLOBAL_MODE_PROPERTY, true)); } cmd.addAll(List.of(agentArgs)); @@ -189,5 +190,13 @@ private TestResult runProcess(List cmd, String taskName) throws Exceptio return new TestResult(p.exitValue(), stdout, stderr); } + protected static String systemProperty(String name, boolean value) { + return systemProperty(name, Boolean.toString(value)); + } + + protected static String systemProperty(String name, String value) { + return "-D" + name + "=" + value; + } + protected record TestResult(int exitCode, String stdout, String stderr) {} } diff --git a/test-utils/src/main/java/io/github/eisop/testutils/RuntimeTestRunner.java b/test-utils/src/main/java/io/github/eisop/testutils/RuntimeTestRunner.java index 692b5de..edd1653 100644 --- a/test-utils/src/main/java/io/github/eisop/testutils/RuntimeTestRunner.java +++ b/test-utils/src/main/java/io/github/eisop/testutils/RuntimeTestRunner.java @@ -1,5 +1,6 @@ package io.github.eisop.testutils; +import io.github.eisop.runtimeframework.config.RuntimeOptions; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; @@ -14,6 +15,8 @@ public class RuntimeTestRunner extends AgentTestHarness { private static final Pattern ERROR_PATTERN = Pattern.compile("//\\s*::\\s*error:\\s*\\((.*)\\)"); + private static final Pattern PACKAGE_PATTERN = + Pattern.compile("(?m)^\\s*package\\s+([\\w.]+)\\s*;"); public void runDirectoryTest(String dirName, String checkerClass, boolean isGlobal) throws Exception { @@ -79,19 +82,27 @@ private void runSingleTest( } String filename = mainSource.getFileName().toString(); - String mainClass = filename.replace(".java", ""); + String mainClass = mainClassName(mainSource); TestResult result = runAgent( mainClass, isGlobal, - "-Druntime.checker=" + checkerClass, - "-Druntime.trustAnnotatedFor=true", - "-Druntime.handler=io.github.eisop.testutils.TestViolationHandler"); + systemProperty(RuntimeOptions.CHECKER_CLASS_PROPERTY, checkerClass), + systemProperty(RuntimeOptions.TRUST_ANNOTATED_FOR_PROPERTY, true), + systemProperty( + RuntimeOptions.HANDLER_CLASS_PROPERTY, + "io.github.eisop.testutils.TestViolationHandler")); verifyErrors(expectedErrors, result.stdout(), filename); } + private String mainClassName(Path mainSource) throws IOException { + String simpleName = mainSource.getFileName().toString().replace(".java", ""); + Matcher matcher = PACKAGE_PATTERN.matcher(Files.readString(mainSource)); + return matcher.find() ? matcher.group(1) + "." + simpleName : simpleName; + } + private List parseExpectedErrors(Path sourceFile) throws IOException { String fileName = sourceFile.getFileName().toString(); List lines = Files.readAllLines(sourceFile);