diff --git a/.gitignore b/.gitignore index 1b6985c..103c78d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,22 @@ # Ignore Gradle build output directory build + +# Example Classfiles +examples/global-policy/global/ +examples/standard-policy/standard/ + +# Eclipse +.metadata +.classpath +.project +.externalToolBuilders +.settings +**/*.factorypath + +# Environment setup +.direnv/ +.envrc + +# bins +framework/bin/ \ No newline at end of file diff --git a/README.md b/README.md deleted file mode 100644 index fb33c9f..0000000 --- a/README.md +++ /dev/null @@ -1,2 +0,0 @@ -# runtime-framework -Runtime tooling for Java diff --git a/README.org b/README.org new file mode 100644 index 0000000..6ca51f8 --- /dev/null +++ b/README.org @@ -0,0 +1,103 @@ +#+TITLE: Runtime Framework +* About + +A Java Agent-based framework designed to enforce pluggable type systems at runtime. By instrumenting bytecode at load-time, the framework ensures that type contracts (such as @NonNull) are respected at the boundary between annotated and unannotated code. This provides a traceable safety net when integrating code verified by tools like the [[https://github.com/eisop/checker-framework][EISOP Checker Framework]] with legacy or unchecked external libraries. + +** Project Structure + +- ~framework/~: The core agent and instrumentation logic. + - ~agent~: The Java Agent entry point and transformer. + - ~core~: The main instrumentation engine + - ~policy~: Defines /when/ checks are injected. + - Currently, there are two defined policies: a *Standard Policy* and a *Global Policy*. + - The **Standard Policy** instruments only code that has been marked as "Checked". + - The **Global Policy** extends this to instrument Unchecked code as well. + - ~resolution~: Handles class hierarchy analysis for bridge methods. + - ~filter~: Controls which classes are instrumented. + - ~runtime~: The runtime library that instrumented code calls. +- ~checker/~: Concrete implementations of type systems (currently Nullness). +- ~test-utils/~: Shared testing infrastructure for writing integration tests. + +** Requirements + +This project requires JDK25+. + +** Status + +This project is in early development and will still go through many breaking changes regarding API and configuration. Its overall design goal is to be flexible enough to support any pluggable type system but is currently focused on Nullness. + +* Building the Project + +This project uses Gradle 9.0. To build the agent and runtime libraries: + +#+begin_src bash +./gradlew build +#+end_src + +Note: This will also run the test suite located under ~checker/src/test/java/~ (executing integration tests via ~NullnessDirectoryTest~). + +This will generate the artifacts in ~build/dist/~: +- ~framework.jar~ (The Java Agent) +- ~checker.jar~ (The Runtime Nullness Checker) +- ~test-utils.jar~ (Testing helpers) +- ~checker-qual.jar~ (Annotations) + +* Usage + +To use the agent, you need to launch your Java application with the ~-javaagent~ flag and configure the classpath. + +** 1. Standard Policy Example + +There are two examples under ~examples/~ that demonstrate basic usage. These examples catch exceptions to print success messages instead of crashing, allowing you to see multiple violations in one run while still using the exception handler. + +The **Standard Policy** example demonstrates instrumentation on only checked code, where checked code is defined as code within the scope of an ~AnnotatedFor~ annotation. This policy is limited insofar as it will not instrument code outside of an ~AnnotatedFor~ scope, leaving field wrties from unchecked to checked code and the checked parent, unchecked child relationship unaccounted for. However, limiting checks to only a checked scope could be prefered in some situations. + +To compile the standard policy example: + +#+BEGIN_SRC bash + javac -cp 'build/dist/*' -d examples/standard-policy/ examples/standard-policy/*.java +#+END_SRC + +To run the example: + +#+BEGIN_SRC bash + java \ + -javaagent:build/dist/framework.jar \ + -Druntime.checker=io.github.eisop.runtimeframework.checker.nullness.NullnessRuntimeChecker \ + -Druntime.trustAnnotatedFor=true \ + -cp 'examples/standard-policy:build/dist/*' \ + standard.StandardDemo +#+END_SRC + + +** 2. Global Policy Example + +The global policy instruments both checked and unchecked code. It applies all the same checks present in the standard policy, but now field writes from unchecked -> checked and Checked Parent Unchecked child relationships can be handled accordingly. + +To compile the global policy example: + +#+BEGIN_SRC bash + javac -cp 'build/dist/*' -d examples/global-policy/ examples/global-policy/*.java +#+END_SRC + +To run the example: + +#+begin_src bash + java \ + -javaagent:build/dist/framework.jar \ + -Druntime.global=true \ + -Druntime.checker=io.github.eisop.runtimeframework.checker.nullness.NullnessRuntimeChecker \ + -Druntime.trustAnnotatedFor=true \ + -cp 'examples/global-policy:build/dist/*' \ + global.GlobalDemo +#+end_src + +** Configuration Properties + +| Property | Description | Example | +|-----------------------------+-------------------------------------------------------------------------+--------------------------------------------------| +| ~runtime.checker~ | Fully qualified class name of the ~RuntimeChecker~ implementation. | ~...checker.nullness.NullnessRuntimeChecker~ | +| ~runtime.classes~ | Comma-separated list of classes to treat as "Checked" (Safe). | ~com.app.Main,com.app.Utils~ | +| ~runtime.global~ | ~true~ to enable Global Policy (scans all classes for external writes). | ~true~ | +| ~runtime.trustAnnotatedFor~ | ~true~ to automatically treat classes with ~@AnnotatedFor~ as Checked. | ~true~ | +| ~runtime.handler~ | ~ViolationHandler~ class to use (defaults to Throwing). | ~io.github.eisop.testutils.TestViolationHandler~ | diff --git a/build.gradle b/build.gradle index 2f15953..da83611 100644 --- a/build.gradle +++ b/build.gradle @@ -1,15 +1,27 @@ plugins { + id 'base' id 'net.ltgt.errorprone' version '4.3.0' apply false id 'com.diffplug.spotless' version '8.1.0' apply false id 'java' } -subprojects { +allprojects { repositories { - mavenCentral() + mavenCentral() } - // TODO: better spotless integration to support groovy etc... + apply plugin: 'com.diffplug.spotless' + spotless { + groovyGradle { + target '**/*.gradle' + targetExclude 'build/**', '.gradle/**', '.direnv/**' + greclipse() + leadingTabsToSpaces(4) + trimTrailingWhitespace() + endWithNewline() + } + } + plugins.withId('java') { java { toolchain { @@ -17,27 +29,73 @@ subprojects { } } - spotless { - java { - googleJavaFormat('1.32.0') + spotless { + java { + googleJavaFormat('1.32.0') + } + } + + apply plugin: 'net.ltgt.errorprone' + dependencies { + errorprone("com.google.errorprone:error_prone_core:2.44.0") + } + + tasks.withType(Test).configureEach { + // Pass the absolute path of the 'dist' folder to the test JVM + useJUnitPlatform() + systemProperty 'agent.dist.dir', rootProject.layout.buildDirectory.dir("dist").get().asFile.absolutePath + + // Ensure artifacts are built before running tests + dependsOn rootProject.tasks.named('copyToDist') + + // Allow stdout to show up in Gradle logs + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" } - } + } } -} -allprojects { apply plugin: 'eclipse' eclipse.classpath { - defaultOutputDir = file("build/default") - file.whenMerged { cp -> + defaultOutputDir = file("build/default") + file.whenMerged { cp -> cp.entries.forEach { cpe -> - if (cpe instanceof org.gradle.plugins.ide.eclipse.model.SourceFolder) { + if (cpe instanceof org.gradle.plugins.ide.eclipse.model.SourceFolder) { cpe.output = cpe.output.replace "bin/", "build/classes/java/" - } - if (cpe instanceof org.gradle.plugins.ide.eclipse.model.Output) { + } + if (cpe instanceof org.gradle.plugins.ide.eclipse.model.Output) { cpe.path = cpe.path.replace "bin/", "build/" - } + } } - } + } } } + +task copyToDist(type: Copy) { + // FIX: Force this task to run every time (disable incremental build check) + outputs.upToDateWhen { false } + + // Use the Layout API to get a provider for the destination directory. + // This is safe to capture for the Configuration Cache. + def distDir = layout.buildDirectory.dir("dist") + into distDir + + // FIX: Flatten the lists so the Copy task sees the actual files, not a List of Lists + from { + subprojects.collect { it.tasks.withType(Jar) }.flatten() + } + from { + subprojects.findAll { it.plugins.hasPlugin('java') || it.plugins.hasPlugin('java-library') } + .collect { it.configurations.getByName('runtimeClasspath') } + .flatten() + } + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + + // Log so we know it ran + doFirst { + // Use the captured provider (.get().asFile) instead of '$buildDir' + println ">> Copying artifacts to ${distDir.get().asFile}" + } +} + +build.dependsOn copyToDist diff --git a/checker/build.gradle b/checker/build.gradle new file mode 100644 index 0000000..19708ab --- /dev/null +++ b/checker/build.gradle @@ -0,0 +1,26 @@ +plugins { + id 'java' +} + + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(25) // Target JDK 25 + } +} + +ext { + versions = [ + eisopVersion: '3.49.3-eisop1' + ] +} + +dependencies { + implementation project(':framework') + implementation "io.github.eisop:checker-qual:${versions.eisopVersion}" + + testImplementation platform('org.junit:junit-bom:5.10.0') + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + testImplementation 'org.junit.jupiter:junit-jupiter' + testImplementation project(':test-utils') +} diff --git a/checker/src/main/java/io/github/eisop/runtimeframework/checker/nullness/NonNullTarget.java b/checker/src/main/java/io/github/eisop/runtimeframework/checker/nullness/NonNullTarget.java new file mode 100644 index 0000000..479e4c8 --- /dev/null +++ b/checker/src/main/java/io/github/eisop/runtimeframework/checker/nullness/NonNullTarget.java @@ -0,0 +1,33 @@ +package io.github.eisop.runtimeframework.checker.nullness; + +import io.github.eisop.runtimeframework.core.TargetAnnotation; +import java.lang.annotation.Annotation; +import java.lang.classfile.CodeBuilder; +import java.lang.classfile.TypeKind; +import java.lang.constant.ClassDesc; +import java.lang.constant.MethodTypeDesc; +import org.checkerframework.checker.nullness.qual.NonNull; + +public class NonNullTarget implements TargetAnnotation { + + private static final ClassDesc VERIFIER = ClassDesc.of(NullnessRuntimeVerifier.class.getName()); + private static final String METHOD = "checkNotNull"; + private static final MethodTypeDesc DESC = + MethodTypeDesc.ofDescriptor("(Ljava/lang/Object;Ljava/lang/String;)V"); + + @Override + public Class annotationType() { + return NonNull.class; + } + + @Override + public void check(CodeBuilder b, TypeKind type, String diagnosticName) { + if (type == TypeKind.REFERENCE) { + b.ldc(diagnosticName + " must be NonNull"); + b.invokestatic(VERIFIER, METHOD, DESC); + } else { + if (type.slotSize() == 1) b.pop(); + else b.pop2(); + } + } +} diff --git a/checker/src/main/java/io/github/eisop/runtimeframework/checker/nullness/NullnessRuntimeChecker.java b/checker/src/main/java/io/github/eisop/runtimeframework/checker/nullness/NullnessRuntimeChecker.java new file mode 100644 index 0000000..c363cf8 --- /dev/null +++ b/checker/src/main/java/io/github/eisop/runtimeframework/checker/nullness/NullnessRuntimeChecker.java @@ -0,0 +1,34 @@ +package io.github.eisop.runtimeframework.checker.nullness; + +import io.github.eisop.runtimeframework.core.AnnotationInstrumenter; +import io.github.eisop.runtimeframework.core.OptOutAnnotation; +import io.github.eisop.runtimeframework.core.RuntimeChecker; +import io.github.eisop.runtimeframework.core.RuntimeInstrumenter; +import io.github.eisop.runtimeframework.filter.ClassInfo; +import io.github.eisop.runtimeframework.filter.Filter; +import io.github.eisop.runtimeframework.policy.EnforcementPolicy; +import io.github.eisop.runtimeframework.resolution.HierarchyResolver; +import io.github.eisop.runtimeframework.resolution.ReflectionHierarchyResolver; +import java.util.List; +import org.checkerframework.checker.nullness.qual.Nullable; + +public class NullnessRuntimeChecker extends RuntimeChecker { + + @Override + public String getName() { + return "nullness"; + } + + @Override + public RuntimeInstrumenter getInstrumenter(Filter filter) { + EnforcementPolicy policy = + createPolicy( + List.of(new NonNullTarget()), List.of(new OptOutAnnotation(Nullable.class)), filter); + + HierarchyResolver resolver = + new ReflectionHierarchyResolver( + className -> filter.test(new ClassInfo(className.replace('.', '/'), null, null))); + + return new AnnotationInstrumenter(policy, resolver, filter); + } +} diff --git a/checker/src/main/java/io/github/eisop/runtimeframework/checker/nullness/NullnessRuntimeVerifier.java b/checker/src/main/java/io/github/eisop/runtimeframework/checker/nullness/NullnessRuntimeVerifier.java new file mode 100644 index 0000000..05062de --- /dev/null +++ b/checker/src/main/java/io/github/eisop/runtimeframework/checker/nullness/NullnessRuntimeVerifier.java @@ -0,0 +1,25 @@ +package io.github.eisop.runtimeframework.checker.nullness; + +import io.github.eisop.runtimeframework.runtime.RuntimeVerifier; + +/** + * The static trampoline for Nullness checks. + * + *

The {@link NullnessRuntimeInstrumenter} generates {@code invokestatic} calls to the methods in + * this class. These methods perform the actual runtime validation and report violations to the + * central {@link RuntimeVerifier}. + */ +public class NullnessRuntimeVerifier extends RuntimeVerifier { + + /** + * Verifies that the given object is not null. + * + * @param o The object to check + * @param message The error message to report if the object is null + */ + public static void checkNotNull(Object o, String message) { + if (o == null) { + reportViolation("Nullness", message); + } + } +} 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 new file mode 100644 index 0000000..11d1e58 --- /dev/null +++ b/checker/src/test/java/io/github/eisop/checker/nullness/NullnessDirectoryTest.java @@ -0,0 +1,63 @@ +package io.github.eisop.checker.nullness; + +import io.github.eisop.testutils.RuntimeTestRunner; +import org.junit.jupiter.api.Test; + +public class NullnessDirectoryTest extends RuntimeTestRunner { + + @Test + public void testParameterScenarios() throws Exception { + runDirectoryTest( + "nullness-parameter", + "io.github.eisop.runtimeframework.checker.nullness.NullnessRuntimeChecker", + false); + } + + @Test + public void testInvokeScenarios() throws Exception { + runDirectoryTest( + "nullness-invoke", + "io.github.eisop.runtimeframework.checker.nullness.NullnessRuntimeChecker", + false); + } + + @Test + public void testFieldReadScenarios() throws Exception { + runDirectoryTest( + "nullness-field-read", + "io.github.eisop.runtimeframework.checker.nullness.NullnessRuntimeChecker", + false); + } + + @Test + public void testFieldWriteScenarios() throws Exception { + runDirectoryTest( + "nullness-field-write", + "io.github.eisop.runtimeframework.checker.nullness.NullnessRuntimeChecker", + true); + } + + @Test + public void testFieldWriteWithoutGlobalScenarios() throws Exception { + runDirectoryTest( + "nullness-field-write-no-global", + "io.github.eisop.runtimeframework.checker.nullness.NullnessRuntimeChecker", + false); + } + + @Test + public void testBridgeGeneration() throws Exception { + runDirectoryTest( + "nullness-bridge", + "io.github.eisop.runtimeframework.checker.nullness.NullnessRuntimeChecker", + false); + } + + @Test + public void testGlobalInheritance() throws Exception { + runDirectoryTest( + "nullness-global-bridge", + "io.github.eisop.runtimeframework.checker.nullness.NullnessRuntimeChecker", + true); + } +} diff --git a/checker/src/test/resources/test-cases/nullness-bridge/InheritanceBridgeTest.java b/checker/src/test/resources/test-cases/nullness-bridge/InheritanceBridgeTest.java new file mode 100644 index 0000000..3dd58aa --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-bridge/InheritanceBridgeTest.java @@ -0,0 +1,38 @@ +// :: error: (Parameter 0 in inherited method dangerousAction must be NonNull) +// :: error: (Parameter 0 in inherited method protectedAction must be NonNull) + +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class InheritanceBridgeTest extends UncheckedParent { + + public static void main(String[] args) { + InheritanceBridgeTest test = new InheritanceBridgeTest(); + test.dangerousAction("safe"); + test.dangerousAction(null); + + test.overrideMe("safe", "safe"); + test.overrideMe(null, "unsafe"); + test.overrideMe("safe", "null"); + + test.protectedAction("safe"); + test.protectedAction(null); + + test.finalAction("safe"); + test.finalAction(null); + // cannot bridge final methods, no error here + + String unsafe = test.returnAction(); + // :: error: (Local Variable Assignment (Slot 2) must be NonNull) + + @Nullable String again = test.returnAction(); + } + + @Override + public void overrideMe(@NonNull String inputA, @Nullable String inputB) { + // :: error: (Parameter 0 must be NonNull) + System.out.println("safe version of this method" + inputA + inputB); + } +} diff --git a/checker/src/test/resources/test-cases/nullness-bridge/UncheckedParent.java b/checker/src/test/resources/test-cases/nullness-bridge/UncheckedParent.java new file mode 100644 index 0000000..4872409 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-bridge/UncheckedParent.java @@ -0,0 +1,22 @@ +public class UncheckedParent { + public void dangerousAction(String input) { + System.out.println("UncheckedParent.dangerousAction: " + input); + } + + public void overrideMe(String inputA, String inputB) { + System.out.println("Override this method check" + inputA + inputB); + } + + protected void protectedAction(String input) { + System.out.println("UncheckedParent.protectedAction: " + input); + } + + public final void finalAction(String input) { + System.out.println("UncheckedParent.finalAction: " + input); + } + + public String returnAction() { + System.out.println("UncheckedParent.returnAction returning null..."); + return null; + } +} diff --git a/checker/src/test/resources/test-cases/nullness-field-read/InstanceFieldRead.java b/checker/src/test/resources/test-cases/nullness-field-read/InstanceFieldRead.java new file mode 100644 index 0000000..66ca7fd --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-field-read/InstanceFieldRead.java @@ -0,0 +1,19 @@ +import org.checkerframework.checker.nullness.qual.Nullable; +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class InstanceFieldRead { + + static class UncheckedLib { + public String poison = null; + } + + public static void main(String[] args) { + UncheckedLib lib = new UncheckedLib(); + + String s = lib.poison; + // :: error: (Local Variable Assignment (Slot 2) must be NonNull) + + @Nullable String q = lib.poison; + } +} diff --git a/checker/src/test/resources/test-cases/nullness-field-read/StaticFieldRead.java b/checker/src/test/resources/test-cases/nullness-field-read/StaticFieldRead.java new file mode 100644 index 0000000..99673fb --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-field-read/StaticFieldRead.java @@ -0,0 +1,14 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class StaticFieldRead { + + static class UncheckedLib { + public static String POISON = null; + } + + public static void main(String[] args) { + String s = UncheckedLib.POISON; + // :: error: (Local Variable Assignment (Slot 1) must be NonNull) + } +} diff --git a/checker/src/test/resources/test-cases/nullness-field-write-no-global/InstanceWrite.java b/checker/src/test/resources/test-cases/nullness-field-write-no-global/InstanceWrite.java new file mode 100644 index 0000000..21ee9c2 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-field-write-no-global/InstanceWrite.java @@ -0,0 +1,24 @@ +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class InstanceWrite { + + public @NonNull String data = "safe"; + public @Nullable String nullableData = "still safe"; + + static class UncheckedInstanceWriter { + public static void poison(InstanceWrite target) { + target.data = null; + // no error, global off + + target.nullableData = null; + + } + } + + public static void main(String[] args) { + UncheckedInstanceWriter.poison(new InstanceWrite()); + } +} diff --git a/checker/src/test/resources/test-cases/nullness-field-write-no-global/StaticWrite.java b/checker/src/test/resources/test-cases/nullness-field-write-no-global/StaticWrite.java new file mode 100644 index 0000000..8100def --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-field-write-no-global/StaticWrite.java @@ -0,0 +1,26 @@ +import org.checkerframework.checker.nullness.qual.Nullable; +import org.checkerframework.checker.nullness.qual.NonNull; +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class StaticWrite{ + + public static @Nullable String nullableData = "don't care"; + public static @NonNull String nonNullData = "safe"; + + static class UncheckedWriter { + public static void writeToNullable() { + StaticWrite.nullableData = null; + } + + public static void writeToNonNull() { + StaticWrite.nonNullData = null; + // no error global off + } + } + + public static void main(String[] args) { + UncheckedWriter.writeToNullable(); + UncheckedWriter.writeToNonNull(); + } +} diff --git a/checker/src/test/resources/test-cases/nullness-field-write/InstanceWrite.java b/checker/src/test/resources/test-cases/nullness-field-write/InstanceWrite.java new file mode 100644 index 0000000..12c2c12 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-field-write/InstanceWrite.java @@ -0,0 +1,24 @@ +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class InstanceWrite { + + public @NonNull String data = "safe"; + public @Nullable String nullableData = "still safe"; + + static class UncheckedInstanceWriter { + public static void poison(InstanceWrite target) { + target.data = null; + // :: error: (Field 'data' must be NonNull) + + target.nullableData = null; + + } + } + + public static void main(String[] args) { + UncheckedInstanceWriter.poison(new InstanceWrite()); + } +} diff --git a/checker/src/test/resources/test-cases/nullness-field-write/StaticWrite.java b/checker/src/test/resources/test-cases/nullness-field-write/StaticWrite.java new file mode 100644 index 0000000..4db4011 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-field-write/StaticWrite.java @@ -0,0 +1,26 @@ +import org.checkerframework.checker.nullness.qual.Nullable; +import org.checkerframework.checker.nullness.qual.NonNull; +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class StaticWrite{ + + public static @Nullable String nullableData = "don't care"; + public static @NonNull String nonNullData = "safe"; + + static class UncheckedWriter { + public static void writeToNullable() { + StaticWrite.nullableData = null; + } + + public static void writeToNonNull() { + StaticWrite.nonNullData = null; + // :: error: (Static Field 'nonNullData' must be NonNull) + } + } + + public static void main(String[] args) { + UncheckedWriter.writeToNullable(); + UncheckedWriter.writeToNonNull(); + } +} diff --git a/checker/src/test/resources/test-cases/nullness-global-bridge/LegacyTrojan.java b/checker/src/test/resources/test-cases/nullness-global-bridge/LegacyTrojan.java new file mode 100644 index 0000000..fb91d84 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-global-bridge/LegacyTrojan.java @@ -0,0 +1,12 @@ +public class LegacyTrojan extends SafeContract { + @Override + public String getValue() { + return null; + // :: error: (Return value of overridden method getValue must be NonNull) + } + @Override + public String getUnsafeValue() { + return null; + } + +} diff --git a/checker/src/test/resources/test-cases/nullness-global-bridge/SafeContract.java b/checker/src/test/resources/test-cases/nullness-global-bridge/SafeContract.java new file mode 100644 index 0000000..57ad000 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-global-bridge/SafeContract.java @@ -0,0 +1,10 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; + + +@AnnotatedFor("nullness") +public class SafeContract { + public @NonNull String getValue() { return "Safe"; } + public @Nullable String getUnsafeValue() { return "Unsafe"; } +} diff --git a/checker/src/test/resources/test-cases/nullness-global-bridge/TrojanRunner.java b/checker/src/test/resources/test-cases/nullness-global-bridge/TrojanRunner.java new file mode 100644 index 0000000..4231398 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-global-bridge/TrojanRunner.java @@ -0,0 +1,8 @@ +public class TrojanRunner { + public static void main(String[] args) { + System.out.println("--- Starting Trojan Runner ---"); + SafeContract c = new LegacyTrojan(); + c.getValue(); + c.getUnsafeValue(); + } +} diff --git a/checker/src/test/resources/test-cases/nullness-invoke/InstanceBoundary.java b/checker/src/test/resources/test-cases/nullness-invoke/InstanceBoundary.java new file mode 100644 index 0000000..7443f37 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-invoke/InstanceBoundary.java @@ -0,0 +1,22 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class InstanceBoundary { + + static class UncheckedLib { + public String getNull() { + return null; + } + } + + public static void main(String[] args) { + UncheckedLib lib = new UncheckedLib(); + + String s = lib.getNull(); + // :: error: (Local Variable Assignment (Slot 2) must be NonNull) + + lib.getNull(); + // currently no explicit check on the return if its not stored + + } +} diff --git a/checker/src/test/resources/test-cases/nullness-invoke/NullableBoundary.java b/checker/src/test/resources/test-cases/nullness-invoke/NullableBoundary.java new file mode 100644 index 0000000..38b6cc2 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-invoke/NullableBoundary.java @@ -0,0 +1,16 @@ +import org.checkerframework.checker.nullness.qual.Nullable; +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class NullableBoundary { + + static class UncheckedLib { + public static String getNull() { + return null; + } + } + + public static void main(String[] args) { + @Nullable String s = UncheckedLib.getNull(); + } +} diff --git a/checker/src/test/resources/test-cases/nullness-invoke/PrimitiveBoundary.java b/checker/src/test/resources/test-cases/nullness-invoke/PrimitiveBoundary.java new file mode 100644 index 0000000..c0131c8 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-invoke/PrimitiveBoundary.java @@ -0,0 +1,16 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class PrimitiveBoundary { + + static class UncheckedLib { + public int getInt() { + return 0; + } + } + + public static void main(String[] args) { + UncheckedLib lib = new UncheckedLib(); + int i = lib.getInt(); + } +} diff --git a/checker/src/test/resources/test-cases/nullness-invoke/StaticBoundary.java b/checker/src/test/resources/test-cases/nullness-invoke/StaticBoundary.java new file mode 100644 index 0000000..3aa1212 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-invoke/StaticBoundary.java @@ -0,0 +1,18 @@ +import org.checkerframework.checker.nullness.qual.NonNull; +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class StaticBoundary { + + static class UncheckedLib { + public static String getNull() { + return null; + } + } + + public static void main(String[] args) { + String s = UncheckedLib.getNull(); + // :: error: (Local Variable Assignment (Slot 1) must be NonNull) + } + +} diff --git a/checker/src/test/resources/test-cases/nullness-invoke/UncheckedToCheckedInstance.java b/checker/src/test/resources/test-cases/nullness-invoke/UncheckedToCheckedInstance.java new file mode 100644 index 0000000..5479ddd --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-invoke/UncheckedToCheckedInstance.java @@ -0,0 +1,32 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; + + +@AnnotatedFor("nullness") +public class UncheckedToCheckedInstance { + + public void checkedMethod(@NonNull String input) { + // :: error: (Parameter 0 must be NonNull) + } + + public void nullableCheckedMethod(@Nullable String input) { + } + + public void mixedCheckedMethod(@Nullable String input, @NonNull String anotherInput) { + // :: error: (Parameter 1 must be NonNull) + } + + static class UncheckedCaller { + public static void invoke(UncheckedToCheckedInstance target) { + target.checkedMethod(null); + target.nullableCheckedMethod(null); + target.mixedCheckedMethod(null, null); + } + } + + public static void main(String[] args) { + UncheckedToCheckedInstance target = new UncheckedToCheckedInstance(); + UncheckedCaller.invoke(target); + } +} diff --git a/checker/src/test/resources/test-cases/nullness-invoke/UncheckedToCheckedStatic.java b/checker/src/test/resources/test-cases/nullness-invoke/UncheckedToCheckedStatic.java new file mode 100644 index 0000000..ee0b322 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-invoke/UncheckedToCheckedStatic.java @@ -0,0 +1,30 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; + +@AnnotatedFor("nullness") +public class UncheckedToCheckedStatic { + + public static void staticCheckedMethod(@NonNull String input) { + // :: error: (Parameter 0 must be NonNull) + } + + public static void staticCheckedNullableMethod(@Nullable String input) { + } + + public static void staticCheckedMixedMethod(@Nullable String input, @NonNull String anotherInput) { + // :: error: (Parameter 1 must be NonNull) + } + + static class UncheckedCaller { + public static void invoke() { + UncheckedToCheckedStatic.staticCheckedMethod(null); + UncheckedToCheckedStatic.staticCheckedNullableMethod(null); + UncheckedToCheckedStatic.staticCheckedMixedMethod(null, null); + } + } + + public static void main(String[] args) { + UncheckedCaller.invoke(); + } +} diff --git a/checker/src/test/resources/test-cases/nullness-parameter/Constructors.java b/checker/src/test/resources/test-cases/nullness-parameter/Constructors.java new file mode 100644 index 0000000..2ab2737 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-parameter/Constructors.java @@ -0,0 +1,18 @@ +import org.checkerframework.checker.nullness.qual.NonNull; +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class Constructors { + public static void main(String[] args) { + new Constructors(null); + new Constructors(null, "ignore"); + } + + public Constructors(String s) { + // :: error: (Parameter 0 must be NonNull) + } + + public Constructors(@NonNull String a, String b) { + // :: error: (Parameter 0 must be NonNull) + } +} diff --git a/checker/src/test/resources/test-cases/nullness-parameter/FieldArgument.java b/checker/src/test/resources/test-cases/nullness-parameter/FieldArgument.java new file mode 100644 index 0000000..cdd6bbe --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-parameter/FieldArgument.java @@ -0,0 +1,23 @@ +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class FieldArgument { + + static class UncheckedLib { + public static String POISON = null; + } + + public static void main(String[] args) { + consume(UncheckedLib.POISON); + nullableConsume(UncheckedLib.POISON); + } + + public static void consume(@NonNull String arg) { + // :: error: (Parameter 0 must be NonNull) + } + + public static void nullableConsume(@Nullable String arg) { + } +} diff --git a/checker/src/test/resources/test-cases/nullness-parameter/MixedMethods.java b/checker/src/test/resources/test-cases/nullness-parameter/MixedMethods.java new file mode 100644 index 0000000..25e9fc4 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-parameter/MixedMethods.java @@ -0,0 +1,38 @@ +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class MixedMethods { + + public static void main(String[] args) { + // 1. Explicit NonNull + checkExplicit(null); + + // 2. Implicit NonNull (Strict Default) + checkImplicit(null); + + // 3. Explicit Nullable (Should NOT error) + checkNullable(null); + + // 3. Explicit Nullable (Should NOT error) + checkMultiple(null,null,null,null); + } + + public static void checkExplicit(@NonNull String s) { + // :: error: (Parameter 0 must be NonNull) + } + + public static void checkImplicit(String s) { + // :: error: (Parameter 0 must be NonNull) + } + + public static void checkNullable(@Nullable String s) { + // No error expected here + } + + public static void checkMultiple(@Nullable String s, String q, String r, @Nullable String v) { + // :: error: (Parameter 1 must be NonNull) + // :: error: (Parameter 2 must be NonNull) + } +} diff --git a/checker/src/test/resources/test-cases/nullness-parameter/Primitives.java b/checker/src/test/resources/test-cases/nullness-parameter/Primitives.java new file mode 100644 index 0000000..2b5b8fa --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-parameter/Primitives.java @@ -0,0 +1,12 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") +public class Primitives { + public static void main(String[] args) { + testPrimitives(42, null, true); + } + + public static void testPrimitives(int a, String b, boolean c) { + // :: error: (Parameter 1 must be NonNull) + } +} diff --git a/examples/global-policy/GlobalDemo.java b/examples/global-policy/GlobalDemo.java new file mode 100644 index 0000000..e3e1f07 --- /dev/null +++ b/examples/global-policy/GlobalDemo.java @@ -0,0 +1,38 @@ +package global; + +public class GlobalDemo { + public static void main(String[] args) { + SafeContract c = new LegacyTrojan(); + + + System.out.println("\n[1] Unchecked override return check"); + try { + // cannot break @NonNull contract + c.getValue(); + } catch (RuntimeException e) { + System.out.println("SUCCESS: Caught expected violation: " + e.getMessage()); + } + + // ok to overide and return null on a @Nullable contract + c.getUnsafeValue(); + + System.out.println("\n[2] External Write (Instance)"); + + SafeContract target = new SafeContract(); + + try { + UncheckedWriter.poisonInstance(target); + System.err.println("FAILURE: Poisoned instance field!"); + } catch (RuntimeException e) { + System.out.println("SUCCESS: Caught expected violation: " + e.getMessage()); + } + + System.out.println("\n[3] External Write (Static)"); + try { + UncheckedWriter.poisonStatic(); + System.err.println("FAILURE: Poisoned static field!"); + } catch (RuntimeException e) { + System.out.println("SUCCESS: Caught expected violation: " + e.getMessage()); + } + } +} diff --git a/examples/global-policy/LegacyTrojan.java b/examples/global-policy/LegacyTrojan.java new file mode 100644 index 0000000..28ae2fe --- /dev/null +++ b/examples/global-policy/LegacyTrojan.java @@ -0,0 +1,13 @@ +package global; + +public class LegacyTrojan extends SafeContract { + @Override + public String getValue() { + return null; + } + @Override + public String getUnsafeValue() { + return null; + } + +} diff --git a/examples/global-policy/SafeContract.java b/examples/global-policy/SafeContract.java new file mode 100644 index 0000000..6b1e3d0 --- /dev/null +++ b/examples/global-policy/SafeContract.java @@ -0,0 +1,15 @@ +package global; + +import io.github.eisop.runtimeframework.qual.AnnotatedFor; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; + +@AnnotatedFor("nullness") +public class SafeContract { + + public @NonNull String safeInstanceField = "Safe"; + public static @NonNull String safeStaticField = "Safe"; + + public @NonNull String getValue() { return "Safe"; } + public @Nullable String getUnsafeValue() { return "Unsafe"; } +} diff --git a/examples/global-policy/UncheckedWriter.java b/examples/global-policy/UncheckedWriter.java new file mode 100644 index 0000000..760ba85 --- /dev/null +++ b/examples/global-policy/UncheckedWriter.java @@ -0,0 +1,16 @@ +package global; + +public class UncheckedWriter { + + public static void poisonInstance(SafeContract target) { + System.out.println("\t[Legacy] Writing null to target.safeInstanceField..."); + // Global Policy instruments this PUTFIELD instruction to prevent poisoning. + target.safeInstanceField = null; + } + + public static void poisonStatic() { + System.out.println("\t[Legacy] Writing null to CheckedTarget.safeStaticField..."); + // Global Policy instruments this PUTSTATIC instruction. + SafeContract.safeStaticField = null; + } +} diff --git a/examples/standard-policy/DataHolder.java b/examples/standard-policy/DataHolder.java new file mode 100644 index 0000000..efde447 --- /dev/null +++ b/examples/standard-policy/DataHolder.java @@ -0,0 +1,18 @@ +package standard; + +import io.github.eisop.runtimeframework.qual.AnnotatedFor; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; + +@AnnotatedFor("nullness") +public class DataHolder { + @NonNull + public String safeField = "Safe"; + + @Nullable + public String nullableField = null; + + public void setSafe(@NonNull String s) { + this.safeField = s; + } +} diff --git a/examples/standard-policy/StandardDemo.java b/examples/standard-policy/StandardDemo.java new file mode 100644 index 0000000..92d8e23 --- /dev/null +++ b/examples/standard-policy/StandardDemo.java @@ -0,0 +1,57 @@ +package standard; + +import io.github.eisop.runtimeframework.qual.AnnotatedFor; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; + +@AnnotatedFor("nullness") +public class StandardDemo extends UncheckedParent { + + public static void main(String[] args) { + System.out.println("=== Standard Policy Demo ==="); + StandardDemo demo = new StandardDemo(); + + System.out.println("\n[1] Internal Parameter Check (Strict Default)"); + try { + demo.internalMethod(null); + } catch (RuntimeException e) { + System.out.println("SUCCESS: Caught expected violation: " + e.getMessage()); + } + + System.out.println("\n[2] Bridge Method Check"); + try { + demo.action(null); + } catch (RuntimeException e) { + System.out.println("SUCCESS: Caught expected violation: " + e.getMessage()); + } + + System.out.println("\n[3] Boundary Call (Strict Default)"); + try { + String s = UncheckedLibrary.getLegacyData(); + } catch (RuntimeException e) { + System.out.println("SUCCESS: Caught expected violation: " + e.getMessage()); + } + + System.out.println("\n[4] Opt-out Check (@Nullable)"); + @Nullable String nullable = UncheckedLibrary.getLegacyData(); + System.out.println("SUCCESS: Allowed null assignment to @Nullable variable."); + + System.out.println("\n[5] Field Safety (Internal Read)"); + DataHolder holder = new DataHolder(); + String val = holder.safeField; + System.out.println("Read safe field: " + val); + + System.out.println("\n[6] Boundary Field Read"); + try { + String s = UncheckedLibrary.legacyField; + } catch (RuntimeException e) { + System.out.println("SUCCESS: Caught expected violation: " + e.getMessage()); + } + + System.out.println("\n=== Demo Finished ==="); + } + + public void internalMethod(String input) { + System.out.println("\tInside internalMethod: " + input); + } +} diff --git a/examples/standard-policy/UncheckedLibrary.java b/examples/standard-policy/UncheckedLibrary.java new file mode 100644 index 0000000..3d70b04 --- /dev/null +++ b/examples/standard-policy/UncheckedLibrary.java @@ -0,0 +1,15 @@ +package standard; + +public class UncheckedLibrary { + + public static String legacyField = null; + + public static String getLegacyData() { + System.out.println("\t[UncheckedLibrary] Returning null..."); + return null; + } + + public static void consumeData(String data) { + System.out.println("\t[UncheckedLibrary] Consumed: " + data); + } +} diff --git a/examples/standard-policy/UncheckedParent.java b/examples/standard-policy/UncheckedParent.java new file mode 100644 index 0000000..82526eb --- /dev/null +++ b/examples/standard-policy/UncheckedParent.java @@ -0,0 +1,16 @@ +package standard; + +public class UncheckedParent { + // Legacy method with no annotations. + // In a Checked child, this implies 's' must be NonNull. + public void action(String s) { + System.out.println("\t[UncheckedParent] action: " + s); + } + + // A method returning null. + // The bridge might not check this (depending on return policy), + // but the caller in Checked code will check the result upon assignment. + public String getValue() { + return null; + } +} diff --git a/framework/build.gradle b/framework/build.gradle index fb5683e..12c5c49 100644 --- a/framework/build.gradle +++ b/framework/build.gradle @@ -1,16 +1,11 @@ plugins { id 'java' - id 'net.ltgt.errorprone' -} - -dependencies { - errorprone("com.google.errorprone:error_prone_core:2.44.0") } jar { manifest { attributes( - 'Premain-Class': 'io.github.eisop.runtimeframework.agent.RuntimeAgent', - ) + 'Premain-Class': 'io.github.eisop.runtimeframework.agent.RuntimeAgent', + ) } } 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 7d1acad..ec87ebc 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,7 +1,89 @@ package io.github.eisop.runtimeframework.agent; -public class RuntimeAgent { - public static void premain(String args, java.lang.instrument.Instrumentation inst) { - System.out.println("Agent Initialized"); +import io.github.eisop.runtimeframework.core.RuntimeChecker; +import io.github.eisop.runtimeframework.filter.ClassInfo; +import io.github.eisop.runtimeframework.filter.ClassListFilter; +import io.github.eisop.runtimeframework.filter.Filter; +import io.github.eisop.runtimeframework.filter.FrameworkSafetyFilter; +import io.github.eisop.runtimeframework.runtime.RuntimeVerifier; +import io.github.eisop.runtimeframework.runtime.ViolationHandler; +import java.lang.instrument.Instrumentation; +import java.util.Arrays; + +public final class RuntimeAgent { + + public static void premain(String args, Instrumentation inst) { + Filter safeFilter = new FrameworkSafetyFilter(); + Filter policyFilter = safeFilter; + + String checkedClasses = System.getProperty("runtime.classes"); + boolean isGlobalMode = Boolean.getBoolean("runtime.global"); + boolean trustAnnotatedFor = Boolean.getBoolean("runtime.trustAnnotatedFor"); + + if (checkedClasses != null && !checkedClasses.isBlank()) { + System.out.println("[RuntimeAgent] Checked Scope restricted to: " + checkedClasses); + Filter listFilter = new ClassListFilter(Arrays.asList(checkedClasses.split(","))); + policyFilter = info -> safeFilter.test(info) && listFilter.test(info); + } else if (trustAnnotatedFor) { + policyFilter = info -> false; + } + + Filter scanFilter = policyFilter; + boolean scanAll = false; + + if (trustAnnotatedFor) { + System.out.println( + "[RuntimeAgent] Auto-Discovery Enabled. Scanning all safe classes for annotations."); + scanAll = true; + } + + if (isGlobalMode) { + System.out.println( + "[RuntimeAgent] Global Mode ENABLED. Scanning all safe classes for external writes."); + scanAll = true; + } + + if (checkedClasses == null && !trustAnnotatedFor) { + scanAll = true; + } + + if (scanAll) { + scanFilter = safeFilter; + } + + // 3. Configure Violation Handler + String handlerClassName = System.getProperty("runtime.handler"); + if (handlerClassName != null && !handlerClassName.isBlank()) { + try { + System.out.println("[RuntimeAgent] Setting ViolationHandler: " + handlerClassName); + Class handlerClass = Class.forName(handlerClassName); + ViolationHandler handler = (ViolationHandler) handlerClass.getConstructor().newInstance(); + RuntimeVerifier.setViolationHandler(handler); + } catch (Exception e) { + System.err.println( + "[RuntimeAgent] ERROR: Could not instantiate handler: " + handlerClassName); + e.printStackTrace(); + } + } + + String checkerClassName = + System.getProperty( + "runtime.checker", "io.github.eisop.runtimeframework.util.SysOutRuntimeChecker"); + + RuntimeChecker checker; + try { + System.out.println("[RuntimeAgent] Loading checker: " + checkerClassName); + Class clazz = Class.forName(checkerClassName); + checker = (RuntimeChecker) clazz.getConstructor().newInstance(); + } catch (Exception e) { + System.err.println( + "[RuntimeAgent] FATAL: Could not instantiate checker: " + checkerClassName); + e.printStackTrace(); + return; + } + + inst.addTransformer( + new RuntimeTransformer(scanFilter, policyFilter, checker, trustAnnotatedFor, isGlobalMode), + 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 new file mode 100644 index 0000000..dbb6f60 --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java @@ -0,0 +1,165 @@ +package io.github.eisop.runtimeframework.agent; + +import io.github.eisop.runtimeframework.core.RuntimeChecker; +import io.github.eisop.runtimeframework.core.RuntimeInstrumenter; +import io.github.eisop.runtimeframework.filter.ClassInfo; +import io.github.eisop.runtimeframework.filter.Filter; +import io.github.eisop.runtimeframework.qual.AnnotatedFor; +import java.lang.classfile.Annotation; +import java.lang.classfile.AnnotationValue; +import java.lang.classfile.Attributes; +import java.lang.classfile.ClassFile; +import java.lang.classfile.ClassModel; +import java.lang.instrument.ClassFileTransformer; +import java.security.ProtectionDomain; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +public class RuntimeTransformer implements ClassFileTransformer { + + private final Filter scanFilter; + private final Filter policyFilter; + private final RuntimeChecker checker; + private final boolean trustAnnotatedFor; + private final boolean isGlobalMode; + + private final Map packageCache = new ConcurrentHashMap<>(); + private static final String ANNOTATED_FOR_DESC = AnnotatedFor.class.descriptorString(); + + public RuntimeTransformer( + Filter scanFilter, + Filter policyFilter, + RuntimeChecker checker, + boolean trustAnnotatedFor, + boolean isGlobalMode) { + this.scanFilter = scanFilter; + this.policyFilter = policyFilter; + this.checker = checker; + this.trustAnnotatedFor = trustAnnotatedFor; + this.isGlobalMode = isGlobalMode; + } + + @Override + public byte[] transform( + Module module, + ClassLoader loader, + String className, + Class classBeingRedefined, + ProtectionDomain protectionDomain, + byte[] classfileBuffer) { + + if (className != null + && (className.startsWith("java/") + || className.startsWith("sun/") + || className.startsWith("jdk/") + || className.startsWith("org/gradle"))) { + return null; + } + + ClassInfo info = new ClassInfo(className, loader, module); + + if (!scanFilter.test(info)) { + return null; + } + + System.out.println("[RuntimeFramework] Processing: " + className); + + try { + ClassFile cf = ClassFile.of(); + ClassModel classModel = cf.parse(classfileBuffer); + + boolean isChecked = policyFilter.test(info); + + if (!isChecked && trustAnnotatedFor) { + String targetSystem = checker.getName(); + + if (hasAnnotatedFor(classModel, targetSystem)) { + System.out.println("[RuntimeFramework] Auto-detected Checked Class: " + className); + isChecked = true; + } else if (hasPackageLevelAnnotation(className, loader, targetSystem)) { + System.out.println("[RuntimeFramework] Auto-detected Checked Package: " + className); + isChecked = true; + } + } + + if (!isChecked && !isGlobalMode) { + return null; + } + + boolean finalIsChecked = isChecked; + Filter dynamicFilter = + ctx -> { + if (ctx.internalName().equals(className)) { + return finalIsChecked; + } + return policyFilter.test(ctx); + }; + + RuntimeInstrumenter instrumenter = checker.getInstrumenter(dynamicFilter); + return cf.transformClass(classModel, instrumenter.asClassTransform(classModel, loader)); + + } catch (Throwable t) { + System.err.println("[RuntimeFramework] CRASH transforming: " + className); + t.printStackTrace(); + return null; + } + } + + private boolean hasPackageLevelAnnotation(String className, ClassLoader loader, String system) { + int lastSlash = className.lastIndexOf('/'); + if (lastSlash == -1) return false; + + String packageName = className.substring(0, lastSlash); + + if (packageCache.containsKey(packageName)) { + return packageCache.get(packageName); + } + + String packageInfoPath = packageName + "/package-info.class"; + boolean found = false; + + try (java.io.InputStream is = + (loader != null) + ? loader.getResourceAsStream(packageInfoPath) + : ClassLoader.getSystemResourceAsStream(packageInfoPath)) { + + if (is != null) { + ClassModel packageModel = ClassFile.of().parse(is.readAllBytes()); + if (hasAnnotatedFor(packageModel, system)) { + found = true; + } + } + } catch (Exception e) { + System.out.println("Cannot get package info"); + } + + packageCache.put(packageName, found); + return found; + } + + private boolean hasAnnotatedFor(ClassModel model, String system) { + return model + .findAttribute(Attributes.runtimeVisibleAnnotations()) + .map( + attr -> { + for (Annotation anno : attr.annotations()) { + if (anno.classSymbol().descriptorString().equals(ANNOTATED_FOR_DESC)) { + for (var element : anno.elements()) { + if (element.name().stringValue().equals("value")) { + if (element.value() instanceof AnnotationValue.OfArray arr) { + for (AnnotationValue v : arr.values()) { + if (v instanceof AnnotationValue.OfString s + && s.stringValue().equals(system)) { + return true; + } + } + } + } + } + } + } + return false; + }) + .orElse(false); + } +} diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java b/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java new file mode 100644 index 0000000..8e37c67 --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java @@ -0,0 +1,263 @@ +package io.github.eisop.runtimeframework.core; + +import io.github.eisop.runtimeframework.filter.ClassInfo; +import io.github.eisop.runtimeframework.filter.Filter; +import io.github.eisop.runtimeframework.policy.EnforcementPolicy; +import io.github.eisop.runtimeframework.resolution.HierarchyResolver; +import java.lang.classfile.ClassBuilder; +import java.lang.classfile.ClassModel; +import java.lang.classfile.CodeBuilder; +import java.lang.classfile.FieldModel; +import java.lang.classfile.MethodModel; +import java.lang.classfile.Opcode; +import java.lang.classfile.TypeKind; +import java.lang.classfile.instruction.ArrayLoadInstruction; +import java.lang.classfile.instruction.ArrayStoreInstruction; +import java.lang.classfile.instruction.FieldInstruction; +import java.lang.classfile.instruction.InvokeInstruction; +import java.lang.classfile.instruction.ReturnInstruction; +import java.lang.classfile.instruction.StoreInstruction; +import java.lang.constant.ClassDesc; +import java.lang.constant.MethodTypeDesc; +import java.lang.reflect.Method; +import java.util.Arrays; + +public class AnnotationInstrumenter extends RuntimeInstrumenter { + + private final EnforcementPolicy policy; + private final HierarchyResolver hierarchyResolver; + + public AnnotationInstrumenter( + EnforcementPolicy policy, + HierarchyResolver hierarchyResolver, + Filter safetyFilter) { + super(safetyFilter); + this.policy = policy; + this.hierarchyResolver = hierarchyResolver; + } + + @Override + protected void generateArrayStoreCheck(CodeBuilder b, ArrayStoreInstruction instruction) { + if (instruction.opcode() == Opcode.AASTORE) { + TargetAnnotation target = policy.getArrayStoreCheck(TypeKind.REFERENCE); + if (target != null) { + b.dup(); + target.check(b, TypeKind.REFERENCE, "Array Element Write"); + } + } + } + + @Override + protected void generateArrayLoadCheck(CodeBuilder b, ArrayLoadInstruction instruction) { + if (instruction.opcode() == Opcode.AALOAD) { + TargetAnnotation target = policy.getArrayLoadCheck(TypeKind.REFERENCE); + if (target != null) { + b.dup(); + target.check(b, TypeKind.REFERENCE, "Array Element Read"); + } + } + } + + @Override + protected void generateParameterCheck( + CodeBuilder b, int slotIndex, TypeKind type, MethodModel method, int paramIndex) { + TargetAnnotation target = policy.getParameterCheck(method, paramIndex, type); + if (target != null) { + b.aload(slotIndex); + target.check(b, type, "Parameter " + paramIndex); + } + } + + @Override + protected void generateFieldWriteCheck( + CodeBuilder b, FieldInstruction field, ClassModel classModel) { + TargetAnnotation target = null; + TypeKind type = TypeKind.fromDescriptor(field.typeSymbol().descriptorString()); + + if (field.owner().asInternalName().equals(classModel.thisClass().asInternalName())) { + FieldModel targetField = findField(classModel, field); + if (targetField != null) { + target = policy.getFieldWriteCheck(targetField, type); + } + } else { + target = + policy.getBoundaryFieldWriteCheck( + field.owner().asInternalName(), field.name().stringValue(), type); + } + + if (target != null) { + if (field.opcode() == Opcode.PUTSTATIC) { + b.dup(); + target.check(b, type, "Static Field '" + field.name().stringValue() + "'"); + } else if (field.opcode() == Opcode.PUTFIELD) { + b.dup_x1(); + target.check(b, type, "Field '" + field.name().stringValue() + "'"); + b.swap(); + } + } + } + + @Override + protected void generateFieldReadCheck( + CodeBuilder b, FieldInstruction field, ClassModel classModel) { + TargetAnnotation target = null; + TypeKind type = TypeKind.fromDescriptor(field.typeSymbol().descriptorString()); + + if (field.owner().asInternalName().equals(classModel.thisClass().asInternalName())) { + FieldModel targetField = findField(classModel, field); + if (targetField != null) { + target = policy.getFieldReadCheck(targetField, type); + } + } else { + target = + policy.getBoundaryFieldReadCheck( + field.owner().asInternalName(), field.name().stringValue(), type); + } + + if (target != null) { + if (type.slotSize() == 1) { + b.dup(); + target.check(b, type, "Read Field '" + field.name().stringValue() + "'"); + } + } + } + + @Override + protected void generateReturnCheck(CodeBuilder b, ReturnInstruction ret, MethodModel method) { + TargetAnnotation target = policy.getReturnCheck(method); + if (target != null) { + b.dup(); + target.check(b, TypeKind.REFERENCE, "Return value of " + method.methodName().stringValue()); + } + } + + @Override + protected void generateUncheckedReturnCheck( + CodeBuilder b, + ReturnInstruction ret, + MethodModel method, + ClassModel classModel, + ClassLoader loader) { + if (ret.opcode() != Opcode.ARETURN) return; + TargetAnnotation target = policy.getUncheckedOverrideReturnCheck(classModel, method, loader); + + if (target != null) { + b.dup(); + target.check( + b, + TypeKind.REFERENCE, + "Return value of overridden method " + method.methodName().stringValue()); + } + } + + @Override + protected void generateMethodCallCheck(CodeBuilder b, InvokeInstruction invoke) { + // empty for now, only need to generate checks when a method call is stored somehwhere + + } + + @Override + protected void generateBridgeMethods(ClassBuilder builder, ClassModel model, ClassLoader loader) { + for (Method parentMethod : hierarchyResolver.resolveUncheckedMethods(model, loader)) { + if (policy.shouldGenerateBridge(parentMethod)) { + emitBridge(builder, parentMethod); + } + } + } + + @Override + protected void generateStoreCheck( + CodeBuilder b, StoreInstruction instruction, MethodModel method) { + boolean isRefStore = + switch (instruction.opcode()) { + case ASTORE, ASTORE_0, ASTORE_1, ASTORE_2, ASTORE_3 -> true; + default -> false; + }; + + if (!isRefStore) return; + + int slot = instruction.slot(); + TargetAnnotation target = policy.getLocalVariableWriteCheck(method, slot, TypeKind.REFERENCE); + + if (target != null) { + b.dup(); + target.check(b, TypeKind.REFERENCE, "Local Variable Assignment (Slot " + slot + ")"); + } + } + + private void emitBridge(ClassBuilder builder, Method parentMethod) { + String methodName = parentMethod.getName(); + MethodTypeDesc desc = + MethodTypeDesc.of( + ClassDesc.ofDescriptor(parentMethod.getReturnType().descriptorString()), + Arrays.stream(parentMethod.getParameterTypes()) + .map(c -> ClassDesc.ofDescriptor(c.descriptorString())) + .toArray(ClassDesc[]::new)); + + builder.withMethod( + methodName, + desc, + java.lang.reflect.Modifier.PUBLIC, + methodBuilder -> { + methodBuilder.withCode( + codeBuilder -> { + int slotIndex = 1; + Class[] paramTypes = parentMethod.getParameterTypes(); + + for (int i = 0; i < paramTypes.length; i++) { + TypeKind type = + TypeKind.from(ClassDesc.ofDescriptor(paramTypes[i].descriptorString())); + TargetAnnotation target = policy.getBridgeParameterCheck(parentMethod, i); + if (target != null) { + codeBuilder.aload(slotIndex); + target.check( + codeBuilder, type, "Parameter " + i + " in inherited method " + methodName); + } + slotIndex += type.slotSize(); + } + + codeBuilder.aload(0); + slotIndex = 1; + for (Class pType : paramTypes) { + TypeKind type = TypeKind.from(ClassDesc.ofDescriptor(pType.descriptorString())); + loadLocal(codeBuilder, type, slotIndex); + slotIndex += type.slotSize(); + } + + ClassDesc parentDesc = ClassDesc.of(parentMethod.getDeclaringClass().getName()); + codeBuilder.invokespecial(parentDesc, methodName, desc); + returnResult(codeBuilder, parentMethod.getReturnType()); + }); + }); + } + + private FieldModel findField(ClassModel classModel, FieldInstruction field) { + for (FieldModel fm : classModel.fields()) { + if (fm.fieldName().stringValue().equals(field.name().stringValue()) + && fm.fieldType().stringValue().equals(field.type().stringValue())) { + return fm; + } + } + return null; + } + + private void loadLocal(CodeBuilder b, TypeKind type, int slot) { + switch (type) { + case INT, BYTE, CHAR, SHORT, BOOLEAN -> b.iload(slot); + case LONG -> b.lload(slot); + case FLOAT -> b.fload(slot); + case DOUBLE -> b.dload(slot); + case REFERENCE -> b.aload(slot); + default -> throw new IllegalArgumentException("Unknown type"); + } + } + + private void returnResult(CodeBuilder b, Class returnType) { + if (returnType == void.class) b.return_(); + else if (returnType == int.class || returnType == boolean.class) b.ireturn(); + else if (returnType == long.class) b.lreturn(); + else if (returnType == float.class) b.freturn(); + else if (returnType == double.class) b.dreturn(); + else b.areturn(); + } +} diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/core/OptOutAnnotation.java b/framework/src/main/java/io/github/eisop/runtimeframework/core/OptOutAnnotation.java new file mode 100644 index 0000000..53deda5 --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/OptOutAnnotation.java @@ -0,0 +1,13 @@ +package io.github.eisop.runtimeframework.core; + +import java.lang.annotation.Annotation; + +/** + * Represents an annotation that explicitly disables strict default checks. + * + *

For example, in a Nullness system, {@code @Nullable} is an OptOutAnnotation. When present, the + * policy will skip generating checks. + */ +public record OptOutAnnotation(Class annotationType) { + // No helper methods needed; the consumer calls annotationType().descriptorString() +} 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 new file mode 100644 index 0000000..a024090 --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeChecker.java @@ -0,0 +1,49 @@ +package io.github.eisop.runtimeframework.core; + +import io.github.eisop.runtimeframework.filter.ClassInfo; +import io.github.eisop.runtimeframework.filter.Filter; +import io.github.eisop.runtimeframework.policy.EnforcementPolicy; +import io.github.eisop.runtimeframework.policy.GlobalEnforcementPolicy; +import io.github.eisop.runtimeframework.policy.StandardEnforcementPolicy; +import java.util.Collection; + +/** + * Represents a specific type system or check to be enforced (e.g., Nullness, Immutability). This + * class acts as the configuration and factory for the instrumentation logic. + */ +public abstract class RuntimeChecker { + + /** Returns the name of this checker. This string should match the name used in AnnotatedFor */ + public abstract String getName(); + + /** + * Creates or returns the instrumenter that injects this checker's logic. + * + * @param filter The safety filter currently active in the Agent. The instrumenter should use this + * to determine boundary checks (Checked vs Unchecked). + */ + public abstract RuntimeInstrumenter getInstrumenter(Filter filter); + + /** + * Helper method to create the appropriate EnforcementPolicy based on the framework's + * configuration (e.g., -Druntime.global=true). + * + *

Subclasses should use this instead of manually checking system properties. + * + * @param targets The collection of target annotations relevant to this checker. + * @param filter The filter defining the boundary between Checked and Unchecked code. + * @return A configured EnforcementPolicy (Standard or Global). + */ + protected EnforcementPolicy createPolicy( + Collection targets, + Collection optOuts, + Filter filter) { + + boolean isGlobalMode = Boolean.getBoolean("runtime.global"); + if (isGlobalMode) { + return new GlobalEnforcementPolicy(targets, optOuts, filter); + } else { + return new StandardEnforcementPolicy(targets, optOuts, filter); + } + } +} diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeInstrumenter.java b/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeInstrumenter.java new file mode 100644 index 0000000..785570d --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeInstrumenter.java @@ -0,0 +1,182 @@ +package io.github.eisop.runtimeframework.core; + +import io.github.eisop.runtimeframework.filter.ClassInfo; +import io.github.eisop.runtimeframework.filter.Filter; +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.Instruction; +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.ArrayLoadInstruction; +import java.lang.classfile.instruction.ArrayStoreInstruction; +import java.lang.classfile.instruction.FieldInstruction; +import java.lang.classfile.instruction.InvokeInstruction; +import java.lang.classfile.instruction.LineNumber; +import java.lang.classfile.instruction.ReturnInstruction; +import java.lang.classfile.instruction.StoreInstruction; +import java.lang.constant.MethodTypeDesc; +import java.lang.reflect.Modifier; + +public abstract class RuntimeInstrumenter { + + protected final Filter scopeFilter; + + protected RuntimeInstrumenter(Filter scopeFilter) { + this.scopeFilter = scopeFilter; + } + + public ClassTransform asClassTransform(ClassModel classModel, ClassLoader loader) { + boolean isCheckedScope = + scopeFilter.test(new ClassInfo(classModel.thisClass().asInternalName(), loader, null)); + + return new ClassTransform() { + @Override + public void accept(ClassBuilder classBuilder, ClassElement classElement) { + if (classElement instanceof MethodModel methodModel && methodModel.code().isPresent()) { + classBuilder.transformMethod( + methodModel, + (methodBuilder, methodElement) -> { + if (methodElement instanceof CodeAttribute codeModel) { + methodBuilder.withCode( + codeBuilder -> { + boolean entryChecksDone = !isCheckedScope; + + for (CodeElement element : codeModel) { + if (!entryChecksDone && element instanceof LineNumber) { + codeBuilder.with(element); + instrumentMethodEntry(codeBuilder, methodModel); + entryChecksDone = true; + continue; + } + if (!entryChecksDone && element instanceof Instruction) { + instrumentMethodEntry(codeBuilder, methodModel); + entryChecksDone = true; + } + + if (element instanceof FieldInstruction fInst) { + if (isFieldWrite(fInst)) { + generateFieldWriteCheck(codeBuilder, fInst, classModel); + codeBuilder.with(element); + } else if (isFieldRead(fInst)) { + codeBuilder.with(element); + if (isCheckedScope) { + // generateFieldReadCheck(codeBuilder, fInst, classModel); + // Currently disabling field read checks as the GETFIELD + // and GETSTATIC instructions are not actually dangerous + // on their own. Its when we STORE a field we read from + // that an issue could arise + // TODO: consider method of turning on and off different + // boundary sites + } + } + } else if (element instanceof ReturnInstruction rInst) { + if (isCheckedScope) { + generateReturnCheck(codeBuilder, rInst, methodModel); + } else { + generateUncheckedReturnCheck( + codeBuilder, rInst, methodModel, classModel, loader); + } + codeBuilder.with(element); + } else if (element instanceof InvokeInstruction invoke) { + codeBuilder.with(element); + if (isCheckedScope) { + generateMethodCallCheck(codeBuilder, invoke); + } + } else if (element instanceof ArrayStoreInstruction astore) { + generateArrayStoreCheck(codeBuilder, astore); + codeBuilder.with(element); + } else if (element instanceof ArrayLoadInstruction aload) { + codeBuilder.with(element); + if (isCheckedScope) { + generateArrayLoadCheck(codeBuilder, aload); + } + } else if (element instanceof StoreInstruction store) { + if (isCheckedScope) { + generateStoreCheck(codeBuilder, store, methodModel); + } + codeBuilder.with(element); + } else { + codeBuilder.with(element); + } + } + + if (!entryChecksDone && isCheckedScope) { + instrumentMethodEntry(codeBuilder, methodModel); + } + }); + } else { + methodBuilder.with(methodElement); + } + }); + } else { + classBuilder.with(classElement); + } + } + + @Override + public void atEnd(ClassBuilder builder) { + if (isCheckedScope) { + generateBridgeMethods(builder, classModel, loader); + } + } + }; + } + + protected boolean isFieldWrite(FieldInstruction f) { + return f.opcode() == Opcode.PUTFIELD || f.opcode() == Opcode.PUTSTATIC; + } + + protected boolean isFieldRead(FieldInstruction f) { + return f.opcode() == Opcode.GETFIELD || f.opcode() == Opcode.GETSTATIC; + } + + protected void instrumentMethodEntry(CodeBuilder builder, MethodModel method) { + boolean isStatic = (method.flags().flagsMask() & Modifier.STATIC) != 0; + int slotIndex = isStatic ? 0 : 1; + MethodTypeDesc methodDesc = method.methodTypeSymbol(); + int paramCount = methodDesc.parameterList().size(); + + for (int i = 0; i < paramCount; i++) { + TypeKind type = TypeKind.from(methodDesc.parameterList().get(i)); + generateParameterCheck(builder, slotIndex, type, method, i); + slotIndex += type.slotSize(); + } + } + + protected abstract void generateParameterCheck( + CodeBuilder b, int slotIndex, TypeKind type, MethodModel method, int paramIndex); + + protected abstract void generateFieldWriteCheck( + CodeBuilder b, FieldInstruction field, ClassModel classModel); + + protected abstract void generateFieldReadCheck( + CodeBuilder b, FieldInstruction field, ClassModel classModel); + + protected abstract void generateReturnCheck( + CodeBuilder b, ReturnInstruction ret, MethodModel method); + + protected abstract void generateUncheckedReturnCheck( + CodeBuilder b, + ReturnInstruction ret, + MethodModel method, + ClassModel classModel, + ClassLoader loader); + + protected abstract void generateMethodCallCheck(CodeBuilder b, InvokeInstruction invoke); + + protected abstract void generateBridgeMethods( + ClassBuilder builder, ClassModel model, ClassLoader loader); + + protected abstract void generateArrayStoreCheck(CodeBuilder b, ArrayStoreInstruction instruction); + + protected abstract void generateArrayLoadCheck(CodeBuilder b, ArrayLoadInstruction instruction); + + protected abstract void generateStoreCheck( + CodeBuilder b, StoreInstruction instruction, MethodModel method); +} diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/core/TargetAnnotation.java b/framework/src/main/java/io/github/eisop/runtimeframework/core/TargetAnnotation.java new file mode 100644 index 0000000..4759e47 --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/TargetAnnotation.java @@ -0,0 +1,25 @@ +package io.github.eisop.runtimeframework.core; + +import java.lang.annotation.Annotation; +import java.lang.classfile.CodeBuilder; +import java.lang.classfile.TypeKind; + +/** Represents a strategy for enforcing a property triggered by a specific annotation. */ +public interface TargetAnnotation { + + /** The annotation class that triggers this check. */ + Class annotationType(); + + /** + * Generates bytecode to verify the property. + * + *

Contract: The value to be checked is already at the top of the operand stack. This + * method must consume that value (e.g., by checking it) or restore the stack state. + * + * @param b The CodeBuilder to emit instructions into. + * @param type The type of the value on the stack. + * @param diagnosticName A human-readable name for the value (e.g., "Parameter 0", "Field 'x'") to + * be used in error messages. + */ + void check(CodeBuilder b, TypeKind type, String diagnosticName); +} diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/filter/ClassInfo.java b/framework/src/main/java/io/github/eisop/runtimeframework/filter/ClassInfo.java new file mode 100644 index 0000000..5b09061 --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/filter/ClassInfo.java @@ -0,0 +1,3 @@ +package io.github.eisop.runtimeframework.filter; + +public record ClassInfo(String internalName, ClassLoader loader, Module module) {} diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/filter/ClassListFilter.java b/framework/src/main/java/io/github/eisop/runtimeframework/filter/ClassListFilter.java new file mode 100644 index 0000000..e4310de --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/filter/ClassListFilter.java @@ -0,0 +1,37 @@ +package io.github.eisop.runtimeframework.filter; + +import java.util.Collection; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** Accepts ONLY the classes whose names are listed (exact match). */ +public final class ClassListFilter implements Filter { + + private final Set allowed; + + public ClassListFilter(Collection classNames) { + Objects.requireNonNull(classNames, "classNames"); + + this.allowed = + classNames.stream() + .filter(Objects::nonNull) + .map(ClassListFilter::toInternalName) + .collect(Collectors.toUnmodifiableSet()); + } + + @Override + public boolean test(ClassInfo ctx) { + String n = ctx.internalName(); + return n != null && allowed.contains(n); + } + + /** Normalize various name spellings to internal form ("pkg/Cls"). */ + private static String toInternalName(String name) { + String s = name.trim(); + while (s.startsWith("/")) s = s.substring(1); + if (s.endsWith(".class")) s = s.substring(0, s.length() - 6); + s = s.replace('.', '/'); + return s; + } +} diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/filter/Filter.java b/framework/src/main/java/io/github/eisop/runtimeframework/filter/Filter.java new file mode 100644 index 0000000..bdb7e2e --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/filter/Filter.java @@ -0,0 +1,15 @@ +package io.github.eisop.runtimeframework.filter; + +import java.util.function.Predicate; + +@FunctionalInterface +public interface Filter extends Predicate { + + static Filter acceptAll() { + return c -> true; + } + + static Filter rejectAll() { + return c -> false; + } +} diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/filter/FrameworkSafetyFilter.java b/framework/src/main/java/io/github/eisop/runtimeframework/filter/FrameworkSafetyFilter.java new file mode 100644 index 0000000..1497b2b --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/filter/FrameworkSafetyFilter.java @@ -0,0 +1,26 @@ +package io.github.eisop.runtimeframework.filter; + +public class FrameworkSafetyFilter implements Filter { + + @Override + public boolean test(ClassInfo info) { + String name = info.internalName(); + + if (name == null) return false; + + // 1. Skip JDK classes + if (name.startsWith("java/") + || name.startsWith("javax/") + || name.startsWith("sun/") + || name.startsWith("jdk/")) { + return false; + } + + // 2. Skip the runtime framework itself + if (name.startsWith("io/github/eisop/")) { + return false; + } + + return true; + } +} diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/policy/EnforcementPolicy.java b/framework/src/main/java/io/github/eisop/runtimeframework/policy/EnforcementPolicy.java new file mode 100644 index 0000000..c1b557f --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/policy/EnforcementPolicy.java @@ -0,0 +1,61 @@ +package io.github.eisop.runtimeframework.policy; + +import io.github.eisop.runtimeframework.core.TargetAnnotation; +import java.lang.classfile.ClassModel; +import java.lang.classfile.FieldModel; +import java.lang.classfile.MethodModel; +import java.lang.classfile.TypeKind; +import java.lang.constant.MethodTypeDesc; +import java.lang.reflect.Method; + +/** Defines the rules for WHEN to inject a runtime check. */ +public interface EnforcementPolicy { + + /** Should we check this specific parameter at method entry? */ + TargetAnnotation getParameterCheck(MethodModel method, int paramIndex, TypeKind type); + + /** Should we check a write to this field? */ + TargetAnnotation getFieldWriteCheck(FieldModel field, TypeKind type); + + /** Should we check a read from this field? */ + TargetAnnotation getFieldReadCheck(FieldModel field, TypeKind type); + + /** Should we check this return value? */ + TargetAnnotation getReturnCheck(MethodModel method); + + /** + * Should we check a write to a field in an EXTERNAL class? (Used when Unchecked code writes to + * Checked code). + */ + default TargetAnnotation getBoundaryFieldWriteCheck( + String owner, String fieldName, TypeKind type) { + return null; + } + + /** We are calling a method on 'owner'. Should we check the result? */ + TargetAnnotation getBoundaryCallCheck(String owner, MethodTypeDesc desc); + + /** We are reading field from an EXTERNAL class. Should we check the value? */ + TargetAnnotation getBoundaryFieldReadCheck(String owner, String fieldName, TypeKind type); + + /** Should we generate a bridge for this inherited method? */ + boolean shouldGenerateBridge(Method parentMethod); + + /** For a bridge we are generating, what check applies to this parameter? */ + TargetAnnotation getBridgeParameterCheck(Method parentMethod, int paramIndex); + + /** Should we check an value being stored into an array? */ + TargetAnnotation getArrayStoreCheck(TypeKind componentType); + + /** Should we check a value being read from an array? */ + TargetAnnotation getArrayLoadCheck(TypeKind componentType); + + /** Should we check a value being stored in a variable? */ + TargetAnnotation getLocalVariableWriteCheck(MethodModel method, int slot, TypeKind type); + + /** Should we check the return of an unchecked override? */ + default TargetAnnotation getUncheckedOverrideReturnCheck( + ClassModel classModel, MethodModel method, ClassLoader loader) { + return null; + } +} diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/policy/GlobalEnforcementPolicy.java b/framework/src/main/java/io/github/eisop/runtimeframework/policy/GlobalEnforcementPolicy.java new file mode 100644 index 0000000..00533ac --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/policy/GlobalEnforcementPolicy.java @@ -0,0 +1,138 @@ +package io.github.eisop.runtimeframework.policy; + +import io.github.eisop.runtimeframework.core.OptOutAnnotation; +import io.github.eisop.runtimeframework.core.TargetAnnotation; +import io.github.eisop.runtimeframework.filter.ClassInfo; +import io.github.eisop.runtimeframework.filter.Filter; +import java.lang.classfile.ClassModel; +import java.lang.classfile.MethodModel; +import java.lang.classfile.TypeKind; +import java.lang.constant.ClassDesc; +import java.lang.reflect.Method; +import java.util.Collection; + +public class GlobalEnforcementPolicy extends StandardEnforcementPolicy { + + public GlobalEnforcementPolicy( + Collection targetAnnotations, + Collection optOutAnnotations, + Filter safetyFilter) { + super(targetAnnotations, optOutAnnotations, safetyFilter); + } + + @Override + public TargetAnnotation getBoundaryFieldWriteCheck( + String owner, String fieldName, TypeKind type) { + if (isClassChecked(owner)) { + if (type == TypeKind.REFERENCE) { + if (isFieldOptOut(owner, fieldName)) { + return null; + } + return super.defaultTarget; + } + } + return null; + } + + @Override + public TargetAnnotation getUncheckedOverrideReturnCheck( + ClassModel classModel, MethodModel method, ClassLoader loader) { + String superName = + classModel.superclass().map(sc -> sc.asInternalName().replace('/', '.')).orElse(null); + if (superName == null || superName.equals("java.lang.Object")) return null; + + try { + Class parent = Class.forName(superName, false, loader); + while (parent != null && parent != Object.class) { + String internalName = parent.getName().replace('.', '/'); + + if (isClassChecked(internalName)) { + for (Method m : parent.getDeclaredMethods()) { + if (m.getName().equals(method.methodName().stringValue())) { + String methodDesc = method.methodTypeSymbol().descriptorString(); + String parentDesc = getMethodDescriptor(m); + if (methodDesc.equals(parentDesc)) { + for (java.lang.annotation.Annotation anno : m.getAnnotations()) { + String desc = "L" + anno.annotationType().getName().replace('.', '/') + ";"; + if (optOutDescriptors.contains(desc)) { + return null; + } + } + + for (java.lang.annotation.Annotation anno : + m.getAnnotatedReturnType().getAnnotations()) { + String desc = "L" + anno.annotationType().getName().replace('.', '/') + ";"; + if (optOutDescriptors.contains(desc)) { + return null; + } + } + + TypeKind returnType = + TypeKind.from(ClassDesc.ofDescriptor(m.getReturnType().descriptorString())); + if (returnType == TypeKind.REFERENCE) { + return super.defaultTarget; + } + } + } + } + } + parent = parent.getSuperclass(); + } + } catch (Throwable e) { + System.out.println("reflection fail in method override"); + } + return null; + } + + private boolean isClassChecked(String internalName) { + if (safetyFilter.test(new ClassInfo(internalName, null, null))) { + return true; + } + try { + String className = internalName.replace('/', '.'); + ClassLoader cl = Thread.currentThread().getContextClassLoader(); + Class clazz = Class.forName(className, false, cl); + for (java.lang.annotation.Annotation anno : clazz.getAnnotations()) { + if (anno.annotationType() + .getName() + .equals("io.github.eisop.runtimeframework.qual.AnnotatedFor")) { + return true; + } + } + } catch (Throwable e) { + System.out.println("Override reflection fail"); + } + return false; + } + + private boolean isFieldOptOut(String owner, String fieldName) { + try { + Class clazz = + Class.forName( + owner.replace('/', '.'), false, Thread.currentThread().getContextClassLoader()); + java.lang.reflect.Field field = clazz.getDeclaredField(fieldName); + + for (java.lang.annotation.Annotation anno : field.getAnnotations()) { + if (optOutDescriptors.contains( + "L" + anno.annotationType().getName().replace('.', '/') + ";")) return true; + } + for (java.lang.annotation.Annotation anno : field.getAnnotatedType().getAnnotations()) { + if (optOutDescriptors.contains( + "L" + anno.annotationType().getName().replace('.', '/') + ";")) return true; + } + } catch (Throwable t) { + System.out.println("reflection fail in is field opt out"); + } + return false; + } + + private String getMethodDescriptor(Method m) { + StringBuilder sb = new StringBuilder("("); + for (Class p : m.getParameterTypes()) { + sb.append(ClassDesc.ofDescriptor(p.descriptorString()).descriptorString()); + } + sb.append(")"); + sb.append(ClassDesc.ofDescriptor(m.getReturnType().descriptorString()).descriptorString()); + return sb.toString(); + } +} diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/policy/StandardEnforcementPolicy.java b/framework/src/main/java/io/github/eisop/runtimeframework/policy/StandardEnforcementPolicy.java new file mode 100644 index 0000000..f513ca6 --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/policy/StandardEnforcementPolicy.java @@ -0,0 +1,262 @@ +package io.github.eisop.runtimeframework.policy; + +import io.github.eisop.runtimeframework.core.OptOutAnnotation; +import io.github.eisop.runtimeframework.core.TargetAnnotation; +import io.github.eisop.runtimeframework.filter.ClassInfo; +import io.github.eisop.runtimeframework.filter.Filter; +import java.lang.classfile.Annotation; +import java.lang.classfile.Attributes; +import java.lang.classfile.FieldModel; +import java.lang.classfile.MethodModel; +import java.lang.classfile.TypeAnnotation; +import java.lang.classfile.TypeKind; +import java.lang.constant.ClassDesc; +import java.lang.constant.MethodTypeDesc; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +public class StandardEnforcementPolicy implements EnforcementPolicy { + + protected final Map targets; + protected final Set optOutDescriptors; + protected final TargetAnnotation defaultTarget; + protected final Filter safetyFilter; + + public StandardEnforcementPolicy( + Collection targetAnnotations, + Collection optOutAnnotations, + Filter safetyFilter) { + + this.targets = + targetAnnotations.stream() + .collect(Collectors.toMap(t -> t.annotationType().descriptorString(), t -> t)); + + this.optOutDescriptors = + optOutAnnotations.stream() + .map(o -> o.annotationType().descriptorString()) + .collect(Collectors.toSet()); + + this.defaultTarget = targetAnnotations.stream().findFirst().orElse(null); + this.safetyFilter = safetyFilter; + } + + private TargetAnnotation findTarget(List annotations) { + for (Annotation a : annotations) { + String desc = a.classSymbol().descriptorString(); + TargetAnnotation t = targets.get(desc); + if (t != null) return t; + } + return null; + } + + private boolean hasOptOutAnnotation(List annotations) { + for (Annotation a : annotations) { + if (optOutDescriptors.contains(a.classSymbol().descriptorString())) { + return true; + } + } + return false; + } + + @Override + public TargetAnnotation getParameterCheck(MethodModel method, int paramIndex, TypeKind type) { + if (type != TypeKind.REFERENCE) return null; + List annos = getMethodParamAnnotations(method, paramIndex); + + TargetAnnotation explicit = findTarget(annos); + if (explicit != null) return explicit; + if (hasOptOutAnnotation(annos)) return null; + + return defaultTarget; + } + + @Override + public TargetAnnotation getFieldWriteCheck(FieldModel field, TypeKind type) { + return null; + } + + @Override + public TargetAnnotation getFieldReadCheck(FieldModel field, TypeKind type) { + if (type != TypeKind.REFERENCE) return null; + List annos = getFieldAnnotations(field); + TargetAnnotation explicit = findTarget(annos); + if (explicit != null) return explicit; + if (hasOptOutAnnotation(annos)) return null; + return defaultTarget; + } + + @Override + public TargetAnnotation getReturnCheck(MethodModel method) { + return null; + } + + @Override + public TargetAnnotation getLocalVariableWriteCheck(MethodModel method, int slot, TypeKind type) { + if (type != TypeKind.REFERENCE) return null; + + List annos = getLocalVariableAnnotations(method, slot); + + TargetAnnotation explicit = findTarget(annos); + if (explicit != null) return explicit; + if (hasOptOutAnnotation(annos)) return null; + + return defaultTarget; + } + + @Override + public TargetAnnotation getArrayStoreCheck(TypeKind componentType) { + if (componentType == TypeKind.REFERENCE) { + return defaultTarget; + } + return null; + } + + @Override + public TargetAnnotation getArrayLoadCheck(TypeKind componentType) { + if (componentType == TypeKind.REFERENCE) { + return defaultTarget; + } + return null; + } + + @Override + public TargetAnnotation getBoundaryCallCheck(String owner, MethodTypeDesc desc) { + boolean isUnchecked = !safetyFilter.test(new ClassInfo(owner, null, null)); + TypeKind returnType = TypeKind.from(desc.returnType()); + + if (isUnchecked && returnType == TypeKind.REFERENCE) { + return defaultTarget; + } + return null; + } + + @Override + public TargetAnnotation getBoundaryFieldReadCheck(String owner, String fieldName, TypeKind type) { + boolean isUnchecked = !safetyFilter.test(new ClassInfo(owner, null, null)); + if (isUnchecked && type == TypeKind.REFERENCE) { + return defaultTarget; + } + return null; + } + + // --- 3. Inheritance Logic --- + + @Override + public boolean shouldGenerateBridge(Method parentMethod) { + if (parentMethod.getDeclaringClass() == Object.class) return false; + Class[] paramTypes = parentMethod.getParameterTypes(); + java.lang.annotation.Annotation[][] paramAnnos = parentMethod.getParameterAnnotations(); + + for (int i = 0; i < paramTypes.length; i++) { + for (java.lang.annotation.Annotation anno : paramAnnos[i]) { + String desc = "L" + anno.annotationType().getName().replace('.', '/') + ";"; + if (targets.containsKey(desc)) return true; + } + + ClassDesc pTypeDesc = ClassDesc.ofDescriptor(paramTypes[i].descriptorString()); + if (TypeKind.from(pTypeDesc) == TypeKind.REFERENCE && defaultTarget != null) { + boolean isOptedOut = false; + for (java.lang.annotation.Annotation anno : paramAnnos[i]) { + String desc = "L" + anno.annotationType().getName().replace('.', '/') + ";"; + if (optOutDescriptors.contains(desc)) { + isOptedOut = true; + break; + } + } + if (!isOptedOut) return true; + } + } + return false; + } + + @Override + public TargetAnnotation getBridgeParameterCheck(Method parentMethod, int paramIndex) { + java.lang.annotation.Annotation[] annos = parentMethod.getParameterAnnotations()[paramIndex]; + Class paramType = parentMethod.getParameterTypes()[paramIndex]; + + for (java.lang.annotation.Annotation anno : annos) { + String desc = "L" + anno.annotationType().getName().replace('.', '/') + ";"; + TargetAnnotation t = targets.get(desc); + if (t != null) return t; + } + + for (java.lang.annotation.Annotation anno : annos) { + String desc = "L" + anno.annotationType().getName().replace('.', '/') + ";"; + if (optOutDescriptors.contains(desc)) return null; + } + + ClassDesc pTypeDesc = ClassDesc.ofDescriptor(paramType.descriptorString()); + if (TypeKind.from(pTypeDesc) == TypeKind.REFERENCE) { + return defaultTarget; + } + return null; + } + + // --- Parsing Helpers --- + private List getMethodParamAnnotations(MethodModel method, int paramIndex) { + List result = new ArrayList<>(); + method + .findAttribute(Attributes.runtimeVisibleParameterAnnotations()) + .ifPresent( + attr -> { + List> all = attr.parameterAnnotations(); + if (paramIndex < all.size()) result.addAll(all.get(paramIndex)); + }); + method + .findAttribute(Attributes.runtimeVisibleTypeAnnotations()) + .ifPresent( + attr -> { + for (TypeAnnotation ta : attr.annotations()) { + if (ta.targetInfo() instanceof TypeAnnotation.FormalParameterTarget pt + && pt.formalParameterIndex() == paramIndex) result.add(ta.annotation()); + } + }); + return result; + } + + private List getFieldAnnotations(FieldModel field) { + List result = new ArrayList<>(); + field + .findAttribute(Attributes.runtimeVisibleAnnotations()) + .ifPresent(attr -> result.addAll(attr.annotations())); + field + .findAttribute(Attributes.runtimeVisibleTypeAnnotations()) + .ifPresent( + attr -> { + for (TypeAnnotation ta : attr.annotations()) { + if (ta.targetInfo().targetType() == TypeAnnotation.TargetType.FIELD) { + result.add(ta.annotation()); + } + } + }); + return result; + } + + private List getLocalVariableAnnotations(MethodModel method, int slot) { + List result = new ArrayList<>(); + method + .code() + .ifPresent( + code -> { + code.findAttribute(Attributes.runtimeVisibleTypeAnnotations()) + .ifPresent( + attr -> { + for (TypeAnnotation ta : attr.annotations()) { + if (ta.targetInfo() instanceof TypeAnnotation.LocalVarTarget localVar) { + for (TypeAnnotation.LocalVarTargetInfo info : localVar.table()) { + if (info.index() == slot) { + result.add(ta.annotation()); + } + } + } + } + }); + }); + return result; + } +} diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/resolution/HierarchyResolver.java b/framework/src/main/java/io/github/eisop/runtimeframework/resolution/HierarchyResolver.java new file mode 100644 index 0000000..32a757c --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/resolution/HierarchyResolver.java @@ -0,0 +1,22 @@ +package io.github.eisop.runtimeframework.resolution; + +import java.lang.classfile.ClassModel; +import java.lang.reflect.Method; +import java.util.Set; + +/** + * Responsible for analyzing the inheritance hierarchy of a class to identify methods that are + * inherited from "Unchecked" parents and require Bridge Methods. + */ +public interface HierarchyResolver { + + /** + * Identifies methods in the superclass hierarchy that: 1. Are NOT overridden by the current + * class. 2. Are NOT final/private/static. 3. Come from an "Unchecked" (unsafe) ancestor. + * + * @param model The class currently being instrumented. + * @param loader The ClassLoader to use for loading parent classes. + * @return A set of java.lang.reflect.Method objects representing the targets for bridging. + */ + Set resolveUncheckedMethods(ClassModel model, ClassLoader loader); +} diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/resolution/ReflectionHierarchyResolver.java b/framework/src/main/java/io/github/eisop/runtimeframework/resolution/ReflectionHierarchyResolver.java new file mode 100644 index 0000000..2b23e7f --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/resolution/ReflectionHierarchyResolver.java @@ -0,0 +1,74 @@ +package io.github.eisop.runtimeframework.resolution; + +import io.github.eisop.runtimeframework.filter.Filter; +import java.lang.classfile.ClassModel; +import java.lang.classfile.MethodModel; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.HashSet; +import java.util.Set; + +public class ReflectionHierarchyResolver implements HierarchyResolver { + + private final Filter safetyFilter; + + public ReflectionHierarchyResolver(Filter safetyFilter) { + this.safetyFilter = safetyFilter; + } + + @Override + public Set resolveUncheckedMethods(ClassModel model, ClassLoader loader) { + Set bridgesNeeded = new HashSet<>(); + Set implementedSignatures = new HashSet<>(); + + for (MethodModel mm : model.methods()) { + implementedSignatures.add( + mm.methodName().stringValue() + mm.methodTypeSymbol().descriptorString()); + } + + String superName = + model + .superclass() + .map(sc -> sc.asInternalName().replace('/', '.')) + .orElse("java.lang.Object"); + if ("java.lang.Object".equals(superName)) return bridgesNeeded; + + try { + Class currentAncestor = Class.forName(superName, false, loader); + + while (currentAncestor != null && currentAncestor != Object.class) { + if (safetyFilter.test(currentAncestor.getName())) { + break; + } + + for (Method m : currentAncestor.getDeclaredMethods()) { + int mods = m.getModifiers(); + if (Modifier.isFinal(mods) || Modifier.isStatic(mods) || Modifier.isPrivate(mods)) + continue; + if (m.isSynthetic() || m.isBridge()) continue; + String sig = m.getName() + getMethodDescriptor(m); + if (implementedSignatures.contains(sig)) continue; + + implementedSignatures.add(sig); + bridgesNeeded.add(m); + } + currentAncestor = currentAncestor.getSuperclass(); + } + } catch (ClassNotFoundException e) { + System.err.println( + "[RuntimeFramework] Could not resolve hierarchy for: " + + model.thisClass().asInternalName()); + } + return bridgesNeeded; + } + + private String getMethodDescriptor(Method m) { + StringBuilder sb = new StringBuilder("("); + for (Class p : m.getParameterTypes()) { + sb.append(p.descriptorString()); + } + sb.append(")"); + sb.append(m.getReturnType().descriptorString()); + return sb.toString(); + } +} diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/runtime/LoggingViolationHandler.java b/framework/src/main/java/io/github/eisop/runtimeframework/runtime/LoggingViolationHandler.java new file mode 100644 index 0000000..df4fec8 --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/runtime/LoggingViolationHandler.java @@ -0,0 +1,25 @@ +package io.github.eisop.runtimeframework.runtime; + +import java.io.PrintStream; + +/** + * A violation handler that logs errors to a PrintStream (stderr by default) instead of crashing the + * application. + */ +public class LoggingViolationHandler implements ViolationHandler { + + private final PrintStream out; + + public LoggingViolationHandler() { + this(System.err); + } + + public LoggingViolationHandler(PrintStream out) { + this.out = out; + } + + @Override + public void handleViolation(String checkerName, String message) { + out.printf("[RuntimeFramework - %s] %s%n", checkerName, message); + } +} 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 new file mode 100644 index 0000000..77912c0 --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/runtime/RuntimeVerifier.java @@ -0,0 +1,49 @@ +package io.github.eisop.runtimeframework.runtime; + +/** + * The abstract base class for all runtime verifiers. + * + *

This class serves as the central manager for the {@link ViolationHandler}. + */ +public abstract class RuntimeVerifier { + + // Default to a fail-fast strategy (crashing the application). + private static volatile ViolationHandler handler; + + static { + // 1. Try to load from System Property + String handlerClass = System.getProperty("runtime.handler"); + if (handlerClass != null && !handlerClass.isBlank()) { + try { + Class clazz = Class.forName(handlerClass); + handler = (ViolationHandler) clazz.getConstructor().newInstance(); + } catch (Exception e) { + System.err.println("[RuntimeFramework] Failed to instantiate handler: " + handlerClass); + e.printStackTrace(); + } + } + + // 2. Fallback to Default + if (handler == null) { + handler = new ThrowingViolationHandler(); + } + } + + /** + * Configures the global violation handler. + * + *

This method can be called by the application at startup to change the behavior of the + * runtime checks. + */ + public static void setViolationHandler(ViolationHandler newHandler) { + if (newHandler == null) { + throw new IllegalArgumentException("ViolationHandler cannot be null"); + } + handler = newHandler; + } + + /** Reports a violation to the current handler. */ + protected static void reportViolation(String checkerName, String message) { + handler.handleViolation(checkerName, message); + } +} diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/runtime/ThrowingViolationHandler.java b/framework/src/main/java/io/github/eisop/runtimeframework/runtime/ThrowingViolationHandler.java new file mode 100644 index 0000000..9ae0600 --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/runtime/ThrowingViolationHandler.java @@ -0,0 +1,10 @@ +package io.github.eisop.runtimeframework.runtime; + +/** A violation handler that throws a RuntimeException when a check fails. */ +public class ThrowingViolationHandler implements ViolationHandler { + + @Override + public void handleViolation(String checkerName, String message) { + throw new RuntimeException(String.format("[%s Violation] %s", checkerName, message)); + } +} diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/runtime/ViolationHandler.java b/framework/src/main/java/io/github/eisop/runtimeframework/runtime/ViolationHandler.java new file mode 100644 index 0000000..7f2e957 --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/runtime/ViolationHandler.java @@ -0,0 +1,13 @@ +package io.github.eisop.runtimeframework.runtime; + +/** Defines the strategy for handling runtime verification violations. */ +public interface ViolationHandler { + + /** + * Handle a reported violation. + * + * @param checkerName The name of the checker that detected the violation + * @param message The descriptive error message provided by the verification logic + */ + void handleViolation(String checkerName, String message); +} diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/util/AnnotatedFor.java b/framework/src/main/java/io/github/eisop/runtimeframework/util/AnnotatedFor.java new file mode 100644 index 0000000..c9bb7a2 --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/util/AnnotatedFor.java @@ -0,0 +1,33 @@ +package io.github.eisop.runtimeframework.qual; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Indicates that this class has been annotated for the given type system. + * + *

This is a runtime-retention version of the Checker Framework's {@code + * org.checkerframework.framework.qual.AnnotatedFor}. It allows the Runtime Framework agent to + * detect which classes are intended to be checked at runtime without requiring build-time + * configuration flags. + * + * @see Original + * AnnotatedFor Documentation + */ +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target({ElementType.TYPE, ElementType.METHOD, ElementType.CONSTRUCTOR, ElementType.PACKAGE}) +public @interface AnnotatedFor { + /** + * Returns the type systems for which the class has been annotated. Legal arguments are any string + * that may be passed to the {@code -processor} command-line argument: the fully-qualified class + * name for the checker, or a shorthand for built-in checkers (e.g. "nullness"). + * + * @return the type systems for which the class has been annotated + */ + String[] value(); +} diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutInstrumenter.java b/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutInstrumenter.java new file mode 100644 index 0000000..8b256a8 --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutInstrumenter.java @@ -0,0 +1,104 @@ +package io.github.eisop.runtimeframework.util; + +import io.github.eisop.runtimeframework.core.RuntimeInstrumenter; +import java.lang.classfile.ClassBuilder; +import java.lang.classfile.ClassModel; +import java.lang.classfile.CodeBuilder; +import java.lang.classfile.MethodModel; +import java.lang.classfile.TypeKind; +import java.lang.classfile.instruction.ArrayLoadInstruction; +import java.lang.classfile.instruction.ArrayStoreInstruction; +import java.lang.classfile.instruction.FieldInstruction; +import java.lang.classfile.instruction.InvokeInstruction; +import java.lang.classfile.instruction.ReturnInstruction; +import java.lang.classfile.instruction.StoreInstruction; +import java.lang.constant.ClassDesc; +import java.lang.constant.MethodTypeDesc; + +public class SysOutInstrumenter extends RuntimeInstrumenter { + + private static final ClassDesc SYSOUT_SYSTEM = ClassDesc.of("java.lang.System"); + private static final ClassDesc SYSOUT_STREAM = ClassDesc.of("java.io.PrintStream"); + private static final MethodTypeDesc SYSOUT_PRINTLN = + MethodTypeDesc.ofDescriptor("(Ljava/lang/String;)V"); + + public SysOutInstrumenter() { + // Pass a filter that accepts everything for debug purposes + super(info -> true); + } + + private void print(CodeBuilder b, String msg) { + b.getstatic(SYSOUT_SYSTEM, "out", SYSOUT_STREAM); + b.ldc(msg); + b.invokevirtual(SYSOUT_STREAM, "println", SYSOUT_PRINTLN); + } + + @Override + protected void generateParameterCheck( + CodeBuilder b, int slotIndex, TypeKind type, MethodModel method, int paramIndex) { + print(b, " [Param Check] Verifying argument at slot " + slotIndex); + } + + @Override + protected void generateFieldWriteCheck( + CodeBuilder b, FieldInstruction field, ClassModel classModel) { + print(b, " [Field Write] About to write to: " + field.name().stringValue()); + } + + @Override + protected void generateFieldReadCheck( + CodeBuilder b, FieldInstruction field, ClassModel classModel) { + print(b, " [Field Read] Just read from: " + field.name().stringValue()); + } + + @Override + protected void generateReturnCheck(CodeBuilder b, ReturnInstruction ret, MethodModel method) { + print(b, " [Return Check] Returning from method via opcode: " + ret.opcode().name()); + } + + @Override + protected void generateUncheckedReturnCheck( + CodeBuilder b, + ReturnInstruction ret, + MethodModel method, + ClassModel classModel, + ClassLoader loader) { + print( + b, + " [Unchecked Override Return Check] Checking return of overridden method: " + + method.methodName().stringValue()); + } + + @Override + protected void generateMethodCallCheck(CodeBuilder b, InvokeInstruction invoke) { + print( + b, + " [Call Site] Just called: " + + invoke.owner().asInternalName() + + "." + + invoke.name().stringValue()); + } + + @Override + protected void generateBridgeMethods(ClassBuilder builder, ClassModel model, ClassLoader loader) { + // Debug instrumenter does not generate bridges, but we can log that the hook was hit + // System.out.println("[SysOutInstrumenter] Bridge hook triggered for: " + + // model.thisClass().asInternalName()); + } + + @Override + protected void generateArrayLoadCheck(CodeBuilder b, ArrayLoadInstruction instruction) { + print(b, " [Array Load] Reading from array"); + } + + @Override + protected void generateArrayStoreCheck(CodeBuilder b, ArrayStoreInstruction instruction) { + print(b, " [Array Store] Writing to array"); + } + + @Override + protected void generateStoreCheck( + CodeBuilder b, StoreInstruction instruction, MethodModel method) { + print(b, " [Local Store] Writing to slot " + instruction.slot()); + } +} diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutRuntimeChecker.java b/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutRuntimeChecker.java new file mode 100644 index 0000000..561bba8 --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutRuntimeChecker.java @@ -0,0 +1,20 @@ +package io.github.eisop.runtimeframework.util; + +import io.github.eisop.runtimeframework.core.RuntimeChecker; +import io.github.eisop.runtimeframework.core.RuntimeInstrumenter; +import io.github.eisop.runtimeframework.filter.ClassInfo; +import io.github.eisop.runtimeframework.filter.Filter; + +public class SysOutRuntimeChecker extends RuntimeChecker { + + @Override + public String getName() { + return "SysOut Debug Checker"; + } + + @Override + public RuntimeInstrumenter getInstrumenter(Filter filter) { + // SysOut instrumenter ignores the filter (it logs everything) + return new SysOutInstrumenter(); + } +} diff --git a/project-notes.org b/project-notes.org new file mode 100644 index 0000000..3398fd4 --- /dev/null +++ b/project-notes.org @@ -0,0 +1,18 @@ +#+TITLE: Project Notes +#+AUTHOR: Alex Cook + +* Building and Running an agent + +Agent currently lives in the framework subproject, so build project then create jar with: + +#+BEGIN_SRC bash +./gradlew :framework:jar +#+END_SRC + +Run agent with the following command: + +#+BEGIN_SRC bash + java -javaagent:framework/build/libs/framework.jar \ + -cp ~/scratch \ + Hello +#+END_SRC diff --git a/settings.gradle b/settings.gradle index ac01898..57e51d7 100644 --- a/settings.gradle +++ b/settings.gradle @@ -8,3 +8,5 @@ rootProject.name = 'runtime-framework' include 'framework' include 'docs' +include 'checker' +include 'test-utils' diff --git a/test-utils/build.gradle b/test-utils/build.gradle new file mode 100644 index 0000000..98dd431 --- /dev/null +++ b/test-utils/build.gradle @@ -0,0 +1,9 @@ +plugins { + id 'java-library' +} + +dependencies { + api project(':framework') + api platform('org.junit:junit-bom:5.10.0') + api 'org.junit.jupiter:junit-jupiter' +} 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 new file mode 100644 index 0000000..a84c717 --- /dev/null +++ b/test-utils/src/main/java/io/github/eisop/testutils/AgentTestHarness.java @@ -0,0 +1,193 @@ +package io.github.eisop.testutils; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.TimeUnit; +import java.util.stream.Stream; + +public abstract class AgentTestHarness { + + protected Path tempDir; + protected Path distDir; + + protected void setup() throws IOException { + this.tempDir = Files.createTempDirectory("eisop-agent-test"); + String distPath = System.getProperty("agent.dist.dir"); + if (distPath == null) { + Path potentialDist = + Path.of(System.getProperty("user.dir")).resolve("../build/dist").normalize(); + if (Files.exists(potentialDist)) { + distPath = potentialDist.toString(); + } else { + throw new IllegalStateException( + "System property 'agent.dist.dir' not set. Run via Gradle or set property."); + } + } + this.distDir = Path.of(distPath); + } + + @SuppressWarnings("EmptyCatch") + protected void cleanup() throws IOException { + try (Stream walk = Files.walk(tempDir)) { + walk.sorted((a, b) -> b.compareTo(a)) + .forEach( + p -> { + try { + Files.delete(p); + } catch (IOException e) { + } + }); + } + } + + protected void copyTestFile(String resourcePath) throws IOException { + String fullPath = "test-cases/" + resourcePath; + try (InputStream is = getClass().getClassLoader().getResourceAsStream(fullPath)) { + if (is == null) { + Path fsPath = Path.of("src/test/resources/" + fullPath); + if (Files.exists(fsPath)) { + copyFileFromDisk(fsPath, resourcePath); + return; + } + throw new IOException("Test resource not found: " + fullPath); + } + Path dest = tempDir.resolve(resourcePath); + Files.createDirectories(dest.getParent()); + Files.copy(is, dest, StandardCopyOption.REPLACE_EXISTING); + } + } + + private void copyFileFromDisk(Path source, String relativeDest) throws IOException { + Path dest = tempDir.resolve(relativeDest); + Files.createDirectories(dest.getParent()); + Files.copy(source, dest, StandardCopyOption.REPLACE_EXISTING); + } + + protected void writeSource(String filename, String content) throws IOException { + Path file = tempDir.resolve(filename); + Files.createDirectories(file.getParent()); + Files.writeString(file, content, StandardOpenOption.CREATE); + } + + protected void compile(List filenames) throws Exception { + compile(filenames.toArray(String[]::new)); + } + + protected void compile(String... filenames) throws Exception { + compileWithClasspath(null, filenames); + } + + protected void compileWithClasspath(String extraClasspath, String... filenames) throws Exception { + Path qualJar = findJar("checker-qual"); + Path frameworkJar = findJar("framework"); + + String cp = + qualJar.toAbsolutePath().toString() + ":" + frameworkJar.toAbsolutePath().toString(); + + if (extraClasspath != null) { + cp += ":" + extraClasspath; + } + + List cmd = new ArrayList<>(); + cmd.add("javac"); + cmd.add("-g"); + cmd.add("-cp"); + cmd.add(cp); + cmd.add("-d"); + cmd.add(tempDir.toAbsolutePath().toString()); + + for (String f : filenames) { + cmd.add(tempDir.resolve(f).toAbsolutePath().toString()); + } + + runProcess(cmd, "Compilation"); + } + + protected TestResult runAgent(String mainClass, String... agentArgs) throws Exception { + return runAgent(mainClass, false, agentArgs); + } + + protected TestResult runAgent(String mainClass, boolean isGlobal, String... agentArgs) + throws Exception { + Path frameworkJar = findJar("framework"); + Path checkerJar = findJar("checker"); + Path qualJar = findJar("checker-qual"); + Path testUtilsJar = findJar("test-utils"); + + String cp = + "." + + ":" + + frameworkJar.toAbsolutePath() + + ":" + + checkerJar.toAbsolutePath() + + ":" + + qualJar.toAbsolutePath() + + ":" + + testUtilsJar.toAbsolutePath(); + + List cmd = new ArrayList<>(); + cmd.add("java"); + cmd.add("--enable-preview"); + cmd.add("-javaagent:" + frameworkJar.toAbsolutePath()); + + if (isGlobal) { + cmd.add("-Druntime.global=true"); + } + + cmd.addAll(List.of(agentArgs)); + cmd.add("-cp"); + cmd.add(cp); + cmd.add(mainClass); + + return runProcess(cmd, "Agent Execution"); + } + + private Path findJar(String prefix) throws IOException { + try (Stream files = Files.list(distDir)) { + return files + .filter( + p -> { + String name = p.getFileName().toString(); + if (prefix.equals("checker") && name.startsWith("checker-qual")) { + return false; + } + return name.startsWith(prefix) && name.endsWith(".jar"); + }) + .findFirst() + .orElseThrow( + () -> + new IllegalStateException( + "Could not find jar starting with " + prefix + " in " + distDir)); + } + } + + private TestResult runProcess(List cmd, String taskName) throws Exception { + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.directory(tempDir.toFile()); + Process p = pb.start(); + + String stdout = new String(p.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + String stderr = new String(p.getErrorStream().readAllBytes(), StandardCharsets.UTF_8); + + boolean finished = p.waitFor(10, TimeUnit.SECONDS); + if (!finished) { + p.destroy(); + throw new RuntimeException(taskName + " timed out."); + } + + if (p.exitValue() != 0 && taskName.equals("Compilation")) { + throw new RuntimeException("Compilation Failed:\n" + stderr); + } + + return new TestResult(p.exitValue(), stdout, stderr); + } + + protected record TestResult(int exitCode, String stdout, String stderr) {} +} diff --git a/test-utils/src/main/java/io/github/eisop/testutils/ExpectedError.java b/test-utils/src/main/java/io/github/eisop/testutils/ExpectedError.java new file mode 100644 index 0000000..f1c161b --- /dev/null +++ b/test-utils/src/main/java/io/github/eisop/testutils/ExpectedError.java @@ -0,0 +1,8 @@ +package io.github.eisop.testutils; + +public record ExpectedError(String filename, long lineNumber, String expectedMessage) { + @Override + public String toString() { + return filename + ":" + lineNumber + ": " + expectedMessage; + } +} 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 new file mode 100644 index 0000000..692b5de --- /dev/null +++ b/test-utils/src/main/java/io/github/eisop/testutils/RuntimeTestRunner.java @@ -0,0 +1,171 @@ +package io.github.eisop.testutils; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Assertions; + +public class RuntimeTestRunner extends AgentTestHarness { + + private static final Pattern ERROR_PATTERN = Pattern.compile("//\\s*::\\s*error:\\s*\\((.*)\\)"); + + public void runDirectoryTest(String dirName, String checkerClass, boolean isGlobal) + throws Exception { + setup(); + try { + String resourcePath = "test-cases/" + dirName; + Path resourceDir = Path.of("src/test/resources/" + resourcePath); + + if (!Files.exists(resourceDir)) { + resourceDir = Path.of("checker/src/test/resources/" + resourcePath); + } + + if (!Files.exists(resourceDir)) { + throw new IOException("Test directory not found: " + resourceDir.toAbsolutePath()); + } + + List javaFiles; + try (var stream = Files.walk(resourceDir)) { + javaFiles = stream.filter(p -> p.toString().endsWith(".java")).collect(Collectors.toList()); + } + + if (javaFiles.isEmpty()) return; + + List fileNames = new ArrayList<>(); + for (Path p : javaFiles) { + String fname = p.getFileName().toString(); + Files.copy(p, tempDir.resolve(fname), StandardCopyOption.REPLACE_EXISTING); + fileNames.add(fname); + } + + compile(fileNames); + + List mainFiles = new ArrayList<>(); + List helperFiles = new ArrayList<>(); + + for (Path sourcePath : javaFiles) { + String content = Files.readString(sourcePath); + if (content.contains("public static void main")) { + mainFiles.add(sourcePath); + } else { + helperFiles.add(sourcePath); + } + } + + for (Path mainSource : mainFiles) { + runSingleTest(mainSource, helperFiles, checkerClass, isGlobal); + } + + } finally { + cleanup(); + } + } + + private void runSingleTest( + Path mainSource, List helperFiles, String checkerClass, boolean isGlobal) + throws Exception { + System.out.println("Running test: " + mainSource.getFileName()); + + List expectedErrors = new ArrayList<>(); + expectedErrors.addAll(parseExpectedErrors(mainSource)); + for (Path helper : helperFiles) { + expectedErrors.addAll(parseExpectedErrors(helper)); + } + + String filename = mainSource.getFileName().toString(); + String mainClass = filename.replace(".java", ""); + + TestResult result = + runAgent( + mainClass, + isGlobal, + "-Druntime.checker=" + checkerClass, + "-Druntime.trustAnnotatedFor=true", + "-Druntime.handler=io.github.eisop.testutils.TestViolationHandler"); + + verifyErrors(expectedErrors, result.stdout(), filename); + } + + private List parseExpectedErrors(Path sourceFile) throws IOException { + String fileName = sourceFile.getFileName().toString(); + List lines = Files.readAllLines(sourceFile); + List errors = new ArrayList<>(); + for (int i = 0; i < lines.size(); i++) { + Matcher m = ERROR_PATTERN.matcher(lines.get(i)); + if (m.find()) { + errors.add(new ExpectedError(fileName, i + 1, m.group(1).trim())); + } + } + return errors; + } + + @SuppressWarnings("StringSplitter") + private void verifyErrors(List expected, String stdout, String testName) { + List actualErrors = new ArrayList<>(); + + stdout + .lines() + .forEach( + line -> { + if (line.startsWith("[VIOLATION]")) { + String[] parts = line.split(" "); + if (parts.length > 1) { + String fileLoc = parts[1]; + if (fileLoc.contains(":")) { + String[] locParts = fileLoc.split(":"); + String errFile = locParts[0]; + long lineNum = Long.parseLong(locParts[1]); + int msgStart = line.indexOf(") ") + 2; + String msg = (msgStart > 1) ? line.substring(msgStart) : ""; + actualErrors.add(new ExpectedError(errFile, lineNum, msg.trim())); + } + } + } + }); + + List unmatchedExpected = new ArrayList<>(expected); + List unmatchedActual = new ArrayList<>(actualErrors); + + unmatchedActual.removeIf( + act -> { + ExpectedError bestMatch = null; + for (ExpectedError exp : unmatchedExpected) { + if (!exp.filename().equals(act.filename())) continue; + if (exp.expectedMessage().equals(act.expectedMessage())) { + long diff = Math.abs(act.lineNumber() - exp.lineNumber()); + if (diff <= 5) { + bestMatch = exp; + break; + } + } + } + if (bestMatch != null) { + unmatchedExpected.remove(bestMatch); + return true; + } + return false; + }); + + if (!unmatchedExpected.isEmpty() || !unmatchedActual.isEmpty()) { + StringBuilder sb = new StringBuilder(); + sb.append("\n=== TEST FAILED: ").append(testName).append(" ===\n"); + if (!unmatchedExpected.isEmpty()) { + sb.append("Missing Expected Errors:\n"); + unmatchedExpected.forEach(e -> sb.append(" ").append(e).append("\n")); + } + if (!unmatchedActual.isEmpty()) { + sb.append("Unexpected Runtime Violations:\n"); + unmatchedActual.forEach(e -> sb.append(" ").append(e).append("\n")); + } + sb.append("\nFull Output:\n").append(stdout).append("\n"); + System.out.println(sb.toString()); + Assertions.fail("Verification failed. Mismatched errors."); + } + } +} diff --git a/test-utils/src/main/java/io/github/eisop/testutils/TestViolationHandler.java b/test-utils/src/main/java/io/github/eisop/testutils/TestViolationHandler.java new file mode 100644 index 0000000..9382c6c --- /dev/null +++ b/test-utils/src/main/java/io/github/eisop/testutils/TestViolationHandler.java @@ -0,0 +1,32 @@ +package io.github.eisop.testutils; + +import io.github.eisop.runtimeframework.runtime.ViolationHandler; + +/** + * A specialized handler for integration tests. Lives in test-utils so it doesn't pollute the + * production framework jar. + */ +public class TestViolationHandler implements ViolationHandler { + + @Override + public void handleViolation(String checkerName, String message) { + StackTraceElement caller = findCaller(); + String location = + (caller != null) ? caller.getFileName() + ":" + caller.getLineNumber() : "Unknown:0"; + + String output = String.format("[VIOLATION] %s (%s) %s", location, checkerName, message); + System.out.println(output); + } + + private StackTraceElement findCaller() { + return StackWalker.getInstance() + .walk( + stream -> + stream + .filter(f -> !f.getClassName().startsWith("io.github.eisop.runtimeframework")) + .filter(f -> !f.getClassName().startsWith("io.github.eisop.testutils")) + .findFirst() + .map(StackWalker.StackFrame::toStackTraceElement) + .orElse(null)); + } +}