From 41e9fae18ca8ff779779b61363a0e00f53854647 Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Tue, 18 Nov 2025 11:24:39 -0500 Subject: [PATCH 01/50] feat(framework): add class reader --- .../runtimeframework/agent/ClassReader.java | 28 +++++++++++++++++++ .../runtimeframework/agent/RuntimeAgent.java | 4 +-- 2 files changed, 30 insertions(+), 2 deletions(-) create mode 100644 framework/src/main/java/io/github/eisop/runtimeframework/agent/ClassReader.java diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/agent/ClassReader.java b/framework/src/main/java/io/github/eisop/runtimeframework/agent/ClassReader.java new file mode 100644 index 0000000..0fa07a2 --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/agent/ClassReader.java @@ -0,0 +1,28 @@ +package io.github.eisop.runtimeframework.agent; + +import java.lang.instrument.ClassFileTransformer; +import java.security.ProtectionDomain; + +final class ClassReader implements ClassFileTransformer { + + @Override + public byte[] transform( + Module module, + ClassLoader loader, + String internalName, + Class classBeingRedefined, + ProtectionDomain pd, + byte[] buf) { + + if (internalName == null) return null; + + System.out.printf( + "[agent] %s (module=%s, loader=%s, size=%d)%n", + internalName, + module != null ? module.getName() : "null", + loader != null ? loader.getClass().getName() : "bootstrap", + buf != null ? buf.length : -1); + + return null; + } +} 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..6873e95 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,7 @@ package io.github.eisop.runtimeframework.agent; -public class RuntimeAgent { +public final class RuntimeAgent { public static void premain(String args, java.lang.instrument.Instrumentation inst) { - System.out.println("Agent Initialized"); + inst.addTransformer(new ClassReader(), false); } } From 715a4fda24e9ce32eccdf827c41e329183bae1ab Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Tue, 18 Nov 2025 11:50:52 -0500 Subject: [PATCH 02/50] feat(fraomework): introduce filter interface --- .../eisop/runtimeframework/filter/Filter.java | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 framework/src/main/java/io/github/eisop/runtimeframework/filter/Filter.java 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..4034e25 --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/filter/Filter.java @@ -0,0 +1,27 @@ +package io.github.eisop.runtimeframework.filter; + +@FunctionalInterface +public interface Filter { + + boolean test(C ctx); + + default Filter and(Filter other) { + return c -> this.test(c) && other.test(c); + } + + default Filter or(Filter other) { + return c -> this.test(c) || other.test(c); + } + + default Filter not() { + return c -> !this.test(c); + } + + static Filter acceptAll() { + return c -> true; + } + + static Filter rejectAll() { + return c -> false; + } +} From 759402e5efe0a8c142cd310cf5e52be26007fa34 Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Tue, 18 Nov 2025 11:52:55 -0500 Subject: [PATCH 03/50] feat(framework): add a prototype Filter implementation - ClassListFilter --- .../runtimeframework/agent/ClassReader.java | 16 ++++++ .../runtimeframework/agent/RuntimeAgent.java | 8 ++- .../runtimeframework/filter/ClassInfo.java | 3 ++ .../filter/ClassListFilter.java | 54 +++++++++++++++++++ 4 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 framework/src/main/java/io/github/eisop/runtimeframework/filter/ClassInfo.java create mode 100644 framework/src/main/java/io/github/eisop/runtimeframework/filter/ClassListFilter.java diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/agent/ClassReader.java b/framework/src/main/java/io/github/eisop/runtimeframework/agent/ClassReader.java index 0fa07a2..e817aa1 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/agent/ClassReader.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/agent/ClassReader.java @@ -1,10 +1,21 @@ package io.github.eisop.runtimeframework.agent; +import io.github.eisop.runtimeframework.filter.ClassInfo; +import io.github.eisop.runtimeframework.filter.ClassListFilter; +import io.github.eisop.runtimeframework.filter.Filter; import java.lang.instrument.ClassFileTransformer; import java.security.ProtectionDomain; +import java.util.List; final class ClassReader implements ClassFileTransformer { + private final Filter classFilter; + + ClassReader(List onlyTheseInternalOrDotNames) { + + this.classFilter = new ClassListFilter(onlyTheseInternalOrDotNames); + } + @Override public byte[] transform( Module module, @@ -16,6 +27,11 @@ public byte[] transform( if (internalName == null) return null; + if (!classFilter.test(new ClassInfo(internalName, loader, module))) { + + return null; // ignore everything not on the allow-list + } + System.out.printf( "[agent] %s (module=%s, loader=%s, size=%d)%n", internalName, 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 6873e95..eb8a8b2 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,11 @@ package io.github.eisop.runtimeframework.agent; +import java.lang.instrument.Instrumentation; +import java.util.List; + public final class RuntimeAgent { - public static void premain(String args, java.lang.instrument.Instrumentation inst) { - inst.addTransformer(new ClassReader(), false); + + public static void premain(String args, Instrumentation inst) { + inst.addTransformer(new ClassReader(List.of("HelloWorld")), false); } } 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..bf868b1 --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/filter/ClassListFilter.java @@ -0,0 +1,54 @@ +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). + * + *

Names may be given as: + * + *

- "io.github.eisop.Foo" + * + *

- "io/github/eisop/Foo" + * + *

- "io/github/eisop/Foo.class" + * + *

All are normalized to internal names like "io/github/eisop/Foo". + */ +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; + } +} From ef15d4ad8afd46bbee596b84358377ecaa7f4a46 Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Tue, 18 Nov 2025 11:56:08 -0500 Subject: [PATCH 04/50] refactor(framework): make Filter extend Predicate --- .../eisop/runtimeframework/filter/Filter.java | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) 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 index 4034e25..bdb7e2e 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/filter/Filter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/filter/Filter.java @@ -1,21 +1,9 @@ package io.github.eisop.runtimeframework.filter; -@FunctionalInterface -public interface Filter { - - boolean test(C ctx); - - default Filter and(Filter other) { - return c -> this.test(c) && other.test(c); - } +import java.util.function.Predicate; - default Filter or(Filter other) { - return c -> this.test(c) || other.test(c); - } - - default Filter not() { - return c -> !this.test(c); - } +@FunctionalInterface +public interface Filter extends Predicate { static Filter acceptAll() { return c -> true; From 8ecb938c7f7728f747f082142729ad091d5dadfb Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Tue, 18 Nov 2025 12:04:16 -0500 Subject: [PATCH 05/50] refactor(framework): update class reader to use generic Filter --- .../github/eisop/runtimeframework/agent/ClassReader.java | 7 ++----- .../github/eisop/runtimeframework/agent/RuntimeAgent.java | 4 +++- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/agent/ClassReader.java b/framework/src/main/java/io/github/eisop/runtimeframework/agent/ClassReader.java index e817aa1..7b1d897 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/agent/ClassReader.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/agent/ClassReader.java @@ -1,19 +1,16 @@ package io.github.eisop.runtimeframework.agent; import io.github.eisop.runtimeframework.filter.ClassInfo; -import io.github.eisop.runtimeframework.filter.ClassListFilter; import io.github.eisop.runtimeframework.filter.Filter; import java.lang.instrument.ClassFileTransformer; import java.security.ProtectionDomain; -import java.util.List; final class ClassReader implements ClassFileTransformer { private final Filter classFilter; - ClassReader(List onlyTheseInternalOrDotNames) { - - this.classFilter = new ClassListFilter(onlyTheseInternalOrDotNames); + ClassReader(Filter filter) { + this.classFilter = filter; } @Override 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 eb8a8b2..17e23de 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,11 +1,13 @@ package io.github.eisop.runtimeframework.agent; +import io.github.eisop.runtimeframework.filter.ClassListFilter; import java.lang.instrument.Instrumentation; import java.util.List; public final class RuntimeAgent { public static void premain(String args, Instrumentation inst) { - inst.addTransformer(new ClassReader(List.of("HelloWorld")), false); + var filter = new ClassListFilter(List.of("HelloWorld")); + inst.addTransformer(new ClassReader(filter), false); } } From f04d36bd1c9f3a58992dd3e8f90f8dd5078fff75 Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Tue, 18 Nov 2025 12:32:16 -0500 Subject: [PATCH 06/50] doc: add project notes --- project-notes.org | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 project-notes.org 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 From 4b52083824b60bae3e542ea1c40634ea7feb33c8 Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Wed, 19 Nov 2025 14:56:23 -0500 Subject: [PATCH 07/50] refactor(framework): turn ClassReader into RuntimeTransformer --- .../runtimeframework/agent/ClassReader.java | 41 --------------- .../runtimeframework/agent/RuntimeAgent.java | 2 +- .../agent/RuntimeTransformer.java | 51 +++++++++++++++++++ 3 files changed, 52 insertions(+), 42 deletions(-) delete mode 100644 framework/src/main/java/io/github/eisop/runtimeframework/agent/ClassReader.java create mode 100644 framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/agent/ClassReader.java b/framework/src/main/java/io/github/eisop/runtimeframework/agent/ClassReader.java deleted file mode 100644 index 7b1d897..0000000 --- a/framework/src/main/java/io/github/eisop/runtimeframework/agent/ClassReader.java +++ /dev/null @@ -1,41 +0,0 @@ -package io.github.eisop.runtimeframework.agent; - -import io.github.eisop.runtimeframework.filter.ClassInfo; -import io.github.eisop.runtimeframework.filter.Filter; -import java.lang.instrument.ClassFileTransformer; -import java.security.ProtectionDomain; - -final class ClassReader implements ClassFileTransformer { - - private final Filter classFilter; - - ClassReader(Filter filter) { - this.classFilter = filter; - } - - @Override - public byte[] transform( - Module module, - ClassLoader loader, - String internalName, - Class classBeingRedefined, - ProtectionDomain pd, - byte[] buf) { - - if (internalName == null) return null; - - if (!classFilter.test(new ClassInfo(internalName, loader, module))) { - - return null; // ignore everything not on the allow-list - } - - System.out.printf( - "[agent] %s (module=%s, loader=%s, size=%d)%n", - internalName, - module != null ? module.getName() : "null", - loader != null ? loader.getClass().getName() : "bootstrap", - buf != null ? buf.length : -1); - - return null; - } -} 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 17e23de..c901065 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 @@ -8,6 +8,6 @@ public final class RuntimeAgent { public static void premain(String args, Instrumentation inst) { var filter = new ClassListFilter(List.of("HelloWorld")); - inst.addTransformer(new ClassReader(filter), false); + inst.addTransformer(new RuntimeTransformer(filter), 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..77fdfd5 --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java @@ -0,0 +1,51 @@ +package io.github.eisop.runtimeframework.agent; + +import io.github.eisop.runtimeframework.filter.ClassInfo; +import io.github.eisop.runtimeframework.filter.Filter; +import java.lang.classfile.ClassFile; +import java.lang.classfile.ClassModel; +import java.lang.classfile.ClassTransform; +import java.lang.instrument.ClassFileTransformer; +import java.security.ProtectionDomain; + +public class RuntimeTransformer implements ClassFileTransformer { + + private final Filter filter; + + public RuntimeTransformer(Filter filter) { + this.filter = filter; + } + + @Override + public byte[] transform( + Module module, + ClassLoader loader, + String className, + Class classBeingRedefined, + ProtectionDomain protectionDomain, + byte[] classfileBuffer) { + + // 1. Wrap context + ClassInfo info = new ClassInfo(className, loader, module); + + // 2. Check Filter + if (!filter.test(info)) { + return null; + } + + // 3. Output matched class + System.out.println("[RuntimeFramework] Filter matched: " + className); + + // 4. Perform transform + try { + ClassFile cf = ClassFile.of(); + ClassModel classModel = cf.parse(classfileBuffer); + return cf.transformClass(classModel, ClassTransform.ACCEPT_ALL); + + } catch (Exception e) { + System.err.println("[RuntimeFramework] Failed to parse: " + className); + e.printStackTrace(); + return null; + } + } +} From 843485c6a3c8bbefe404f76ca26c102ee8f6a4bc Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Thu, 20 Nov 2025 14:02:36 -0500 Subject: [PATCH 08/50] feat(framework): introduce RuntimeInstrumenter abstract class with concrete debugging implementation SysOutInstrumenter --- .../agent/RuntimeTransformer.java | 6 +- .../core/RuntimeInstrumenter.java | 92 +++++++++++++++++++ .../util/SysOutInstrumenter.java | 44 +++++++++ 3 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeInstrumenter.java create mode 100644 framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutInstrumenter.java diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java b/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java index 77fdfd5..c986023 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java @@ -1,10 +1,11 @@ package io.github.eisop.runtimeframework.agent; +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.util.SysOutInstrumenter; import java.lang.classfile.ClassFile; import java.lang.classfile.ClassModel; -import java.lang.classfile.ClassTransform; import java.lang.instrument.ClassFileTransformer; import java.security.ProtectionDomain; @@ -40,7 +41,8 @@ public byte[] transform( try { ClassFile cf = ClassFile.of(); ClassModel classModel = cf.parse(classfileBuffer); - return cf.transformClass(classModel, ClassTransform.ACCEPT_ALL); + RuntimeInstrumenter instrumenter = new SysOutInstrumenter(); + return cf.transformClass(classModel, instrumenter.asClassTransform()); } catch (Exception e) { System.err.println("[RuntimeFramework] Failed to parse: " + className); 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..12df095 --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeInstrumenter.java @@ -0,0 +1,92 @@ +package io.github.eisop.runtimeframework.core; + +import java.lang.classfile.ClassTransform; +import java.lang.classfile.CodeBuilder; +import java.lang.classfile.CodeElement; +import java.lang.classfile.MethodModel; +import java.lang.classfile.TypeKind; +import java.lang.classfile.attribute.CodeAttribute; +import java.lang.constant.MethodTypeDesc; +import java.lang.reflect.Modifier; + +/** The "Worker" that visits methods and injects bytecode. */ +public abstract class RuntimeInstrumenter { + + // We will use this later to decide *if* we check a parameter + // protected final RuntimeCheckPolicy policy; + + // For now, let's just get the traversal working without the policy complexity + public RuntimeInstrumenter() {} + + /** The main entry point. This returns the function we pass to 'transformClass'. */ + public ClassTransform asClassTransform() { + return (classBuilder, classElement) -> { + + // 1. Filter: Is this element a Method? + if (classElement instanceof MethodModel methodModel) { + + // 2. Filter: Does it have a body? (Abstract/Native methods don't) + if (methodModel.code().isPresent()) { + + // 3. Rebuild the method + // We recreate the method structure so we can modify its contents + classBuilder.withMethod( + methodModel.methodName(), + methodModel.methodType(), + methodModel.flags().flagsMask(), + methodBuilder -> { + // Iterate over the method's internals (Annotations, Code, etc.) + for (var element : methodModel) { + if (element instanceof CodeAttribute code) { + // FOUND THE CODE! -> Rewrite it + methodBuilder.withCode( + codeBuilder -> { + + // A. Inject our custom checks at the very top + instrumentMethodEntry(codeBuilder, methodModel); + + // B. Copy the original instructions + for (CodeElement ce : code) { + codeBuilder.with(ce); + } + }); + } else { + // Copy annotations/attributes as-is + methodBuilder.with(element); + } + } + }); + } else { + // It's abstract or native -> Just copy it + classBuilder.with(classElement); + } + } else { + // It's a Field or Class Attribute -> Just copy it + classBuilder.with(classElement); + } + }; + } + + /** Helper to calculate slot indices and delegate to the specific checker. */ + protected void instrumentMethodEntry(CodeBuilder builder, MethodModel method) { + // Calculate slot index (Static methods start at 0, Instance methods at 1 for 'this') + 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)); + + // Call the abstract method to let the subclass insert checks + generateCheck(builder, slotIndex, type); + + // Advance slot (Double/Long take 2 slots) + slotIndex += type.slotSize(); + } + } + + /** The "Hole" to be filled by the specific checker (e.g. NullnessChecker). */ + protected abstract void generateCheck(CodeBuilder builder, int slotIndex, TypeKind type); +} 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..61e5c6b --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutInstrumenter.java @@ -0,0 +1,44 @@ +package io.github.eisop.runtimeframework.util; + +import io.github.eisop.runtimeframework.core.RuntimeInstrumenter; +import java.lang.classfile.CodeBuilder; +import java.lang.classfile.TypeKind; +import java.lang.constant.ClassDesc; +import java.lang.constant.MethodTypeDesc; + +/** + * A concrete instrumenter used for testing and debugging. It injects a System.out.println at the + * start of every method. + */ +public class SysOutInstrumenter extends RuntimeInstrumenter { + + public SysOutInstrumenter() { + super(); + } + + /** + * Generates bytecode instructions to print a diagnostic message to standard output. + *

+ * The generated bytecode corresponds to the Java statement: + *

{@code
+     * System.out.println("RuntimeCheck: Verifying param at slot " + slotIndex);
+     * }
+ *

+ * This implementation ignores the actual type of the parameter and simply reports + * which slot is being visited. + * + * @param b the {@link CodeBuilder} used to emit the bytecode instructions + * @param slotIndex the local variable slot index of the parameter currently being "checked" + * @param type the {@link TypeKind} of the parameter (unused in this implementation, + * but required by the contract) + */ + @Override + protected void generateCheck(CodeBuilder b, int slotIndex, TypeKind type) { + b.getstatic(ClassDesc.of("java.lang.System"), "out", ClassDesc.of("java.io.PrintStream")); + b.ldc("RuntimeCheck: Verifying param at slot " + slotIndex); + b.invokevirtual( + ClassDesc.of("java.io.PrintStream"), + "println", + MethodTypeDesc.ofDescriptor("(Ljava/lang/String;)V")); + } +} From c851b0dc4a88da892d00e94275de864bf8d7c998 Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Thu, 20 Nov 2025 14:04:40 -0500 Subject: [PATCH 09/50] chore: spotless formatting --- .../util/SysOutInstrumenter.java | 57 ++++++++++--------- 1 file changed, 29 insertions(+), 28 deletions(-) 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 index 61e5c6b..d906fe7 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutInstrumenter.java @@ -12,33 +12,34 @@ */ public class SysOutInstrumenter extends RuntimeInstrumenter { - public SysOutInstrumenter() { - super(); - } + public SysOutInstrumenter() { + super(); + } - /** - * Generates bytecode instructions to print a diagnostic message to standard output. - *

- * The generated bytecode corresponds to the Java statement: - *

{@code
-     * System.out.println("RuntimeCheck: Verifying param at slot " + slotIndex);
-     * }
- *

- * This implementation ignores the actual type of the parameter and simply reports - * which slot is being visited. - * - * @param b the {@link CodeBuilder} used to emit the bytecode instructions - * @param slotIndex the local variable slot index of the parameter currently being "checked" - * @param type the {@link TypeKind} of the parameter (unused in this implementation, - * but required by the contract) - */ - @Override - protected void generateCheck(CodeBuilder b, int slotIndex, TypeKind type) { - b.getstatic(ClassDesc.of("java.lang.System"), "out", ClassDesc.of("java.io.PrintStream")); - b.ldc("RuntimeCheck: Verifying param at slot " + slotIndex); - b.invokevirtual( - ClassDesc.of("java.io.PrintStream"), - "println", - MethodTypeDesc.ofDescriptor("(Ljava/lang/String;)V")); - } + /** + * Generates bytecode instructions to print a diagnostic message to standard output. + * + *

The generated bytecode corresponds to the Java statement: + * + *

{@code
+   * System.out.println("RuntimeCheck: Verifying param at slot " + slotIndex);
+   * }
+ * + *

This implementation ignores the actual type of the parameter and simply reports which slot + * is being visited. + * + * @param b the {@link CodeBuilder} used to emit the bytecode instructions + * @param slotIndex the local variable slot index of the parameter currently being "checked" + * @param type the {@link TypeKind} of the parameter (unused in this implementation, but required + * by the contract) + */ + @Override + protected void generateCheck(CodeBuilder b, int slotIndex, TypeKind type) { + b.getstatic(ClassDesc.of("java.lang.System"), "out", ClassDesc.of("java.io.PrintStream")); + b.ldc("RuntimeCheck: Verifying param at slot " + slotIndex); + b.invokevirtual( + ClassDesc.of("java.io.PrintStream"), + "println", + MethodTypeDesc.ofDescriptor("(Ljava/lang/String;)V")); + } } From ff8663f70fb74f881def0095e840953ffc74fbc9 Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Thu, 20 Nov 2025 14:11:14 -0500 Subject: [PATCH 10/50] feat(framework): add FrameworkSafetyFilter to ignore JDK and framework files --- .../runtimeframework/agent/RuntimeAgent.java | 5 ++-- .../filter/FrameworkSafetyFilter.java | 26 +++++++++++++++++++ 2 files changed, 28 insertions(+), 3 deletions(-) create mode 100644 framework/src/main/java/io/github/eisop/runtimeframework/filter/FrameworkSafetyFilter.java 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 c901065..d330b9f 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,13 +1,12 @@ package io.github.eisop.runtimeframework.agent; -import io.github.eisop.runtimeframework.filter.ClassListFilter; +import io.github.eisop.runtimeframework.filter.FrameworkSafetyFilter; import java.lang.instrument.Instrumentation; -import java.util.List; public final class RuntimeAgent { public static void premain(String args, Instrumentation inst) { - var filter = new ClassListFilter(List.of("HelloWorld")); + var filter = new FrameworkSafetyFilter(); inst.addTransformer(new RuntimeTransformer(filter), 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; + } +} From 35fa6d996d6e45210fcce3afd37c8d294c8a3e53 Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Mon, 24 Nov 2025 14:34:55 -0500 Subject: [PATCH 11/50] feat(framework): add field write instrumentation --- .../core/RuntimeInstrumenter.java | 90 +++++++++---------- .../util/SysOutInstrumenter.java | 50 +++++------ 2 files changed, 67 insertions(+), 73 deletions(-) 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 index 12df095..a386f6e 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeInstrumenter.java @@ -4,72 +4,71 @@ import java.lang.classfile.CodeBuilder; import java.lang.classfile.CodeElement; 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.FieldInstruction; import java.lang.constant.MethodTypeDesc; import java.lang.reflect.Modifier; -/** The "Worker" that visits methods and injects bytecode. */ public abstract class RuntimeInstrumenter { - // We will use this later to decide *if* we check a parameter - // protected final RuntimeCheckPolicy policy; - - // For now, let's just get the traversal working without the policy complexity public RuntimeInstrumenter() {} - /** The main entry point. This returns the function we pass to 'transformClass'. */ public ClassTransform asClassTransform() { return (classBuilder, classElement) -> { - - // 1. Filter: Is this element a Method? + // 1. Only process Methods if (classElement instanceof MethodModel methodModel) { - // 2. Filter: Does it have a body? (Abstract/Native methods don't) + // 2. Only process methods with Code (skips abstract/native) if (methodModel.code().isPresent()) { + classBuilder.transformMethod( + methodModel, + (methodBuilder, methodElement) -> { + + // 3. Only process the Code attribute + if (methodElement instanceof CodeAttribute codeModel) { + methodBuilder.withCode( + codeBuilder -> { + + // PHASE 1: Inject Entry Checks (Parameters) + instrumentMethodEntry(codeBuilder, methodModel); + + // PHASE 2: Stream Instructions (Field Checks) + for (CodeElement element : codeModel) { + if (element instanceof FieldInstruction fInst && isFieldWrite(fInst)) { + + // A. Inject check BEFORE the write + generateFieldWriteCheck(codeBuilder, fInst); + + // B. Write the original instruction + codeBuilder.with(element); - // 3. Rebuild the method - // We recreate the method structure so we can modify its contents - classBuilder.withMethod( - methodModel.methodName(), - methodModel.methodType(), - methodModel.flags().flagsMask(), - methodBuilder -> { - // Iterate over the method's internals (Annotations, Code, etc.) - for (var element : methodModel) { - if (element instanceof CodeAttribute code) { - // FOUND THE CODE! -> Rewrite it - methodBuilder.withCode( - codeBuilder -> { - - // A. Inject our custom checks at the very top - instrumentMethodEntry(codeBuilder, methodModel); - - // B. Copy the original instructions - for (CodeElement ce : code) { - codeBuilder.with(ce); + } else { + // Pass everything else through unchanged + codeBuilder.with(element); } - }); - } else { - // Copy annotations/attributes as-is - methodBuilder.with(element); - } + } + }); + } else { + methodBuilder.with(methodElement); } }); } else { - // It's abstract or native -> Just copy it classBuilder.with(classElement); } } else { - // It's a Field or Class Attribute -> Just copy it classBuilder.with(classElement); } }; } - /** Helper to calculate slot indices and delegate to the specific checker. */ + private boolean isFieldWrite(FieldInstruction f) { + return f.opcode() == Opcode.PUTFIELD || f.opcode() == Opcode.PUTSTATIC; + } + + // --- Helper for Method Entry --- protected void instrumentMethodEntry(CodeBuilder builder, MethodModel method) { - // Calculate slot index (Static methods start at 0, Instance methods at 1 for 'this') boolean isStatic = (method.flags().flagsMask() & Modifier.STATIC) != 0; int slotIndex = isStatic ? 0 : 1; @@ -78,15 +77,16 @@ protected void instrumentMethodEntry(CodeBuilder builder, MethodModel method) { for (int i = 0; i < paramCount; i++) { TypeKind type = TypeKind.from(methodDesc.parameterList().get(i)); - - // Call the abstract method to let the subclass insert checks - generateCheck(builder, slotIndex, type); - - // Advance slot (Double/Long take 2 slots) + generateParamCheck(builder, slotIndex, type); slotIndex += type.slotSize(); } } - /** The "Hole" to be filled by the specific checker (e.g. NullnessChecker). */ - protected abstract void generateCheck(CodeBuilder builder, int slotIndex, TypeKind type); + // --- Abstract Hooks for Subclasses --- + + /** Called at the start of the method for every parameter. */ + protected abstract void generateParamCheck(CodeBuilder b, int slotIndex, TypeKind type); + + /** Called immediately before a PUTFIELD or PUTSTATIC instruction. */ + protected abstract void generateFieldWriteCheck(CodeBuilder b, FieldInstruction field); } 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 index d906fe7..1fec0f1 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutInstrumenter.java @@ -3,43 +3,37 @@ import io.github.eisop.runtimeframework.core.RuntimeInstrumenter; import java.lang.classfile.CodeBuilder; import java.lang.classfile.TypeKind; +import java.lang.classfile.instruction.FieldInstruction; import java.lang.constant.ClassDesc; import java.lang.constant.MethodTypeDesc; -/** - * A concrete instrumenter used for testing and debugging. It injects a System.out.println at the - * start of every method. - */ 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() { super(); } - /** - * Generates bytecode instructions to print a diagnostic message to standard output. - * - *

The generated bytecode corresponds to the Java statement: - * - *

{@code
-   * System.out.println("RuntimeCheck: Verifying param at slot " + slotIndex);
-   * }
- * - *

This implementation ignores the actual type of the parameter and simply reports which slot - * is being visited. - * - * @param b the {@link CodeBuilder} used to emit the bytecode instructions - * @param slotIndex the local variable slot index of the parameter currently being "checked" - * @param type the {@link TypeKind} of the parameter (unused in this implementation, but required - * by the contract) - */ @Override - protected void generateCheck(CodeBuilder b, int slotIndex, TypeKind type) { - b.getstatic(ClassDesc.of("java.lang.System"), "out", ClassDesc.of("java.io.PrintStream")); - b.ldc("RuntimeCheck: Verifying param at slot " + slotIndex); - b.invokevirtual( - ClassDesc.of("java.io.PrintStream"), - "println", - MethodTypeDesc.ofDescriptor("(Ljava/lang/String;)V")); + protected void generateParamCheck(CodeBuilder b, int slotIndex, TypeKind type) { + b.getstatic(SYSOUT_SYSTEM, "out", SYSOUT_STREAM); + b.ldc(" [Param Check] Verifying argument at slot " + slotIndex); + b.invokevirtual(SYSOUT_STREAM, "println", SYSOUT_PRINTLN); + } + + @Override + protected void generateFieldWriteCheck(CodeBuilder b, FieldInstruction field) { + // 1. Load System.out + b.getstatic(SYSOUT_SYSTEM, "out", SYSOUT_STREAM); + + // 2. Load the message string + b.ldc(" [Field Check] About to write to field: " + field.name().stringValue()); + + // 3. Print (Stack is now clean, original value for PUTFIELD is underneath) + b.invokevirtual(SYSOUT_STREAM, "println", SYSOUT_PRINTLN); } } From 6d890ef6d7733b923f39c9b6969d053bcc89cd4c Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Mon, 24 Nov 2025 14:44:17 -0500 Subject: [PATCH 12/50] feat(framework): add field read and return instrumentation --- .../core/RuntimeInstrumenter.java | 82 +++++++++---------- .../util/SysOutInstrumenter.java | 26 ++++-- 2 files changed, 58 insertions(+), 50 deletions(-) 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 index a386f6e..cf17f10 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeInstrumenter.java @@ -8,6 +8,7 @@ import java.lang.classfile.TypeKind; import java.lang.classfile.attribute.CodeAttribute; import java.lang.classfile.instruction.FieldInstruction; +import java.lang.classfile.instruction.ReturnInstruction; import java.lang.constant.MethodTypeDesc; import java.lang.reflect.Modifier; @@ -17,46 +18,43 @@ public RuntimeInstrumenter() {} public ClassTransform asClassTransform() { return (classBuilder, classElement) -> { - // 1. Only process Methods - if (classElement instanceof MethodModel methodModel) { - - // 2. Only process methods with Code (skips abstract/native) - if (methodModel.code().isPresent()) { - classBuilder.transformMethod( - methodModel, - (methodBuilder, methodElement) -> { - - // 3. Only process the Code attribute - if (methodElement instanceof CodeAttribute codeModel) { - methodBuilder.withCode( - codeBuilder -> { - - // PHASE 1: Inject Entry Checks (Parameters) - instrumentMethodEntry(codeBuilder, methodModel); - - // PHASE 2: Stream Instructions (Field Checks) - for (CodeElement element : codeModel) { - if (element instanceof FieldInstruction fInst && isFieldWrite(fInst)) { - - // A. Inject check BEFORE the write + if (classElement instanceof MethodModel methodModel && methodModel.code().isPresent()) { + classBuilder.transformMethod( + methodModel, + (methodBuilder, methodElement) -> { + if (methodElement instanceof CodeAttribute codeModel) { + methodBuilder.withCode( + codeBuilder -> { + + // PHASE 1: Method Entry + instrumentMethodEntry(codeBuilder, methodModel); + + // PHASE 2: Instruction Stream + for (CodeElement element : codeModel) { + + if (element instanceof FieldInstruction fInst) { + if (isFieldWrite(fInst)) { + // WRITE: Check BEFORE (Value is on stack) generateFieldWriteCheck(codeBuilder, fInst); - - // B. Write the original instruction codeBuilder.with(element); - - } else { - // Pass everything else through unchanged + } else if (isFieldRead(fInst)) { + // READ: Check AFTER (Value has just landed on stack) codeBuilder.with(element); + generateFieldReadCheck(codeBuilder, fInst); } + } else if (element instanceof ReturnInstruction rInst) { + // RETURN: Check BEFORE (Value is on stack) + generateReturnCheck(codeBuilder, rInst); + codeBuilder.with(element); + } else { + codeBuilder.with(element); } - }); - } else { - methodBuilder.with(methodElement); - } - }); - } else { - classBuilder.with(classElement); - } + } + }); + } else { + methodBuilder.with(methodElement); + } + }); } else { classBuilder.with(classElement); } @@ -67,11 +65,13 @@ private boolean isFieldWrite(FieldInstruction f) { return f.opcode() == Opcode.PUTFIELD || f.opcode() == Opcode.PUTSTATIC; } - // --- Helper for Method Entry --- + private 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(); @@ -82,11 +82,11 @@ protected void instrumentMethodEntry(CodeBuilder builder, MethodModel method) { } } - // --- Abstract Hooks for Subclasses --- - - /** Called at the start of the method for every parameter. */ protected abstract void generateParamCheck(CodeBuilder b, int slotIndex, TypeKind type); - /** Called immediately before a PUTFIELD or PUTSTATIC instruction. */ protected abstract void generateFieldWriteCheck(CodeBuilder b, FieldInstruction field); + + protected abstract void generateFieldReadCheck(CodeBuilder b, FieldInstruction field); + + protected abstract void generateReturnCheck(CodeBuilder b, ReturnInstruction ret); } 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 index 1fec0f1..e07dca6 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutInstrumenter.java @@ -4,6 +4,7 @@ import java.lang.classfile.CodeBuilder; import java.lang.classfile.TypeKind; import java.lang.classfile.instruction.FieldInstruction; +import java.lang.classfile.instruction.ReturnInstruction; import java.lang.constant.ClassDesc; import java.lang.constant.MethodTypeDesc; @@ -18,22 +19,29 @@ public SysOutInstrumenter() { super(); } - @Override - protected void generateParamCheck(CodeBuilder b, int slotIndex, TypeKind type) { + private void print(CodeBuilder b, String msg) { b.getstatic(SYSOUT_SYSTEM, "out", SYSOUT_STREAM); - b.ldc(" [Param Check] Verifying argument at slot " + slotIndex); + b.ldc(msg); b.invokevirtual(SYSOUT_STREAM, "println", SYSOUT_PRINTLN); } + @Override + protected void generateParamCheck(CodeBuilder b, int slotIndex, TypeKind type) { + print(b, " [Param Check] Verifying argument at slot " + slotIndex); + } + @Override protected void generateFieldWriteCheck(CodeBuilder b, FieldInstruction field) { - // 1. Load System.out - b.getstatic(SYSOUT_SYSTEM, "out", SYSOUT_STREAM); + print(b, " [Field Write] About to write to: " + field.name().stringValue()); + } - // 2. Load the message string - b.ldc(" [Field Check] About to write to field: " + field.name().stringValue()); + @Override + protected void generateFieldReadCheck(CodeBuilder b, FieldInstruction field) { + print(b, " [Field Read] Just read from: " + field.name().stringValue()); + } - // 3. Print (Stack is now clean, original value for PUTFIELD is underneath) - b.invokevirtual(SYSOUT_STREAM, "println", SYSOUT_PRINTLN); + @Override + protected void generateReturnCheck(CodeBuilder b, ReturnInstruction ret) { + print(b, " [Return Check] Returning from method via opcode: " + ret.opcode().name()); } } From c5b2cac96bd5025e33dea1fdc7a91728e32a0891 Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Tue, 25 Nov 2025 14:03:42 -0500 Subject: [PATCH 13/50] feat(framework.core): introduce abstract RuntimeChecker --- .../eisop/runtimeframework/core/RuntimeChecker.java | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeChecker.java 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..cef0cb9 --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeChecker.java @@ -0,0 +1,10 @@ +package io.github.eisop.runtimeframework.core; + +public abstract class RuntimeChecker { + + /** Returns the human-readable name of this checker */ + public abstract String getName(); + + /** Creates or returns the instrumenter that injects this checker's logic. */ + public abstract RuntimeInstrumenter getInstrumenter(); +} From 27bf55022380d653978078ee0fadec4ffa068b1c Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Tue, 25 Nov 2025 14:16:17 -0500 Subject: [PATCH 14/50] feat(framework.util): concrete utility checker implementation for debugging --- .../util/SysOutRuntimeChecker.java | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutRuntimeChecker.java 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..ccfb857 --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutRuntimeChecker.java @@ -0,0 +1,24 @@ +package io.github.eisop.runtimeframework.util; + +import io.github.eisop.runtimeframework.core.RuntimeChecker; +import io.github.eisop.runtimeframework.core.RuntimeInstrumenter; + +/** + * A basic checker for testing and debugging the framework infrastructure. + * + *

Instead of enforcing safety properties, this checker injects System.out.println statements at + * every instrumentation point. Use this to verify that the Agent is correctly intercepting and + * rewriting classes. + */ +public class SysOutRuntimeChecker extends RuntimeChecker { + + @Override + public String getName() { + return "SysOut Debug Checker"; + } + + @Override + public RuntimeInstrumenter getInstrumenter() { + return new SysOutInstrumenter(); + } +} From 3d26575c4f80084697d8742c9ace6f843f1c99db Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Tue, 25 Nov 2025 14:17:13 -0500 Subject: [PATCH 15/50] refactor(framework.agent): update agent to use debugging checker --- .../io/github/eisop/runtimeframework/agent/RuntimeAgent.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 d330b9f..af34d6b 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,12 +1,15 @@ package io.github.eisop.runtimeframework.agent; +import io.github.eisop.runtimeframework.core.RuntimeChecker; import io.github.eisop.runtimeframework.filter.FrameworkSafetyFilter; +import io.github.eisop.runtimeframework.util.SysOutRuntimeChecker; import java.lang.instrument.Instrumentation; public final class RuntimeAgent { public static void premain(String args, Instrumentation inst) { var filter = new FrameworkSafetyFilter(); - inst.addTransformer(new RuntimeTransformer(filter), false); + RuntimeChecker checker = new SysOutRuntimeChecker(); + inst.addTransformer(new RuntimeTransformer(filter, checker), false); } } From ed5cf4d685ed12ef583777b048779123f29cf367 Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Tue, 25 Nov 2025 14:17:47 -0500 Subject: [PATCH 16/50] refactor(framework.agent): Update RuntimeTransformer to use new checker abstraction --- .../eisop/runtimeframework/agent/RuntimeTransformer.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java b/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java index c986023..fd67a86 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java @@ -1,9 +1,9 @@ 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.util.SysOutInstrumenter; import java.lang.classfile.ClassFile; import java.lang.classfile.ClassModel; import java.lang.instrument.ClassFileTransformer; @@ -12,9 +12,11 @@ public class RuntimeTransformer implements ClassFileTransformer { private final Filter filter; + private final RuntimeChecker checker; - public RuntimeTransformer(Filter filter) { + public RuntimeTransformer(Filter filter, RuntimeChecker checker) { this.filter = filter; + this.checker = checker; } @Override @@ -41,7 +43,7 @@ public byte[] transform( try { ClassFile cf = ClassFile.of(); ClassModel classModel = cf.parse(classfileBuffer); - RuntimeInstrumenter instrumenter = new SysOutInstrumenter(); + RuntimeInstrumenter instrumenter = checker.getInstrumenter(); return cf.transformClass(classModel, instrumenter.asClassTransform()); } catch (Exception e) { From fc930c440e25e2a5126faa61c274474ca089dc02 Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Tue, 25 Nov 2025 14:24:50 -0500 Subject: [PATCH 17/50] feat(framework.runtime): introduce an abstract RuntimeVerifier and ViolationHandler --- .../runtime/LoggingViolationHandler.java | 25 +++++++++++ .../runtime/RuntimeVerifier.java | 41 +++++++++++++++++++ .../runtime/ThrowingViolationHandler.java | 10 +++++ .../runtime/ViolationHandler.java | 13 ++++++ 4 files changed, 89 insertions(+) create mode 100644 framework/src/main/java/io/github/eisop/runtimeframework/runtime/LoggingViolationHandler.java create mode 100644 framework/src/main/java/io/github/eisop/runtimeframework/runtime/RuntimeVerifier.java create mode 100644 framework/src/main/java/io/github/eisop/runtimeframework/runtime/ThrowingViolationHandler.java create mode 100644 framework/src/main/java/io/github/eisop/runtimeframework/runtime/ViolationHandler.java 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..a917821 --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/runtime/RuntimeVerifier.java @@ -0,0 +1,41 @@ +package io.github.eisop.runtimeframework.runtime; + +/** + * The abstract base class for all runtime verifiers. + * + *

Design Note: This class does not contain abstract methods for checks. + * Specific verification methods (e.g., {@code checkNotNull}) must be {@code static} in subclasses + * to allow efficient {@code invokestatic} calls from the instrumented bytecode. + */ +public abstract class RuntimeVerifier { + + private static volatile ViolationHandler 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 (e.g., to switch from throwing exceptions to logging). + * + * @param newHandler The new handler to use + */ + 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. + * + *

This method is designed to be called by the static check methods in concrete subclasses (the + * "Static Trampolines"). + * + * @param checkerName The name of the checker reporting the issue + * @param message The violation details + */ + 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); +} From 88c1eda5c0f959ab9297b693a255d3a4e7b1fa12 Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Tue, 25 Nov 2025 15:11:30 -0500 Subject: [PATCH 18/50] chore(git): update .gitignore --- .gitignore | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.gitignore b/.gitignore index 1b6985c..aad400c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,18 @@ # Ignore Gradle build output directory build + +# Eclipse +.metadata +.classpath +.project +.externalToolBuilders +.settings +**/*.factorypath + +# Environment setup +.direnv/ +.envrc + +# bins +framework/bin/ \ No newline at end of file From e2fe8f995104b94f93354fe1aee5b9c11591b390 Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Wed, 26 Nov 2025 14:21:28 -0500 Subject: [PATCH 19/50] build: introduce checker subproject --- build.gradle | 47 +++++++++++++++++++++++++++--------------- checker/build.gradle | 21 +++++++++++++++++++ framework/build.gradle | 9 ++------ settings.gradle | 1 + 4 files changed, 54 insertions(+), 24 deletions(-) create mode 100644 checker/build.gradle diff --git a/build.gradle b/build.gradle index e436136..60e4db8 100644 --- a/build.gradle +++ b/build.gradle @@ -1,15 +1,25 @@ plugins { id 'net.ltgt.errorprone' version '4.3.0' apply false id 'com.diffplug.spotless' version '8.0.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 +27,30 @@ 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") + } } -} -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/" - } + } } - } + } } } diff --git a/checker/build.gradle b/checker/build.gradle new file mode 100644 index 0000000..c4f3ca3 --- /dev/null +++ b/checker/build.gradle @@ -0,0 +1,21 @@ +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}" +} 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/settings.gradle b/settings.gradle index ac01898..6ed3c79 100644 --- a/settings.gradle +++ b/settings.gradle @@ -8,3 +8,4 @@ rootProject.name = 'runtime-framework' include 'framework' include 'docs' +include 'checker' From 1a9307c1221a615f1f38451af12121dff0ed7ea2 Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Thu, 27 Nov 2025 15:38:20 -0500 Subject: [PATCH 20/50] feat(checker): add nullness checker --- build.gradle | 30 ++++++ .../checker/nullness/NonNullTarget.java | 44 +++++++++ .../nullness/NullnessRuntimeChecker.java | 20 ++++ .../nullness/NullnessRuntimeVerifier.java | 25 +++++ .../runtimeframework/agent/RuntimeAgent.java | 23 ++++- .../core/AnnotationInstrumenter.java | 97 +++++++++++++++++++ .../core/RuntimeInstrumenter.java | 15 +-- .../core/TargetAnnotation.java | 25 +++++ .../util/SysOutInstrumenter.java | 4 +- 9 files changed, 274 insertions(+), 9 deletions(-) create mode 100644 checker/src/main/java/io/github/eisop/runtimeframework/checker/nullness/NonNullTarget.java create mode 100644 checker/src/main/java/io/github/eisop/runtimeframework/checker/nullness/NullnessRuntimeChecker.java create mode 100644 checker/src/main/java/io/github/eisop/runtimeframework/checker/nullness/NullnessRuntimeVerifier.java create mode 100644 framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java create mode 100644 framework/src/main/java/io/github/eisop/runtimeframework/core/TargetAnnotation.java diff --git a/build.gradle b/build.gradle index 60e4db8..45f73bc 100644 --- a/build.gradle +++ b/build.gradle @@ -1,4 +1,5 @@ plugins { + id 'base' id 'net.ltgt.errorprone' version '4.3.0' apply false id 'com.diffplug.spotless' version '8.0.0' apply false } @@ -54,3 +55,32 @@ allprojects { } } } + +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/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..876d60d --- /dev/null +++ b/checker/src/main/java/io/github/eisop/runtimeframework/checker/nullness/NonNullTarget.java @@ -0,0 +1,44 @@ +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) { + // FIX: Use TypeKind.Reference (correct enum name in JDK 25) + if (type == TypeKind.REFERENCE) { + // Stack: [..., Value] + + // 1. Push Message + b.ldc(diagnosticName + " must be NonNull"); + + // Stack: [..., Value, String] + + // 2. Call Verifier + b.invokestatic(VERIFIER, METHOD, DESC); + + // Stack: [...] (Consumed) + } else { + // If it's a primitive, pop it to clean stack (no check needed) + 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..3d1a437 --- /dev/null +++ b/checker/src/main/java/io/github/eisop/runtimeframework/checker/nullness/NullnessRuntimeChecker.java @@ -0,0 +1,20 @@ +package io.github.eisop.runtimeframework.checker.nullness; + +import io.github.eisop.runtimeframework.core.AnnotationInstrumenter; +import io.github.eisop.runtimeframework.core.RuntimeChecker; +import io.github.eisop.runtimeframework.core.RuntimeInstrumenter; +import java.util.List; + +public class NullnessRuntimeChecker extends RuntimeChecker { + + @Override + public String getName() { + return "Nullness Runtime Checker"; + } + + @Override + public RuntimeInstrumenter getInstrumenter() { + // Register our strategies + return new AnnotationInstrumenter(List.of(new NonNullTarget())); + } +} 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/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeAgent.java b/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeAgent.java index af34d6b..f694916 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 @@ -2,14 +2,33 @@ import io.github.eisop.runtimeframework.core.RuntimeChecker; import io.github.eisop.runtimeframework.filter.FrameworkSafetyFilter; -import io.github.eisop.runtimeframework.util.SysOutRuntimeChecker; import java.lang.instrument.Instrumentation; public final class RuntimeAgent { public static void premain(String args, Instrumentation inst) { + // 1. Setup Filter var filter = new FrameworkSafetyFilter(); - RuntimeChecker checker = new SysOutRuntimeChecker(); + + // 2. Load Checker Dynamically + // Default to SysOut if nothing specified + 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; // Abort agent + } + + // 3. Register inst.addTransformer(new RuntimeTransformer(filter, checker), 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..4a45868 --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java @@ -0,0 +1,97 @@ +package io.github.eisop.runtimeframework.core; + +import java.lang.classfile.Attributes; +import java.lang.classfile.CodeBuilder; +import java.lang.classfile.MethodModel; +import java.lang.classfile.TypeAnnotation; // Needed for TypeAnnotation casting +import java.lang.classfile.TypeKind; +import java.lang.classfile.instruction.FieldInstruction; +import java.lang.classfile.instruction.ReturnInstruction; +import java.util.ArrayList; // Needed for aggregation +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * A reusable instrumenter that parses bytecode attributes to find annotations and delegates the + * verification logic to registered {@link TargetAnnotation} strategies. + */ +public class AnnotationInstrumenter extends RuntimeInstrumenter { + + private final Map targets; + + public AnnotationInstrumenter(Collection targetAnnotations) { + this.targets = + targetAnnotations.stream() + .collect(Collectors.toMap(t -> t.annotationType().descriptorString(), t -> t)); + } + + @Override + protected void generateParamCheck( + CodeBuilder b, int slotIndex, TypeKind type, MethodModel method, int paramIndex) { + // 1. Find Annotations (Now scans both Declaration and Type attributes) + List paramAnnotations = + getParameterAnnotations(method, paramIndex); + + // 2. Dispatch + for (java.lang.classfile.Annotation annotation : paramAnnotations) { + String descriptor = annotation.classSymbol().descriptorString(); + TargetAnnotation target = targets.get(descriptor); + + if (target != null) { + // PLUMBING: Load the value onto the stack + b.aload(slotIndex); + + // LOGIC: Delegate to the target + target.check(b, type, "Parameter " + paramIndex); + } + } + } + + private List getParameterAnnotations( + MethodModel method, int paramIndex) { + List result = new ArrayList<>(); + + // 1. Check RuntimeVisibleParameterAnnotations (Legacy/Declaration annotations) + method + .findAttribute(Attributes.runtimeVisibleParameterAnnotations()) + .ifPresent( + attr -> { + List> allParams = attr.parameterAnnotations(); + if (paramIndex < allParams.size()) { + result.addAll(allParams.get(paramIndex)); + } + }); + + // 2. Check RuntimeVisibleTypeAnnotations (Modern Type Use annotations like @NonNull) + method + .findAttribute(Attributes.runtimeVisibleTypeAnnotations()) + .ifPresent( + attr -> { + for (TypeAnnotation typeAnno : attr.annotations()) { + // We must check if this annotation targets the specific parameter index we are + // visiting + TypeAnnotation.TargetInfo target = typeAnno.targetInfo(); + + if (target instanceof TypeAnnotation.FormalParameterTarget paramTarget) { + if (paramTarget.formalParameterIndex() == paramIndex) { + // TypeAnnotation wraps the actual Annotation in JDK 25 + result.add(typeAnno.annotation()); + } + } + } + }); + + return result; + } + + @Override + protected void generateFieldWriteCheck(CodeBuilder b, FieldInstruction field) {} + + @Override + protected void generateFieldReadCheck(CodeBuilder b, FieldInstruction field) {} + + @Override + protected void generateReturnCheck(CodeBuilder b, ReturnInstruction ret) {} +} 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 index cf17f10..307c230 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeInstrumenter.java @@ -31,19 +31,15 @@ public ClassTransform asClassTransform() { // PHASE 2: Instruction Stream for (CodeElement element : codeModel) { - if (element instanceof FieldInstruction fInst) { if (isFieldWrite(fInst)) { - // WRITE: Check BEFORE (Value is on stack) generateFieldWriteCheck(codeBuilder, fInst); codeBuilder.with(element); } else if (isFieldRead(fInst)) { - // READ: Check AFTER (Value has just landed on stack) codeBuilder.with(element); generateFieldReadCheck(codeBuilder, fInst); } } else if (element instanceof ReturnInstruction rInst) { - // RETURN: Check BEFORE (Value is on stack) generateReturnCheck(codeBuilder, rInst); codeBuilder.with(element); } else { @@ -77,12 +73,19 @@ protected void instrumentMethodEntry(CodeBuilder builder, MethodModel method) { for (int i = 0; i < paramCount; i++) { TypeKind type = TypeKind.from(methodDesc.parameterList().get(i)); - generateParamCheck(builder, slotIndex, type); + + // FIXED: Pass the MethodModel and param index 'i' to match the new signature + generateParamCheck(builder, slotIndex, type, method, i); + slotIndex += type.slotSize(); } } - protected abstract void generateParamCheck(CodeBuilder b, int slotIndex, TypeKind type); + // --- Abstract Hooks --- + + // FIXED: Added 'abstract' keyword and updated arguments + protected abstract void generateParamCheck( + CodeBuilder b, int slotIndex, TypeKind type, MethodModel method, int paramIndex); protected abstract void generateFieldWriteCheck(CodeBuilder b, FieldInstruction field); 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/util/SysOutInstrumenter.java b/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutInstrumenter.java index e07dca6..9f17712 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutInstrumenter.java @@ -2,6 +2,7 @@ import io.github.eisop.runtimeframework.core.RuntimeInstrumenter; import java.lang.classfile.CodeBuilder; +import java.lang.classfile.MethodModel; import java.lang.classfile.TypeKind; import java.lang.classfile.instruction.FieldInstruction; import java.lang.classfile.instruction.ReturnInstruction; @@ -26,7 +27,8 @@ private void print(CodeBuilder b, String msg) { } @Override - protected void generateParamCheck(CodeBuilder b, int slotIndex, TypeKind type) { + protected void generateParamCheck( + CodeBuilder b, int slotIndex, TypeKind type, MethodModel method, int paramIndex) { print(b, " [Param Check] Verifying argument at slot " + slotIndex); } From b9a1574f453e52fca2148211d699585bd4f99ae2 Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Fri, 28 Nov 2025 13:47:19 -0500 Subject: [PATCH 21/50] feat(framework): add field write checks --- .../agent/RuntimeTransformer.java | 2 +- .../core/AnnotationInstrumenter.java | 86 +++++++++++++++---- .../core/RuntimeInstrumenter.java | 18 ++-- .../util/SysOutInstrumenter.java | 4 +- 4 files changed, 77 insertions(+), 33 deletions(-) diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java b/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java index fd67a86..18aa321 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java @@ -44,7 +44,7 @@ public byte[] transform( ClassFile cf = ClassFile.of(); ClassModel classModel = cf.parse(classfileBuffer); RuntimeInstrumenter instrumenter = checker.getInstrumenter(); - return cf.transformClass(classModel, instrumenter.asClassTransform()); + return cf.transformClass(classModel, instrumenter.asClassTransform(classModel)); } catch (Exception e) { System.err.println("[RuntimeFramework] Failed to parse: " + className); 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 index 4a45868..13f42c1 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java @@ -1,13 +1,16 @@ package io.github.eisop.runtimeframework.core; import java.lang.classfile.Attributes; +import java.lang.classfile.ClassModel; import java.lang.classfile.CodeBuilder; +import java.lang.classfile.FieldModel; import java.lang.classfile.MethodModel; -import java.lang.classfile.TypeAnnotation; // Needed for TypeAnnotation casting +import java.lang.classfile.Opcode; +import java.lang.classfile.TypeAnnotation; import java.lang.classfile.TypeKind; import java.lang.classfile.instruction.FieldInstruction; import java.lang.classfile.instruction.ReturnInstruction; -import java.util.ArrayList; // Needed for aggregation +import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Map; @@ -30,20 +33,13 @@ public AnnotationInstrumenter(Collection targetAnnotations) { @Override protected void generateParamCheck( CodeBuilder b, int slotIndex, TypeKind type, MethodModel method, int paramIndex) { - // 1. Find Annotations (Now scans both Declaration and Type attributes) List paramAnnotations = getParameterAnnotations(method, paramIndex); - - // 2. Dispatch for (java.lang.classfile.Annotation annotation : paramAnnotations) { String descriptor = annotation.classSymbol().descriptorString(); TargetAnnotation target = targets.get(descriptor); - if (target != null) { - // PLUMBING: Load the value onto the stack b.aload(slotIndex); - - // LOGIC: Delegate to the target target.check(b, type, "Parameter " + paramIndex); } } @@ -52,8 +48,6 @@ protected void generateParamCheck( private List getParameterAnnotations( MethodModel method, int paramIndex) { List result = new ArrayList<>(); - - // 1. Check RuntimeVisibleParameterAnnotations (Legacy/Declaration annotations) method .findAttribute(Attributes.runtimeVisibleParameterAnnotations()) .ifPresent( @@ -63,31 +57,85 @@ private List getParameterAnnotations( result.addAll(allParams.get(paramIndex)); } }); - - // 2. Check RuntimeVisibleTypeAnnotations (Modern Type Use annotations like @NonNull) method .findAttribute(Attributes.runtimeVisibleTypeAnnotations()) .ifPresent( attr -> { for (TypeAnnotation typeAnno : attr.annotations()) { - // We must check if this annotation targets the specific parameter index we are - // visiting TypeAnnotation.TargetInfo target = typeAnno.targetInfo(); - if (target instanceof TypeAnnotation.FormalParameterTarget paramTarget) { if (paramTarget.formalParameterIndex() == paramIndex) { - // TypeAnnotation wraps the actual Annotation in JDK 25 result.add(typeAnno.annotation()); } } } }); - return result; } + @Override - protected void generateFieldWriteCheck(CodeBuilder b, FieldInstruction field) {} + protected void generateFieldWriteCheck( + CodeBuilder b, FieldInstruction field, ClassModel classModel) {ame + if (!field.owner().equals(classModel.thisClass())) { + // TODO: Field is in another class + return; + } + + FieldModel targetField = null; + for (FieldModel fm : classModel.fields()) { + if (fm.fieldName().equals(field.name()) && fm.fieldType().equals(field.type())) { + targetField = fm; + break; + } + } + + if (targetField == null) return; + + List annotations = new ArrayList<>(); + + targetField + .findAttribute(Attributes.runtimeVisibleAnnotations()) + .ifPresent(attr -> annotations.addAll(attr.annotations())); + + targetField + .findAttribute(Attributes.runtimeVisibleTypeAnnotations()) + .ifPresent( + attr -> { + for (TypeAnnotation ta : attr.annotations()) { + if (ta.targetInfo() instanceof TypeAnnotation.EmptyTarget) { + annotations.add(ta.annotation()); + } + } + }); + + for (java.lang.classfile.Annotation annotation : annotations) { + String descriptor = annotation.classSymbol().descriptorString(); + TargetAnnotation target = targets.get(descriptor); + + if (target != null) { + injectFieldCheck(b, field, target); + } + } + } + + private void injectFieldCheck(CodeBuilder b, FieldInstruction field, TargetAnnotation target) { + TypeKind type = TypeKind.fromDescriptor(field.typeSymbol().descriptorString()); + + // TODO: Support category 2 types (long/double) + + if (type.slotSize() != 1) return; + + 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) {} 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 index 307c230..d1f10b8 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeInstrumenter.java @@ -1,5 +1,6 @@ package io.github.eisop.runtimeframework.core; +import java.lang.classfile.ClassModel; import java.lang.classfile.ClassTransform; import java.lang.classfile.CodeBuilder; import java.lang.classfile.CodeElement; @@ -16,7 +17,7 @@ public abstract class RuntimeInstrumenter { public RuntimeInstrumenter() {} - public ClassTransform asClassTransform() { + public ClassTransform asClassTransform(ClassModel classModel) { return (classBuilder, classElement) -> { if (classElement instanceof MethodModel methodModel && methodModel.code().isPresent()) { classBuilder.transformMethod( @@ -25,15 +26,13 @@ public ClassTransform asClassTransform() { if (methodElement instanceof CodeAttribute codeModel) { methodBuilder.withCode( codeBuilder -> { - - // PHASE 1: Method Entry instrumentMethodEntry(codeBuilder, methodModel); - // PHASE 2: Instruction Stream for (CodeElement element : codeModel) { if (element instanceof FieldInstruction fInst) { if (isFieldWrite(fInst)) { - generateFieldWriteCheck(codeBuilder, fInst); + + generateFieldWriteCheck(codeBuilder, fInst, classModel); codeBuilder.with(element); } else if (isFieldRead(fInst)) { codeBuilder.with(element); @@ -73,21 +72,16 @@ protected void instrumentMethodEntry(CodeBuilder builder, MethodModel method) { for (int i = 0; i < paramCount; i++) { TypeKind type = TypeKind.from(methodDesc.parameterList().get(i)); - - // FIXED: Pass the MethodModel and param index 'i' to match the new signature generateParamCheck(builder, slotIndex, type, method, i); - slotIndex += type.slotSize(); } } - // --- Abstract Hooks --- - - // FIXED: Added 'abstract' keyword and updated arguments protected abstract void generateParamCheck( CodeBuilder b, int slotIndex, TypeKind type, MethodModel method, int paramIndex); - protected abstract void generateFieldWriteCheck(CodeBuilder b, FieldInstruction field); + protected abstract void generateFieldWriteCheck( + CodeBuilder b, FieldInstruction field, ClassModel classModel); protected abstract void generateFieldReadCheck(CodeBuilder b, FieldInstruction field); 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 index 9f17712..e421bd0 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutInstrumenter.java @@ -1,6 +1,7 @@ package io.github.eisop.runtimeframework.util; import io.github.eisop.runtimeframework.core.RuntimeInstrumenter; +import java.lang.classfile.ClassModel; import java.lang.classfile.CodeBuilder; import java.lang.classfile.MethodModel; import java.lang.classfile.TypeKind; @@ -33,7 +34,8 @@ protected void generateParamCheck( } @Override - protected void generateFieldWriteCheck(CodeBuilder b, FieldInstruction field) { + protected void generateFieldWriteCheck( + CodeBuilder b, FieldInstruction field, ClassModel classModel) { print(b, " [Field Write] About to write to: " + field.name().stringValue()); } From 1f532167b642c2cf08628ee15b10f8bed3f81b3c Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Fri, 28 Nov 2025 14:04:26 -0500 Subject: [PATCH 22/50] feat(framework): add return checks --- .../core/AnnotationInstrumenter.java | 55 ++++++++++--------- .../core/RuntimeInstrumenter.java | 6 +- .../util/SysOutInstrumenter.java | 2 +- 3 files changed, 34 insertions(+), 29 deletions(-) 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 index 13f42c1..8cb3f61 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java @@ -16,10 +16,6 @@ import java.util.Map; import java.util.stream.Collectors; -/** - * A reusable instrumenter that parses bytecode attributes to find annotations and delegates the - * verification logic to registered {@link TargetAnnotation} strategies. - */ public class AnnotationInstrumenter extends RuntimeInstrumenter { private final Map targets; @@ -73,14 +69,10 @@ private List getParameterAnnotations( return result; } - @Override protected void generateFieldWriteCheck( - CodeBuilder b, FieldInstruction field, ClassModel classModel) {ame - if (!field.owner().equals(classModel.thisClass())) { - // TODO: Field is in another class - return; - } + CodeBuilder b, FieldInstruction field, ClassModel classModel) { + if (!field.owner().equals(classModel.thisClass())) return; FieldModel targetField = null; for (FieldModel fm : classModel.fields()) { @@ -89,15 +81,12 @@ protected void generateFieldWriteCheck( break; } } - if (targetField == null) return; List annotations = new ArrayList<>(); - targetField .findAttribute(Attributes.runtimeVisibleAnnotations()) .ifPresent(attr -> annotations.addAll(attr.annotations())); - targetField .findAttribute(Attributes.runtimeVisibleTypeAnnotations()) .ifPresent( @@ -110,24 +99,16 @@ protected void generateFieldWriteCheck( }); for (java.lang.classfile.Annotation annotation : annotations) { - String descriptor = annotation.classSymbol().descriptorString(); - TargetAnnotation target = targets.get(descriptor); - - if (target != null) { - injectFieldCheck(b, field, target); - } + TargetAnnotation target = targets.get(annotation.classSymbol().descriptorString()); + if (target != null) injectFieldCheck(b, field, target); } } private void injectFieldCheck(CodeBuilder b, FieldInstruction field, TargetAnnotation target) { TypeKind type = TypeKind.fromDescriptor(field.typeSymbol().descriptorString()); - - // TODO: Support category 2 types (long/double) - if (type.slotSize() != 1) return; - + // TODO: support cat 2 types (long/double) if (field.opcode() == Opcode.PUTSTATIC) { - b.dup(); target.check(b, type, "Static Field '" + field.name().stringValue() + "'"); } else if (field.opcode() == Opcode.PUTFIELD) { @@ -141,5 +122,29 @@ private void injectFieldCheck(CodeBuilder b, FieldInstruction field, TargetAnnot protected void generateFieldReadCheck(CodeBuilder b, FieldInstruction field) {} @Override - protected void generateReturnCheck(CodeBuilder b, ReturnInstruction ret) {} + protected void generateReturnCheck(CodeBuilder b, ReturnInstruction ret, MethodModel method) { + if (ret.opcode() != Opcode.ARETURN) return; + + List returnAnnotations = new ArrayList<>(); + + method + .findAttribute(Attributes.runtimeVisibleTypeAnnotations()) + .ifPresent( + attr -> { + for (TypeAnnotation typeAnno : attr.annotations()) { + if (typeAnno.targetInfo().targetType() == TypeAnnotation.TargetType.METHOD_RETURN) { + returnAnnotations.add(typeAnno.annotation()); + } + } + }); + + for (java.lang.classfile.Annotation annotation : returnAnnotations) { + TargetAnnotation target = targets.get(annotation.classSymbol().descriptorString()); + + if (target != null) { + b.dup(); + target.check(b, TypeKind.REFERENCE, "Return value of " + method.methodName().stringValue()); + } + } + } } 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 index d1f10b8..029293f 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeInstrumenter.java @@ -31,7 +31,6 @@ public ClassTransform asClassTransform(ClassModel classModel) { for (CodeElement element : codeModel) { if (element instanceof FieldInstruction fInst) { if (isFieldWrite(fInst)) { - generateFieldWriteCheck(codeBuilder, fInst, classModel); codeBuilder.with(element); } else if (isFieldRead(fInst)) { @@ -39,7 +38,7 @@ public ClassTransform asClassTransform(ClassModel classModel) { generateFieldReadCheck(codeBuilder, fInst); } } else if (element instanceof ReturnInstruction rInst) { - generateReturnCheck(codeBuilder, rInst); + generateReturnCheck(codeBuilder, rInst, methodModel); codeBuilder.with(element); } else { codeBuilder.with(element); @@ -85,5 +84,6 @@ protected abstract void generateFieldWriteCheck( protected abstract void generateFieldReadCheck(CodeBuilder b, FieldInstruction field); - protected abstract void generateReturnCheck(CodeBuilder b, ReturnInstruction ret); + protected abstract void generateReturnCheck( + CodeBuilder b, ReturnInstruction ret, MethodModel method); } 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 index e421bd0..577889e 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutInstrumenter.java @@ -45,7 +45,7 @@ protected void generateFieldReadCheck(CodeBuilder b, FieldInstruction field) { } @Override - protected void generateReturnCheck(CodeBuilder b, ReturnInstruction ret) { + protected void generateReturnCheck(CodeBuilder b, ReturnInstruction ret, MethodModel method) { print(b, " [Return Check] Returning from method via opcode: " + ret.opcode().name()); } } From c895dac11f04e0a9197cee45ec9f23b9d2cd691e Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Mon, 1 Dec 2025 08:40:42 -0500 Subject: [PATCH 23/50] feat(framework): add field read --- .../core/AnnotationInstrumenter.java | 86 ++++++++++++------- .../core/RuntimeInstrumenter.java | 5 +- .../util/SysOutInstrumenter.java | 3 +- 3 files changed, 59 insertions(+), 35 deletions(-) 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 index 8cb3f61..dd56775 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java @@ -32,8 +32,7 @@ protected void generateParamCheck( List paramAnnotations = getParameterAnnotations(method, paramIndex); for (java.lang.classfile.Annotation annotation : paramAnnotations) { - String descriptor = annotation.classSymbol().descriptorString(); - TargetAnnotation target = targets.get(descriptor); + TargetAnnotation target = targets.get(annotation.classSymbol().descriptorString()); if (target != null) { b.aload(slotIndex); target.check(b, type, "Parameter " + paramIndex); @@ -74,15 +73,63 @@ protected void generateFieldWriteCheck( CodeBuilder b, FieldInstruction field, ClassModel classModel) { if (!field.owner().equals(classModel.thisClass())) return; - FieldModel targetField = null; + FieldModel targetField = findField(classModel, field); + if (targetField == null) return; + + List annotations = getFieldAnnotations(targetField); + + for (java.lang.classfile.Annotation annotation : annotations) { + TargetAnnotation target = targets.get(annotation.classSymbol().descriptorString()); + if (target != null) { + TypeKind type = TypeKind.fromDescriptor(field.typeSymbol().descriptorString()); + if (type.slotSize() != 1) return; + + 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) { + // TODO: out of class fields + if (!field.owner().equals(classModel.thisClass())) return; + + FieldModel targetField = findField(classModel, field); + if (targetField == null) return; + + List annotations = getFieldAnnotations(targetField); + + for (java.lang.classfile.Annotation annotation : annotations) { + TargetAnnotation target = targets.get(annotation.classSymbol().descriptorString()); + if (target != null) { + + TypeKind type = TypeKind.fromDescriptor(field.typeSymbol().descriptorString()); + if (type.slotSize() == 1) { + b.dup(); + target.check(b, type, "Read Field '" + field.name().stringValue() + "'"); + } + } + } + } + + private FieldModel findField(ClassModel classModel, FieldInstruction field) { for (FieldModel fm : classModel.fields()) { if (fm.fieldName().equals(field.name()) && fm.fieldType().equals(field.type())) { - targetField = fm; - break; + return fm; } } - if (targetField == null) return; + return null; + } + private List getFieldAnnotations(FieldModel targetField) { List annotations = new ArrayList<>(); targetField .findAttribute(Attributes.runtimeVisibleAnnotations()) @@ -97,36 +144,13 @@ protected void generateFieldWriteCheck( } } }); - - for (java.lang.classfile.Annotation annotation : annotations) { - TargetAnnotation target = targets.get(annotation.classSymbol().descriptorString()); - if (target != null) injectFieldCheck(b, field, target); - } + return annotations; } - private void injectFieldCheck(CodeBuilder b, FieldInstruction field, TargetAnnotation target) { - TypeKind type = TypeKind.fromDescriptor(field.typeSymbol().descriptorString()); - if (type.slotSize() != 1) return; - // TODO: support cat 2 types (long/double) - 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) {} - @Override protected void generateReturnCheck(CodeBuilder b, ReturnInstruction ret, MethodModel method) { if (ret.opcode() != Opcode.ARETURN) return; - List returnAnnotations = new ArrayList<>(); - method .findAttribute(Attributes.runtimeVisibleTypeAnnotations()) .ifPresent( @@ -137,10 +161,8 @@ protected void generateReturnCheck(CodeBuilder b, ReturnInstruction ret, MethodM } } }); - for (java.lang.classfile.Annotation annotation : returnAnnotations) { TargetAnnotation target = targets.get(annotation.classSymbol().descriptorString()); - if (target != null) { b.dup(); target.check(b, TypeKind.REFERENCE, "Return value of " + method.methodName().stringValue()); 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 index 029293f..e0190ce 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeInstrumenter.java @@ -35,7 +35,7 @@ public ClassTransform asClassTransform(ClassModel classModel) { codeBuilder.with(element); } else if (isFieldRead(fInst)) { codeBuilder.with(element); - generateFieldReadCheck(codeBuilder, fInst); + generateFieldReadCheck(codeBuilder, fInst, classModel); } } else if (element instanceof ReturnInstruction rInst) { generateReturnCheck(codeBuilder, rInst, methodModel); @@ -82,7 +82,8 @@ protected abstract void generateParamCheck( protected abstract void generateFieldWriteCheck( CodeBuilder b, FieldInstruction field, ClassModel classModel); - protected abstract void generateFieldReadCheck(CodeBuilder b, FieldInstruction field); + protected abstract void generateFieldReadCheck( + CodeBuilder b, FieldInstruction field, ClassModel classModel); protected abstract void generateReturnCheck( CodeBuilder b, ReturnInstruction ret, MethodModel method); 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 index 577889e..c22f072 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutInstrumenter.java @@ -40,7 +40,8 @@ protected void generateFieldWriteCheck( } @Override - protected void generateFieldReadCheck(CodeBuilder b, FieldInstruction field) { + protected void generateFieldReadCheck( + CodeBuilder b, FieldInstruction field, ClassModel classModel) { print(b, " [Field Read] Just read from: " + field.name().stringValue()); } From 9f56cf671db9e4862b45e91518d0deb4f377b539 Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Mon, 1 Dec 2025 12:58:54 -0500 Subject: [PATCH 24/50] refactor(framework): rename generateParamCheck to generateParameterCheck --- .../eisop/runtimeframework/core/AnnotationInstrumenter.java | 3 ++- .../eisop/runtimeframework/core/RuntimeInstrumenter.java | 4 ++-- .../eisop/runtimeframework/util/SysOutInstrumenter.java | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) 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 index dd56775..fef6019 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java @@ -27,13 +27,14 @@ public AnnotationInstrumenter(Collection targetAnnotations) { } @Override - protected void generateParamCheck( + protected void generateParameterCheck( CodeBuilder b, int slotIndex, TypeKind type, MethodModel method, int paramIndex) { List paramAnnotations = getParameterAnnotations(method, paramIndex); for (java.lang.classfile.Annotation annotation : paramAnnotations) { TargetAnnotation target = targets.get(annotation.classSymbol().descriptorString()); if (target != null) { + // only handle reference types for now b.aload(slotIndex); target.check(b, type, "Parameter " + paramIndex); } 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 index e0190ce..23aa655 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeInstrumenter.java @@ -71,12 +71,12 @@ protected void instrumentMethodEntry(CodeBuilder builder, MethodModel method) { for (int i = 0; i < paramCount; i++) { TypeKind type = TypeKind.from(methodDesc.parameterList().get(i)); - generateParamCheck(builder, slotIndex, type, method, i); + generateParameterCheck(builder, slotIndex, type, method, i); slotIndex += type.slotSize(); } } - protected abstract void generateParamCheck( + protected abstract void generateParameterCheck( CodeBuilder b, int slotIndex, TypeKind type, MethodModel method, int paramIndex); protected abstract void generateFieldWriteCheck( 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 index c22f072..996e969 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutInstrumenter.java @@ -28,7 +28,7 @@ private void print(CodeBuilder b, String msg) { } @Override - protected void generateParamCheck( + protected void generateParameterCheck( CodeBuilder b, int slotIndex, TypeKind type, MethodModel method, int paramIndex) { print(b, " [Param Check] Verifying argument at slot " + slotIndex); } From ab961f6d99e2251ec5e6fabdef959af431d53f50 Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Mon, 1 Dec 2025 13:06:50 -0500 Subject: [PATCH 25/50] refactor(framework): remove fully qualified name usage --- .../core/AnnotationInstrumenter.java | 29 +++++++++---------- 1 file changed, 14 insertions(+), 15 deletions(-) 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 index fef6019..975891d 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java @@ -1,5 +1,6 @@ package io.github.eisop.runtimeframework.core; +import java.lang.classfile.Annotation; import java.lang.classfile.Attributes; import java.lang.classfile.ClassModel; import java.lang.classfile.CodeBuilder; @@ -29,9 +30,8 @@ public AnnotationInstrumenter(Collection targetAnnotations) { @Override protected void generateParameterCheck( CodeBuilder b, int slotIndex, TypeKind type, MethodModel method, int paramIndex) { - List paramAnnotations = - getParameterAnnotations(method, paramIndex); - for (java.lang.classfile.Annotation annotation : paramAnnotations) { + List paramAnnotations = getParameterAnnotations(method, paramIndex); + for (Annotation annotation : paramAnnotations) { TargetAnnotation target = targets.get(annotation.classSymbol().descriptorString()); if (target != null) { // only handle reference types for now @@ -41,14 +41,13 @@ protected void generateParameterCheck( } } - private List getParameterAnnotations( - MethodModel method, int paramIndex) { - List result = new ArrayList<>(); + private List getParameterAnnotations(MethodModel method, int paramIndex) { + List result = new ArrayList<>(); method .findAttribute(Attributes.runtimeVisibleParameterAnnotations()) .ifPresent( attr -> { - List> allParams = attr.parameterAnnotations(); + List> allParams = attr.parameterAnnotations(); if (paramIndex < allParams.size()) { result.addAll(allParams.get(paramIndex)); } @@ -77,9 +76,9 @@ protected void generateFieldWriteCheck( FieldModel targetField = findField(classModel, field); if (targetField == null) return; - List annotations = getFieldAnnotations(targetField); + List annotations = getFieldAnnotations(targetField); - for (java.lang.classfile.Annotation annotation : annotations) { + for (Annotation annotation : annotations) { TargetAnnotation target = targets.get(annotation.classSymbol().descriptorString()); if (target != null) { TypeKind type = TypeKind.fromDescriptor(field.typeSymbol().descriptorString()); @@ -106,9 +105,9 @@ protected void generateFieldReadCheck( FieldModel targetField = findField(classModel, field); if (targetField == null) return; - List annotations = getFieldAnnotations(targetField); + List annotations = getFieldAnnotations(targetField); - for (java.lang.classfile.Annotation annotation : annotations) { + for (Annotation annotation : annotations) { TargetAnnotation target = targets.get(annotation.classSymbol().descriptorString()); if (target != null) { @@ -130,8 +129,8 @@ private FieldModel findField(ClassModel classModel, FieldInstruction field) { return null; } - private List getFieldAnnotations(FieldModel targetField) { - List annotations = new ArrayList<>(); + private List getFieldAnnotations(FieldModel targetField) { + List annotations = new ArrayList<>(); targetField .findAttribute(Attributes.runtimeVisibleAnnotations()) .ifPresent(attr -> annotations.addAll(attr.annotations())); @@ -151,7 +150,7 @@ private List getFieldAnnotations(FieldModel targ @Override protected void generateReturnCheck(CodeBuilder b, ReturnInstruction ret, MethodModel method) { if (ret.opcode() != Opcode.ARETURN) return; - List returnAnnotations = new ArrayList<>(); + List returnAnnotations = new ArrayList<>(); method .findAttribute(Attributes.runtimeVisibleTypeAnnotations()) .ifPresent( @@ -162,7 +161,7 @@ protected void generateReturnCheck(CodeBuilder b, ReturnInstruction ret, MethodM } } }); - for (java.lang.classfile.Annotation annotation : returnAnnotations) { + for (Annotation annotation : returnAnnotations) { TargetAnnotation target = targets.get(annotation.classSymbol().descriptorString()); if (target != null) { b.dup(); From df51bae19e027aff1a654e74bbadf0b24d439ded Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Mon, 1 Dec 2025 14:44:20 -0500 Subject: [PATCH 26/50] feat(framework): introduce dynamic dispatch (WIP) --- .../runtimeframework/agent/RuntimeAgent.java | 30 +++-- .../agent/RuntimeTransformer.java | 2 +- .../core/AnnotationInstrumenter.java | 115 ++++++++++++++++++ .../core/HierarchyResolver.java | 22 ++++ .../core/ReflectionHierarchyResolver.java | 76 ++++++++++++ .../core/RuntimeInstrumenter.java | 80 +++++++----- .../util/SysOutInstrumenter.java | 8 ++ 7 files changed, 295 insertions(+), 38 deletions(-) create mode 100644 framework/src/main/java/io/github/eisop/runtimeframework/core/HierarchyResolver.java create mode 100644 framework/src/main/java/io/github/eisop/runtimeframework/core/ReflectionHierarchyResolver.java 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 f694916..f9ba99c 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,17 +1,33 @@ package io.github.eisop.runtimeframework.agent; 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 java.lang.instrument.Instrumentation; +import java.util.Arrays; public final class RuntimeAgent { public static void premain(String args, Instrumentation inst) { - // 1. Setup Filter - var filter = new FrameworkSafetyFilter(); + // 1. Base Safety Filter (Always active to protect JDK/Agent classes) + Filter activeFilter = new FrameworkSafetyFilter(); - // 2. Load Checker Dynamically - // Default to SysOut if nothing specified + // 2. Optional Checked List (Controlled by -Druntime.classes=com.Foo,com.Bar) + // Renamed from 'allowedClasses' to 'checkedClasses' to match the concept of Checked Code. + String checkedClasses = System.getProperty("runtime.classes"); + if (checkedClasses != null && !checkedClasses.isBlank()) { + System.out.println("[RuntimeAgent] Restricting instrumentation to: " + checkedClasses); + + Filter checkedList = new ClassListFilter(Arrays.asList(checkedClasses.split(","))); + + // Composition: Must be Safe AND in the Checked List + Filter safety = activeFilter; + activeFilter = info -> safety.test(info) && checkedList.test(info); + } + + // 3. Load Checker String checkerClassName = System.getProperty( "runtime.checker", "io.github.eisop.runtimeframework.util.SysOutRuntimeChecker"); @@ -25,10 +41,10 @@ public static void premain(String args, Instrumentation inst) { System.err.println( "[RuntimeAgent] FATAL: Could not instantiate checker: " + checkerClassName); e.printStackTrace(); - return; // Abort agent + return; } - // 3. Register - inst.addTransformer(new RuntimeTransformer(filter, checker), false); + // 4. Register + inst.addTransformer(new RuntimeTransformer(activeFilter, checker), false); } } diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java b/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java index 18aa321..d622d68 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java @@ -44,7 +44,7 @@ public byte[] transform( ClassFile cf = ClassFile.of(); ClassModel classModel = cf.parse(classfileBuffer); RuntimeInstrumenter instrumenter = checker.getInstrumenter(); - return cf.transformClass(classModel, instrumenter.asClassTransform(classModel)); + return cf.transformClass(classModel, instrumenter.asClassTransform(classModel, loader)); } catch (Exception e) { System.err.println("[RuntimeFramework] Failed to parse: " + className); 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 index 975891d..ebfec14 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java @@ -1,7 +1,10 @@ package io.github.eisop.runtimeframework.core; +import io.github.eisop.runtimeframework.filter.ClassInfo; +import io.github.eisop.runtimeframework.filter.FrameworkSafetyFilter; import java.lang.classfile.Annotation; import java.lang.classfile.Attributes; +import java.lang.classfile.ClassBuilder; import java.lang.classfile.ClassModel; import java.lang.classfile.CodeBuilder; import java.lang.classfile.FieldModel; @@ -11,7 +14,11 @@ import java.lang.classfile.TypeKind; import java.lang.classfile.instruction.FieldInstruction; import java.lang.classfile.instruction.ReturnInstruction; +import java.lang.constant.ClassDesc; +import java.lang.constant.MethodTypeDesc; +import java.lang.reflect.Method; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.List; import java.util.Map; @@ -20,11 +27,16 @@ public class AnnotationInstrumenter extends RuntimeInstrumenter { private final Map targets; + private final HierarchyResolver hierarchyResolver; public AnnotationInstrumenter(Collection targetAnnotations) { this.targets = targetAnnotations.stream() .collect(Collectors.toMap(t -> t.annotationType().descriptorString(), t -> t)); + FrameworkSafetyFilter safetyFilter = new FrameworkSafetyFilter(); + this.hierarchyResolver = + new ReflectionHierarchyResolver( + className -> safetyFilter.test(new ClassInfo(className.replace('.', '/'), null, null))); } @Override @@ -169,4 +181,107 @@ protected void generateReturnCheck(CodeBuilder b, ReturnInstruction ret, MethodM } } } + + // --- Bridge Method Generation --- + + @Override + protected void generateBridgeMethods(ClassBuilder builder, ClassModel model, ClassLoader loader) { + for (Method parentMethod : hierarchyResolver.resolveUncheckedMethods(model, loader)) { + boolean needsBridge = false; + + for (java.lang.annotation.Annotation[] paramAnnos : parentMethod.getParameterAnnotations()) { + for (java.lang.annotation.Annotation anno : paramAnnos) { + String desc = "L" + anno.annotationType().getName().replace('.', '/') + ";"; + if (targets.containsKey(desc)) { + needsBridge = true; + break; + } + } + } + + if (needsBridge) { + emitBridge(builder, parentMethod); + } + } + } + + private void emitBridge(ClassBuilder builder, Method parentMethod) { + String methodName = parentMethod.getName(); + + // FIX: Use JDK APIs instead of ASM + 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; + java.lang.annotation.Annotation[][] allAnnos = + parentMethod.getParameterAnnotations(); + Class[] paramTypes = parentMethod.getParameterTypes(); + + // 1. INJECT CHECKS + for (int i = 0; i < paramTypes.length; i++) { + TypeKind type = + TypeKind.from(ClassDesc.ofDescriptor(paramTypes[i].descriptorString())); + + for (java.lang.annotation.Annotation anno : allAnnos[i]) { + String annoDesc = "L" + anno.annotationType().getName().replace('.', '/') + ";"; + TargetAnnotation target = targets.get(annoDesc); + if (target != null) { + codeBuilder.aload(slotIndex); + target.check( + codeBuilder, + type, + "Parameter " + i + " in inherited method " + methodName); + } + } + slotIndex += type.slotSize(); + } + + // 2. CALL SUPER + 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); + + // 3. RETURN + returnResult(codeBuilder, parentMethod.getReturnType()); + }); + }); + } + + 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: " + 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/HierarchyResolver.java b/framework/src/main/java/io/github/eisop/runtimeframework/core/HierarchyResolver.java new file mode 100644 index 0000000..e53d583 --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/HierarchyResolver.java @@ -0,0 +1,22 @@ +package io.github.eisop.runtimeframework.core; + +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/core/ReflectionHierarchyResolver.java b/framework/src/main/java/io/github/eisop/runtimeframework/core/ReflectionHierarchyResolver.java new file mode 100644 index 0000000..db111a7 --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/ReflectionHierarchyResolver.java @@ -0,0 +1,76 @@ +package io.github.eisop.runtimeframework.core; + +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; + + // FIX: Use manual descriptor generation instead of ASM + 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; + } + + // Helper to generate descriptor (e.g. "(Ljava/lang/String;)V") using JDK APIs + 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/core/RuntimeInstrumenter.java b/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeInstrumenter.java index 23aa655..8347062 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeInstrumenter.java @@ -1,5 +1,7 @@ package io.github.eisop.runtimeframework.core; +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; @@ -17,40 +19,50 @@ public abstract class RuntimeInstrumenter { public RuntimeInstrumenter() {} - public ClassTransform asClassTransform(ClassModel classModel) { - return (classBuilder, classElement) -> { - if (classElement instanceof MethodModel methodModel && methodModel.code().isPresent()) { - classBuilder.transformMethod( - methodModel, - (methodBuilder, methodElement) -> { - if (methodElement instanceof CodeAttribute codeModel) { - methodBuilder.withCode( - codeBuilder -> { - instrumentMethodEntry(codeBuilder, methodModel); - - for (CodeElement element : codeModel) { - if (element instanceof FieldInstruction fInst) { - if (isFieldWrite(fInst)) { - generateFieldWriteCheck(codeBuilder, fInst, classModel); + // UPDATED: Now accepts ClassLoader to pass to the HierarchyResolver + public ClassTransform asClassTransform(ClassModel classModel, ClassLoader loader) { + // We use an anonymous class to implement 'atEnd' + 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 -> { + instrumentMethodEntry(codeBuilder, methodModel); + for (CodeElement element : codeModel) { + if (element instanceof FieldInstruction fInst) { + if (isFieldWrite(fInst)) { + generateFieldWriteCheck(codeBuilder, fInst, classModel); + codeBuilder.with(element); + } else if (isFieldRead(fInst)) { + codeBuilder.with(element); + generateFieldReadCheck(codeBuilder, fInst, classModel); + } + } else if (element instanceof ReturnInstruction rInst) { + generateReturnCheck(codeBuilder, rInst, methodModel); codeBuilder.with(element); - } else if (isFieldRead(fInst)) { + } else { codeBuilder.with(element); - generateFieldReadCheck(codeBuilder, fInst, classModel); } - } else if (element instanceof ReturnInstruction rInst) { - generateReturnCheck(codeBuilder, rInst, methodModel); - codeBuilder.with(element); - } else { - codeBuilder.with(element); } - } - }); - } else { - methodBuilder.with(methodElement); - } - }); - } else { - classBuilder.with(classElement); + }); + } else { + methodBuilder.with(methodElement); + } + }); + } else { + classBuilder.with(classElement); + } + } + + @Override + public void atEnd(ClassBuilder builder) { + // Hook to generate bridge methods after all existing methods are processed + generateBridgeMethods(builder, classModel, loader); } }; } @@ -71,11 +83,15 @@ protected void instrumentMethodEntry(CodeBuilder builder, MethodModel method) { for (int i = 0; i < paramCount; i++) { TypeKind type = TypeKind.from(methodDesc.parameterList().get(i)); + // REFACTOR: generateParameterCheck (was generateParamCheck) generateParameterCheck(builder, slotIndex, type, method, i); slotIndex += type.slotSize(); } } + // --- Abstract Hooks --- + + // REFACTOR: Renamed from generateParamCheck protected abstract void generateParameterCheck( CodeBuilder b, int slotIndex, TypeKind type, MethodModel method, int paramIndex); @@ -87,4 +103,8 @@ protected abstract void generateFieldReadCheck( protected abstract void generateReturnCheck( CodeBuilder b, ReturnInstruction ret, MethodModel method); + + // NEW HOOK: Bridge Generation + protected abstract void generateBridgeMethods( + ClassBuilder builder, ClassModel model, ClassLoader loader); } 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 index 996e969..55627b8 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutInstrumenter.java @@ -1,6 +1,7 @@ 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; @@ -49,4 +50,11 @@ protected void generateFieldReadCheck( protected void generateReturnCheck(CodeBuilder b, ReturnInstruction ret, MethodModel method) { print(b, " [Return Check] Returning from method via opcode: " + ret.opcode().name()); } + + @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()); + } } From 13ccfc44197d66e54a107596f4a0e52e013137a9 Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Mon, 1 Dec 2025 15:39:16 -0500 Subject: [PATCH 27/50] feat(framework): method invocation implementation --- .../nullness/NullnessRuntimeChecker.java | 8 +- .../agent/RuntimeTransformer.java | 2 +- .../core/AnnotationInstrumenter.java | 76 ++++++++++++++++--- .../runtimeframework/core/RuntimeChecker.java | 18 ++++- .../core/RuntimeInstrumenter.java | 15 ++-- .../util/SysOutInstrumenter.java | 16 +++- .../util/SysOutRuntimeChecker.java | 12 +-- 7 files changed, 111 insertions(+), 36 deletions(-) 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 index 3d1a437..c42b0a4 100644 --- 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 @@ -3,6 +3,8 @@ import io.github.eisop.runtimeframework.core.AnnotationInstrumenter; 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 java.util.List; public class NullnessRuntimeChecker extends RuntimeChecker { @@ -13,8 +15,8 @@ public String getName() { } @Override - public RuntimeInstrumenter getInstrumenter() { - // Register our strategies - return new AnnotationInstrumenter(List.of(new NonNullTarget())); + public RuntimeInstrumenter getInstrumenter(Filter filter) { + // Pass the filter to the instrumenter + return new AnnotationInstrumenter(List.of(new NonNullTarget()), filter); } } diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java b/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java index d622d68..933c269 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java @@ -43,7 +43,7 @@ public byte[] transform( try { ClassFile cf = ClassFile.of(); ClassModel classModel = cf.parse(classfileBuffer); - RuntimeInstrumenter instrumenter = checker.getInstrumenter(); + RuntimeInstrumenter instrumenter = checker.getInstrumenter(filter); return cf.transformClass(classModel, instrumenter.asClassTransform(classModel, loader)); } catch (Exception e) { 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 index ebfec14..47ab806 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java @@ -1,7 +1,7 @@ package io.github.eisop.runtimeframework.core; import io.github.eisop.runtimeframework.filter.ClassInfo; -import io.github.eisop.runtimeframework.filter.FrameworkSafetyFilter; +import io.github.eisop.runtimeframework.filter.Filter; import java.lang.classfile.Annotation; import java.lang.classfile.Attributes; import java.lang.classfile.ClassBuilder; @@ -13,6 +13,7 @@ import java.lang.classfile.TypeAnnotation; import java.lang.classfile.TypeKind; import java.lang.classfile.instruction.FieldInstruction; +import java.lang.classfile.instruction.InvokeInstruction; import java.lang.classfile.instruction.ReturnInstruction; import java.lang.constant.ClassDesc; import java.lang.constant.MethodTypeDesc; @@ -28,15 +29,26 @@ public class AnnotationInstrumenter extends RuntimeInstrumenter { private final Map targets; private final HierarchyResolver hierarchyResolver; + private final Filter safetyFilter; + private final TargetAnnotation defaultTarget; - public AnnotationInstrumenter(Collection targetAnnotations) { + // UPDATED CONSTRUCTOR: Now accepts the Filter directly from the Checker + public AnnotationInstrumenter( + Collection targetAnnotations, Filter safetyFilter) { this.targets = targetAnnotations.stream() .collect(Collectors.toMap(t -> t.annotationType().descriptorString(), t -> t)); - FrameworkSafetyFilter safetyFilter = new FrameworkSafetyFilter(); + + this.defaultTarget = targetAnnotations.stream().findFirst().orElse(null); + + // Store the passed filter instead of recreating it from system properties + this.safetyFilter = safetyFilter; + + // Use the passed filter for the hierarchy resolver too this.hierarchyResolver = new ReflectionHierarchyResolver( - className -> safetyFilter.test(new ClassInfo(className.replace('.', '/'), null, null))); + className -> + this.safetyFilter.test(new ClassInfo(className.replace('.', '/'), null, null))); } @Override @@ -46,7 +58,6 @@ protected void generateParameterCheck( for (Annotation annotation : paramAnnotations) { TargetAnnotation target = targets.get(annotation.classSymbol().descriptorString()); if (target != null) { - // only handle reference types for now b.aload(slotIndex); target.check(b, type, "Parameter " + paramIndex); } @@ -72,6 +83,7 @@ private List getParameterAnnotations(MethodModel method, int paramIn TypeAnnotation.TargetInfo target = typeAnno.targetInfo(); if (target instanceof TypeAnnotation.FormalParameterTarget paramTarget) { if (paramTarget.formalParameterIndex() == paramIndex) { + // Assuming TypeAnnotation in recent JDK 25 builds exposes .annotation() result.add(typeAnno.annotation()); } } @@ -111,7 +123,6 @@ protected void generateFieldWriteCheck( @Override protected void generateFieldReadCheck( CodeBuilder b, FieldInstruction field, ClassModel classModel) { - // TODO: out of class fields if (!field.owner().equals(classModel.thisClass())) return; FieldModel targetField = findField(classModel, field); @@ -122,7 +133,6 @@ protected void generateFieldReadCheck( for (Annotation annotation : annotations) { TargetAnnotation target = targets.get(annotation.classSymbol().descriptorString()); if (target != null) { - TypeKind type = TypeKind.fromDescriptor(field.typeSymbol().descriptorString()); if (type.slotSize() == 1) { b.dup(); @@ -182,21 +192,51 @@ protected void generateReturnCheck(CodeBuilder b, ReturnInstruction ret, MethodM } } - // --- Bridge Method Generation --- + @Override + protected void generateMethodCallCheck(CodeBuilder b, InvokeInstruction invoke) { + String ownerClass = invoke.owner().asInternalName(); + boolean isUncheckedTarget = !safetyFilter.test(new ClassInfo(ownerClass, null, null)); + + if (isUncheckedTarget) { + ClassDesc returnType = invoke.typeSymbol().returnType(); + TypeKind type = TypeKind.from(returnType); + + if (type == TypeKind.REFERENCE && defaultTarget != null) { + b.dup(); + defaultTarget.check(b, type, "Result from unchecked method " + invoke.name().stringValue()); + } + } + } @Override protected void generateBridgeMethods(ClassBuilder builder, ClassModel model, ClassLoader loader) { for (Method parentMethod : hierarchyResolver.resolveUncheckedMethods(model, loader)) { + + // 1. DETERMINE IF BRIDGE IS NEEDED + // We check both Explicit Annotations AND the Implicit Default Policy boolean needsBridge = false; + Class[] paramTypes = parentMethod.getParameterTypes(); + java.lang.annotation.Annotation[][] paramAnnos = parentMethod.getParameterAnnotations(); + + for (int i = 0; i < paramTypes.length; i++) { + boolean explicitFound = false; - for (java.lang.annotation.Annotation[] paramAnnos : parentMethod.getParameterAnnotations()) { - for (java.lang.annotation.Annotation anno : paramAnnos) { + // Check Explicit + for (java.lang.annotation.Annotation anno : paramAnnos[i]) { String desc = "L" + anno.annotationType().getName().replace('.', '/') + ";"; if (targets.containsKey(desc)) { needsBridge = true; + explicitFound = true; break; } } + + // Check Default (If no explicit annotation and it's a reference type) + if (!explicitFound && !paramTypes[i].isPrimitive() && defaultTarget != null) { + needsBridge = true; + } + + if (needsBridge) break; } if (needsBridge) { @@ -207,8 +247,6 @@ protected void generateBridgeMethods(ClassBuilder builder, ClassModel model, Cla private void emitBridge(ClassBuilder builder, Method parentMethod) { String methodName = parentMethod.getName(); - - // FIX: Use JDK APIs instead of ASM MethodTypeDesc desc = MethodTypeDesc.of( ClassDesc.ofDescriptor(parentMethod.getReturnType().descriptorString()), @@ -233,6 +271,9 @@ private void emitBridge(ClassBuilder builder, Method parentMethod) { TypeKind type = TypeKind.from(ClassDesc.ofDescriptor(paramTypes[i].descriptorString())); + boolean checkGenerated = false; + + // A. Check Explicit Annotations for (java.lang.annotation.Annotation anno : allAnnos[i]) { String annoDesc = "L" + anno.annotationType().getName().replace('.', '/') + ";"; TargetAnnotation target = targets.get(annoDesc); @@ -242,8 +283,19 @@ private void emitBridge(ClassBuilder builder, Method parentMethod) { codeBuilder, type, "Parameter " + i + " in inherited method " + methodName); + checkGenerated = true; } } + + // B. Check Default (Strict Mode for Unchecked Inheritance) + if (!checkGenerated && type == TypeKind.REFERENCE && defaultTarget != null) { + codeBuilder.aload(slotIndex); + defaultTarget.check( + codeBuilder, + type, + "Unannotated Parameter " + i + " in inherited method " + methodName); + } + slotIndex += type.slotSize(); } diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeChecker.java b/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeChecker.java index cef0cb9..3a9bc36 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeChecker.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeChecker.java @@ -1,10 +1,22 @@ package io.github.eisop.runtimeframework.core; +import io.github.eisop.runtimeframework.filter.ClassInfo; +import io.github.eisop.runtimeframework.filter.Filter; + +/** + * 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 human-readable name of this checker */ + /** Returns the human-readable name of this checker (e.g., "Nullness Runtime Checker"). */ public abstract String getName(); - /** Creates or returns the instrumenter that injects this checker's logic. */ - public abstract RuntimeInstrumenter getInstrumenter(); + /** + * Creates or returns the instrumenter that injects this checker's logic. + * + * @param filter The safety filter currently active in the Agent. The instrumenter can use this to + * determine boundary checks (Checked vs Unchecked). + */ + public abstract RuntimeInstrumenter getInstrumenter(Filter 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 index 8347062..2932243 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeInstrumenter.java @@ -11,6 +11,7 @@ import java.lang.classfile.TypeKind; import java.lang.classfile.attribute.CodeAttribute; import java.lang.classfile.instruction.FieldInstruction; +import java.lang.classfile.instruction.InvokeInstruction; // <--- NEW import java.lang.classfile.instruction.ReturnInstruction; import java.lang.constant.MethodTypeDesc; import java.lang.reflect.Modifier; @@ -19,9 +20,7 @@ public abstract class RuntimeInstrumenter { public RuntimeInstrumenter() {} - // UPDATED: Now accepts ClassLoader to pass to the HierarchyResolver public ClassTransform asClassTransform(ClassModel classModel, ClassLoader loader) { - // We use an anonymous class to implement 'atEnd' return new ClassTransform() { @Override public void accept(ClassBuilder classBuilder, ClassElement classElement) { @@ -45,6 +44,11 @@ public void accept(ClassBuilder classBuilder, ClassElement classElement) { } else if (element instanceof ReturnInstruction rInst) { generateReturnCheck(codeBuilder, rInst, methodModel); codeBuilder.with(element); + } else if (element instanceof InvokeInstruction invoke) { // <--- NEW HOOK + // 1. Write the original call + codeBuilder.with(element); + // 2. Check the result (if applicable) + generateMethodCallCheck(codeBuilder, invoke); } else { codeBuilder.with(element); } @@ -61,7 +65,6 @@ public void accept(ClassBuilder classBuilder, ClassElement classElement) { @Override public void atEnd(ClassBuilder builder) { - // Hook to generate bridge methods after all existing methods are processed generateBridgeMethods(builder, classModel, loader); } }; @@ -83,7 +86,6 @@ protected void instrumentMethodEntry(CodeBuilder builder, MethodModel method) { for (int i = 0; i < paramCount; i++) { TypeKind type = TypeKind.from(methodDesc.parameterList().get(i)); - // REFACTOR: generateParameterCheck (was generateParamCheck) generateParameterCheck(builder, slotIndex, type, method, i); slotIndex += type.slotSize(); } @@ -91,7 +93,6 @@ protected void instrumentMethodEntry(CodeBuilder builder, MethodModel method) { // --- Abstract Hooks --- - // REFACTOR: Renamed from generateParamCheck protected abstract void generateParameterCheck( CodeBuilder b, int slotIndex, TypeKind type, MethodModel method, int paramIndex); @@ -104,7 +105,9 @@ protected abstract void generateFieldReadCheck( protected abstract void generateReturnCheck( CodeBuilder b, ReturnInstruction ret, MethodModel method); - // NEW HOOK: Bridge Generation + // NEW HOOK: Triggered immediately after INVOKEVIRTUAL/STATIC/INTERFACE etc. + protected abstract void generateMethodCallCheck(CodeBuilder b, InvokeInstruction invoke); + protected abstract void generateBridgeMethods( ClassBuilder builder, ClassModel model, ClassLoader loader); } 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 index 55627b8..13662f5 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutInstrumenter.java @@ -7,6 +7,7 @@ import java.lang.classfile.MethodModel; import java.lang.classfile.TypeKind; import java.lang.classfile.instruction.FieldInstruction; +import java.lang.classfile.instruction.InvokeInstruction; // New import java.lang.classfile.instruction.ReturnInstruction; import java.lang.constant.ClassDesc; import java.lang.constant.MethodTypeDesc; @@ -51,10 +52,19 @@ protected void generateReturnCheck(CodeBuilder b, ReturnInstruction ret, MethodM print(b, " [Return Check] Returning from method via opcode: " + ret.opcode().name()); } + // NEW HOOK IMPLEMENTATION + @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()); + // No-op for debug } } 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 index ccfb857..561bba8 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutRuntimeChecker.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutRuntimeChecker.java @@ -2,14 +2,9 @@ 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; -/** - * A basic checker for testing and debugging the framework infrastructure. - * - *

Instead of enforcing safety properties, this checker injects System.out.println statements at - * every instrumentation point. Use this to verify that the Agent is correctly intercepting and - * rewriting classes. - */ public class SysOutRuntimeChecker extends RuntimeChecker { @Override @@ -18,7 +13,8 @@ public String getName() { } @Override - public RuntimeInstrumenter getInstrumenter() { + public RuntimeInstrumenter getInstrumenter(Filter filter) { + // SysOut instrumenter ignores the filter (it logs everything) return new SysOutInstrumenter(); } } From 94f8b07543cc03b7022829ae15900c5a3c39d4e2 Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Tue, 2 Dec 2025 13:15:54 -0500 Subject: [PATCH 28/50] feat(framework): introduce policy abstraction --- .../nullness/NullnessRuntimeChecker.java | 16 +- .../agent/RuntimeTransformer.java | 31 +- .../core/AnnotationInstrumenter.java | 268 ++++-------------- .../core/EnforcementPolicy.java | 46 +++ .../core/StandardEnforcementPolicy.java | 183 ++++++++++++ .../filter/ClassListFilter.java | 20 +- 6 files changed, 326 insertions(+), 238 deletions(-) create mode 100644 framework/src/main/java/io/github/eisop/runtimeframework/core/EnforcementPolicy.java create mode 100644 framework/src/main/java/io/github/eisop/runtimeframework/core/StandardEnforcementPolicy.java 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 index c42b0a4..9aa4cce 100644 --- 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 @@ -1,8 +1,12 @@ package io.github.eisop.runtimeframework.checker.nullness; import io.github.eisop.runtimeframework.core.AnnotationInstrumenter; +import io.github.eisop.runtimeframework.core.EnforcementPolicy; +import io.github.eisop.runtimeframework.core.HierarchyResolver; +import io.github.eisop.runtimeframework.core.ReflectionHierarchyResolver; import io.github.eisop.runtimeframework.core.RuntimeChecker; import io.github.eisop.runtimeframework.core.RuntimeInstrumenter; +import io.github.eisop.runtimeframework.core.StandardEnforcementPolicy; import io.github.eisop.runtimeframework.filter.ClassInfo; import io.github.eisop.runtimeframework.filter.Filter; import java.util.List; @@ -16,7 +20,15 @@ public String getName() { @Override public RuntimeInstrumenter getInstrumenter(Filter filter) { - // Pass the filter to the instrumenter - return new AnnotationInstrumenter(List.of(new NonNullTarget()), filter); + // 1. Define Policy (Auto-discovers Default Target from @DefaultQualifierInHierarchy) + EnforcementPolicy policy = new StandardEnforcementPolicy(List.of(new NonNullTarget()), filter); + + // 2. Define Hierarchy Resolver + HierarchyResolver resolver = + new ReflectionHierarchyResolver( + className -> filter.test(new ClassInfo(className.replace('.', '/'), null, null))); + + // 3. Create Instrumenter + return new AnnotationInstrumenter(policy, resolver); } } diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java b/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java index 933c269..6a7a751 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java @@ -28,27 +28,34 @@ public byte[] transform( ProtectionDomain protectionDomain, byte[] classfileBuffer) { - // 1. Wrap context - ClassInfo info = new ClassInfo(className, loader, module); - - // 2. Check Filter - if (!filter.test(info)) { + // IGNORE JDK INTERNALS to avoid crashing the console + if (className != null + && (className.startsWith("java/") + || className.startsWith("sun/") + || className.startsWith("jdk/") + || className.startsWith("org/gradle"))) { return null; } - // 3. Output matched class - System.out.println("[RuntimeFramework] Filter matched: " + className); - - // 4. Perform transform try { + ClassInfo info = new ClassInfo(className, loader, module); + boolean accepted = filter.test(info); + + if (!accepted) { + System.out.println("[RuntimeFramework] -> REJECTED by filter"); + return null; + } + + System.out.println("[RuntimeFramework] -> ACCEPTED. Instrumenting..."); + ClassFile cf = ClassFile.of(); ClassModel classModel = cf.parse(classfileBuffer); RuntimeInstrumenter instrumenter = checker.getInstrumenter(filter); return cf.transformClass(classModel, instrumenter.asClassTransform(classModel, loader)); - } catch (Exception e) { - System.err.println("[RuntimeFramework] Failed to parse: " + className); - e.printStackTrace(); + } catch (Throwable t) { + System.err.println("[RuntimeFramework] CRASH transforming: " + className); + t.printStackTrace(); return null; } } 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 index 47ab806..1400600 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java @@ -1,16 +1,11 @@ package io.github.eisop.runtimeframework.core; -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.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.TypeAnnotation; import java.lang.classfile.TypeKind; import java.lang.classfile.instruction.FieldInstruction; import java.lang.classfile.instruction.InvokeInstruction; @@ -18,104 +13,47 @@ import java.lang.constant.ClassDesc; import java.lang.constant.MethodTypeDesc; import java.lang.reflect.Method; -import java.util.ArrayList; import java.util.Arrays; -import java.util.Collection; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; public class AnnotationInstrumenter extends RuntimeInstrumenter { - private final Map targets; + private final EnforcementPolicy policy; private final HierarchyResolver hierarchyResolver; - private final Filter safetyFilter; - private final TargetAnnotation defaultTarget; - // UPDATED CONSTRUCTOR: Now accepts the Filter directly from the Checker - public AnnotationInstrumenter( - Collection targetAnnotations, Filter safetyFilter) { - this.targets = - targetAnnotations.stream() - .collect(Collectors.toMap(t -> t.annotationType().descriptorString(), t -> t)); - - this.defaultTarget = targetAnnotations.stream().findFirst().orElse(null); - - // Store the passed filter instead of recreating it from system properties - this.safetyFilter = safetyFilter; - - // Use the passed filter for the hierarchy resolver too - this.hierarchyResolver = - new ReflectionHierarchyResolver( - className -> - this.safetyFilter.test(new ClassInfo(className.replace('.', '/'), null, null))); + public AnnotationInstrumenter(EnforcementPolicy policy, HierarchyResolver hierarchyResolver) { + this.policy = policy; + this.hierarchyResolver = hierarchyResolver; } @Override protected void generateParameterCheck( CodeBuilder b, int slotIndex, TypeKind type, MethodModel method, int paramIndex) { - List paramAnnotations = getParameterAnnotations(method, paramIndex); - for (Annotation annotation : paramAnnotations) { - TargetAnnotation target = targets.get(annotation.classSymbol().descriptorString()); - if (target != null) { - b.aload(slotIndex); - target.check(b, type, "Parameter " + paramIndex); - } + TargetAnnotation target = policy.getParameterCheck(method, paramIndex, type); + if (target != null) { + b.aload(slotIndex); + target.check(b, type, "Parameter " + paramIndex); } } - private List getParameterAnnotations(MethodModel method, int paramIndex) { - List result = new ArrayList<>(); - method - .findAttribute(Attributes.runtimeVisibleParameterAnnotations()) - .ifPresent( - attr -> { - List> allParams = attr.parameterAnnotations(); - if (paramIndex < allParams.size()) { - result.addAll(allParams.get(paramIndex)); - } - }); - method - .findAttribute(Attributes.runtimeVisibleTypeAnnotations()) - .ifPresent( - attr -> { - for (TypeAnnotation typeAnno : attr.annotations()) { - TypeAnnotation.TargetInfo target = typeAnno.targetInfo(); - if (target instanceof TypeAnnotation.FormalParameterTarget paramTarget) { - if (paramTarget.formalParameterIndex() == paramIndex) { - // Assuming TypeAnnotation in recent JDK 25 builds exposes .annotation() - result.add(typeAnno.annotation()); - } - } - } - }); - return result; - } - @Override protected void generateFieldWriteCheck( CodeBuilder b, FieldInstruction field, ClassModel classModel) { - if (!field.owner().equals(classModel.thisClass())) return; + if (!field.owner().asInternalName().equals(classModel.thisClass().asInternalName())) return; FieldModel targetField = findField(classModel, field); if (targetField == null) return; - List annotations = getFieldAnnotations(targetField); - - for (Annotation annotation : annotations) { - TargetAnnotation target = targets.get(annotation.classSymbol().descriptorString()); - if (target != null) { - TypeKind type = TypeKind.fromDescriptor(field.typeSymbol().descriptorString()); - if (type.slotSize() != 1) return; + TypeKind type = TypeKind.fromDescriptor(field.typeSymbol().descriptorString()); + TargetAnnotation target = policy.getFieldWriteCheck(targetField, type); - 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(); - } + 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(); } } } @@ -123,123 +61,52 @@ protected void generateFieldWriteCheck( @Override protected void generateFieldReadCheck( CodeBuilder b, FieldInstruction field, ClassModel classModel) { - if (!field.owner().equals(classModel.thisClass())) return; - - FieldModel targetField = findField(classModel, field); - if (targetField == null) return; + TargetAnnotation target = null; + TypeKind type = TypeKind.fromDescriptor(field.typeSymbol().descriptorString()); - List annotations = getFieldAnnotations(targetField); - - for (Annotation annotation : annotations) { - TargetAnnotation target = targets.get(annotation.classSymbol().descriptorString()); - if (target != null) { - TypeKind type = TypeKind.fromDescriptor(field.typeSymbol().descriptorString()); - if (type.slotSize() == 1) { - b.dup(); - target.check(b, type, "Read Field '" + field.name().stringValue() + "'"); - } + 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); } - } - private FieldModel findField(ClassModel classModel, FieldInstruction field) { - for (FieldModel fm : classModel.fields()) { - if (fm.fieldName().equals(field.name()) && fm.fieldType().equals(field.type())) { - return fm; + if (target != null) { + if (type.slotSize() == 1) { + b.dup(); + target.check(b, type, "Read Field '" + field.name().stringValue() + "'"); } } - return null; - } - - private List getFieldAnnotations(FieldModel targetField) { - List annotations = new ArrayList<>(); - targetField - .findAttribute(Attributes.runtimeVisibleAnnotations()) - .ifPresent(attr -> annotations.addAll(attr.annotations())); - targetField - .findAttribute(Attributes.runtimeVisibleTypeAnnotations()) - .ifPresent( - attr -> { - for (TypeAnnotation ta : attr.annotations()) { - if (ta.targetInfo() instanceof TypeAnnotation.EmptyTarget) { - annotations.add(ta.annotation()); - } - } - }); - return annotations; } @Override protected void generateReturnCheck(CodeBuilder b, ReturnInstruction ret, MethodModel method) { - if (ret.opcode() != Opcode.ARETURN) return; - List returnAnnotations = new ArrayList<>(); - method - .findAttribute(Attributes.runtimeVisibleTypeAnnotations()) - .ifPresent( - attr -> { - for (TypeAnnotation typeAnno : attr.annotations()) { - if (typeAnno.targetInfo().targetType() == TypeAnnotation.TargetType.METHOD_RETURN) { - returnAnnotations.add(typeAnno.annotation()); - } - } - }); - for (Annotation annotation : returnAnnotations) { - TargetAnnotation target = targets.get(annotation.classSymbol().descriptorString()); - if (target != null) { - b.dup(); - target.check(b, TypeKind.REFERENCE, "Return value of " + method.methodName().stringValue()); - } + TargetAnnotation target = policy.getReturnCheck(method); + if (target != null) { + b.dup(); + target.check(b, TypeKind.REFERENCE, "Return value of " + method.methodName().stringValue()); } } @Override protected void generateMethodCallCheck(CodeBuilder b, InvokeInstruction invoke) { - String ownerClass = invoke.owner().asInternalName(); - boolean isUncheckedTarget = !safetyFilter.test(new ClassInfo(ownerClass, null, null)); - - if (isUncheckedTarget) { - ClassDesc returnType = invoke.typeSymbol().returnType(); - TypeKind type = TypeKind.from(returnType); - - if (type == TypeKind.REFERENCE && defaultTarget != null) { - b.dup(); - defaultTarget.check(b, type, "Result from unchecked method " + invoke.name().stringValue()); - } + TargetAnnotation target = + policy.getBoundaryCallCheck(invoke.owner().asInternalName(), invoke.typeSymbol()); + if (target != null) { + b.dup(); + target.check( + b, TypeKind.REFERENCE, "Result from unchecked method " + invoke.name().stringValue()); } } @Override protected void generateBridgeMethods(ClassBuilder builder, ClassModel model, ClassLoader loader) { for (Method parentMethod : hierarchyResolver.resolveUncheckedMethods(model, loader)) { - - // 1. DETERMINE IF BRIDGE IS NEEDED - // We check both Explicit Annotations AND the Implicit Default Policy - boolean needsBridge = false; - Class[] paramTypes = parentMethod.getParameterTypes(); - java.lang.annotation.Annotation[][] paramAnnos = parentMethod.getParameterAnnotations(); - - for (int i = 0; i < paramTypes.length; i++) { - boolean explicitFound = false; - - // Check Explicit - for (java.lang.annotation.Annotation anno : paramAnnos[i]) { - String desc = "L" + anno.annotationType().getName().replace('.', '/') + ";"; - if (targets.containsKey(desc)) { - needsBridge = true; - explicitFound = true; - break; - } - } - - // Check Default (If no explicit annotation and it's a reference type) - if (!explicitFound && !paramTypes[i].isPrimitive() && defaultTarget != null) { - needsBridge = true; - } - - if (needsBridge) break; - } - - if (needsBridge) { + if (policy.shouldGenerateBridge(parentMethod)) { emitBridge(builder, parentMethod); } } @@ -262,44 +129,23 @@ private void emitBridge(ClassBuilder builder, Method parentMethod) { methodBuilder.withCode( codeBuilder -> { int slotIndex = 1; - java.lang.annotation.Annotation[][] allAnnos = - parentMethod.getParameterAnnotations(); Class[] paramTypes = parentMethod.getParameterTypes(); - // 1. INJECT CHECKS + // 1. Checks for (int i = 0; i < paramTypes.length; i++) { TypeKind type = TypeKind.from(ClassDesc.ofDescriptor(paramTypes[i].descriptorString())); - boolean checkGenerated = false; - - // A. Check Explicit Annotations - for (java.lang.annotation.Annotation anno : allAnnos[i]) { - String annoDesc = "L" + anno.annotationType().getName().replace('.', '/') + ";"; - TargetAnnotation target = targets.get(annoDesc); - if (target != null) { - codeBuilder.aload(slotIndex); - target.check( - codeBuilder, - type, - "Parameter " + i + " in inherited method " + methodName); - checkGenerated = true; - } - } - - // B. Check Default (Strict Mode for Unchecked Inheritance) - if (!checkGenerated && type == TypeKind.REFERENCE && defaultTarget != null) { + TargetAnnotation target = policy.getBridgeParameterCheck(parentMethod, i); + if (target != null) { codeBuilder.aload(slotIndex); - defaultTarget.check( - codeBuilder, - type, - "Unannotated Parameter " + i + " in inherited method " + methodName); + target.check( + codeBuilder, type, "Parameter " + i + " in inherited method " + methodName); } - slotIndex += type.slotSize(); } - // 2. CALL SUPER + // 2. Super Call codeBuilder.aload(0); slotIndex = 1; for (Class pType : paramTypes) { @@ -311,12 +157,22 @@ private void emitBridge(ClassBuilder builder, Method parentMethod) { ClassDesc parentDesc = ClassDesc.of(parentMethod.getDeclaringClass().getName()); codeBuilder.invokespecial(parentDesc, methodName, desc); - // 3. RETURN + // 3. Return 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); @@ -324,7 +180,7 @@ private void loadLocal(CodeBuilder b, TypeKind type, int slot) { case FLOAT -> b.fload(slot); case DOUBLE -> b.dload(slot); case REFERENCE -> b.aload(slot); - default -> throw new IllegalArgumentException("Unknown type: " + type); + default -> throw new IllegalArgumentException("Unknown type"); } } diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/core/EnforcementPolicy.java b/framework/src/main/java/io/github/eisop/runtimeframework/core/EnforcementPolicy.java new file mode 100644 index 0000000..828f3b2 --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/EnforcementPolicy.java @@ -0,0 +1,46 @@ +package io.github.eisop.runtimeframework.core; + +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. + * + *

This separates the "Mechanism" (Bytecode Generation) from the "Policy" (Safety Rules). It + * allows swapping between Strict, Lenient, or Debug policies without changing the instrumenter. + */ +public interface EnforcementPolicy { + + // --- 1. Internal Logic (Method Bodies) --- + + /** 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); + + // --- 2. Boundary Logic (Calls & External Access) --- + + /** We are calling a method on 'owner'. Should we check the result? */ + TargetAnnotation getBoundaryCallCheck(String owner, MethodTypeDesc desc); + + /** We are reading a field from an EXTERNAL class. Should we check the value? */ + TargetAnnotation getBoundaryFieldReadCheck(String owner, String fieldName, TypeKind type); + + // --- 3. Inheritance Logic (Bridges) --- + + /** 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); +} diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/core/StandardEnforcementPolicy.java b/framework/src/main/java/io/github/eisop/runtimeframework/core/StandardEnforcementPolicy.java new file mode 100644 index 0000000..215ccc3 --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/StandardEnforcementPolicy.java @@ -0,0 +1,183 @@ +package io.github.eisop.runtimeframework.core; + +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.stream.Collectors; + +/** + * The standard policy for Annotation-Driven Runtime Verification. + */ +public class StandardEnforcementPolicy implements EnforcementPolicy { + + private final Map targets; + private final TargetAnnotation defaultTarget; + private final Filter safetyFilter; + + public StandardEnforcementPolicy(Collection targetAnnotations, Filter safetyFilter) { + this.targets = targetAnnotations.stream() + .collect(Collectors.toMap(t -> t.annotationType().descriptorString(), t -> t)); + this.defaultTarget = targetAnnotations.stream().findFirst().orElse(null); + this.safetyFilter = safetyFilter; + } + + private TargetAnnotation findTarget(List annotations) { + for (Annotation a : annotations) { + TargetAnnotation t = targets.get(a.classSymbol().descriptorString()); + if (t != null) return t; + } + return null; + } + + @Override + public TargetAnnotation getParameterCheck(MethodModel method, int paramIndex, TypeKind type) { + if (type != TypeKind.REFERENCE) return null; + TargetAnnotation explicit = findTarget(getMethodParamAnnotations(method, paramIndex)); + if (explicit != null) return explicit; + 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; + return findTarget(getFieldAnnotations(field)); + } + + @Override + public TargetAnnotation getReturnCheck(MethodModel method) { + TargetAnnotation explicit = findTarget(getMethodReturnAnnotations(method)); + if (explicit != null) return explicit; + + TypeKind returnKind = TypeKind.from(method.methodTypeSymbol().returnType()); + if (returnKind == TypeKind.REFERENCE) { + return defaultTarget; + } + return null; + } + + // --- 2. Boundary Logic --- + + @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) { + 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) { + 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; + } + + ClassDesc pTypeDesc = ClassDesc.ofDescriptor(paramType.descriptorString()); + if (TypeKind.from(pTypeDesc) == TypeKind.REFERENCE) { + return defaultTarget; + } + return null; + } + + + 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() instanceof TypeAnnotation.EmptyTarget) { + result.add(ta.annotation()); + } + } + }); + return result; + } + + private List getMethodReturnAnnotations(MethodModel method) { + List result = new ArrayList<>(); + method.findAttribute(Attributes.runtimeVisibleTypeAnnotations()) + .ifPresent(attr -> { + for (TypeAnnotation ta : attr.annotations()) { + // FIX: Use enum comparison, not instanceof + if (ta.targetInfo().targetType() == TypeAnnotation.TargetType.METHOD_RETURN) { + result.add(ta.annotation()); + } + } + }); + return result; + } +} 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 index bf868b1..45cdaeb 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/filter/ClassListFilter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/filter/ClassListFilter.java @@ -5,19 +5,7 @@ import java.util.Set; import java.util.stream.Collectors; -/** - * Accepts ONLY the classes whose names are listed (exact match). - * - *

Names may be given as: - * - *

- "io.github.eisop.Foo" - * - *

- "io/github/eisop/Foo" - * - *

- "io/github/eisop/Foo.class" - * - *

All are normalized to internal names like "io/github/eisop/Foo". - */ +/** Accepts ONLY the classes whose names are listed (exact match). */ public final class ClassListFilter implements Filter { private final Set allowed; @@ -30,25 +18,21 @@ public ClassListFilter(Collection classNames) { .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; } } From 758da07437ee0d0adf8ab2186f0abfa3083fed59 Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Wed, 17 Dec 2025 14:45:25 -0500 Subject: [PATCH 29/50] feat(framework): introduce global policy --- .../nullness/NullnessRuntimeChecker.java | 15 +- .../runtimeframework/agent/RuntimeAgent.java | 34 ++-- .../agent/RuntimeTransformer.java | 33 ++-- .../core/AnnotationInstrumenter.java | 107 ++++++++-- .../core/EnforcementPolicy.java | 46 ----- .../core/HierarchyResolver.java | 22 --- .../core/ReflectionHierarchyResolver.java | 76 -------- .../runtimeframework/core/RuntimeChecker.java | 28 ++- .../core/RuntimeInstrumenter.java | 63 ++++-- .../core/StandardEnforcementPolicy.java | 183 ------------------ .../filter/ClassListFilter.java | 1 - .../util/SysOutInstrumenter.java | 23 ++- 12 files changed, 239 insertions(+), 392 deletions(-) delete mode 100644 framework/src/main/java/io/github/eisop/runtimeframework/core/EnforcementPolicy.java delete mode 100644 framework/src/main/java/io/github/eisop/runtimeframework/core/HierarchyResolver.java delete mode 100644 framework/src/main/java/io/github/eisop/runtimeframework/core/ReflectionHierarchyResolver.java delete mode 100644 framework/src/main/java/io/github/eisop/runtimeframework/core/StandardEnforcementPolicy.java 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 index 9aa4cce..c939dbf 100644 --- 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 @@ -1,14 +1,13 @@ package io.github.eisop.runtimeframework.checker.nullness; import io.github.eisop.runtimeframework.core.AnnotationInstrumenter; -import io.github.eisop.runtimeframework.core.EnforcementPolicy; -import io.github.eisop.runtimeframework.core.HierarchyResolver; -import io.github.eisop.runtimeframework.core.ReflectionHierarchyResolver; import io.github.eisop.runtimeframework.core.RuntimeChecker; import io.github.eisop.runtimeframework.core.RuntimeInstrumenter; -import io.github.eisop.runtimeframework.core.StandardEnforcementPolicy; 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; public class NullnessRuntimeChecker extends RuntimeChecker { @@ -20,15 +19,15 @@ public String getName() { @Override public RuntimeInstrumenter getInstrumenter(Filter filter) { - // 1. Define Policy (Auto-discovers Default Target from @DefaultQualifierInHierarchy) - EnforcementPolicy policy = new StandardEnforcementPolicy(List.of(new NonNullTarget()), filter); + // 1. Create Policy + EnforcementPolicy policy = createPolicy(List.of(new NonNullTarget()), filter); - // 2. Define Hierarchy Resolver + // 2. Create Resolver HierarchyResolver resolver = new ReflectionHierarchyResolver( className -> filter.test(new ClassInfo(className.replace('.', '/'), null, null))); // 3. Create Instrumenter - return new AnnotationInstrumenter(policy, resolver); + return new AnnotationInstrumenter(policy, resolver, filter); } } 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 f9ba99c..54a04fd 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 @@ -11,23 +11,34 @@ public final class RuntimeAgent { public static void premain(String args, Instrumentation inst) { - // 1. Base Safety Filter (Always active to protect JDK/Agent classes) - Filter activeFilter = new FrameworkSafetyFilter(); + // 1. Safety Filter (Always active to protect JDK/Framework) + Filter safeFilter = new FrameworkSafetyFilter(); - // 2. Optional Checked List (Controlled by -Druntime.classes=com.Foo,com.Bar) - // Renamed from 'allowedClasses' to 'checkedClasses' to match the concept of Checked Code. + // 2. Defaults + Filter policyFilter = safeFilter; // "Checked" scope + Filter scanFilter = safeFilter; // "Instrumentation" scope + + // 3. User Configuration: Checked List String checkedClasses = System.getProperty("runtime.classes"); if (checkedClasses != null && !checkedClasses.isBlank()) { - System.out.println("[RuntimeAgent] Restricting instrumentation to: " + checkedClasses); + System.out.println("[RuntimeAgent] Checked Scope restricted to: " + checkedClasses); + Filter listFilter = new ClassListFilter(Arrays.asList(checkedClasses.split(","))); - Filter checkedList = new ClassListFilter(Arrays.asList(checkedClasses.split(","))); + // Policy: Must be Safe AND in List + policyFilter = info -> safeFilter.test(info) && listFilter.test(info); + // Default Scan: Matches Policy + scanFilter = policyFilter; + } - // Composition: Must be Safe AND in the Checked List - Filter safety = activeFilter; - activeFilter = info -> safety.test(info) && checkedList.test(info); + // 4. User Configuration: Global Mode + // If enabled, we scan EVERYTHING safe, even if it's not in the Checked List. + // This allows us to instrument LegacyLib to catch writes to Checked code. + if (Boolean.getBoolean("runtime.global")) { + System.out.println("[RuntimeAgent] Global Mode ENABLED. Scanning all safe classes."); + scanFilter = safeFilter; } - // 3. Load Checker + // 5. Load Checker String checkerClassName = System.getProperty( "runtime.checker", "io.github.eisop.runtimeframework.util.SysOutRuntimeChecker"); @@ -44,7 +55,6 @@ public static void premain(String args, Instrumentation inst) { return; } - // 4. Register - inst.addTransformer(new RuntimeTransformer(activeFilter, checker), false); + inst.addTransformer(new RuntimeTransformer(scanFilter, policyFilter, checker), false); } } diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java b/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java index 6a7a751..b901760 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java @@ -11,11 +11,15 @@ public class RuntimeTransformer implements ClassFileTransformer { - private final Filter filter; + private final Filter scanFilter; // Which classes do we parse/instrument? + private final Filter + policyFilter; // Which classes are considered "Checked" by the policy? private final RuntimeChecker checker; - public RuntimeTransformer(Filter filter, RuntimeChecker checker) { - this.filter = filter; + public RuntimeTransformer( + Filter scanFilter, Filter policyFilter, RuntimeChecker checker) { + this.scanFilter = scanFilter; + this.policyFilter = policyFilter; this.checker = checker; } @@ -28,7 +32,7 @@ public byte[] transform( ProtectionDomain protectionDomain, byte[] classfileBuffer) { - // IGNORE JDK INTERNALS to avoid crashing the console + // Skip JDK internals to avoid boot loop issues if (className != null && (className.startsWith("java/") || className.startsWith("sun/") @@ -37,20 +41,23 @@ public byte[] transform( return null; } - try { - ClassInfo info = new ClassInfo(className, loader, module); - boolean accepted = filter.test(info); + ClassInfo info = new ClassInfo(className, loader, module); - if (!accepted) { - System.out.println("[RuntimeFramework] -> REJECTED by filter"); - return null; - } + // 1. Check Scanning Scope + if (!scanFilter.test(info)) { + return null; + } - System.out.println("[RuntimeFramework] -> ACCEPTED. Instrumenting..."); + System.out.println("[RuntimeFramework] Processing: " + className); + try { ClassFile cf = ClassFile.of(); ClassModel classModel = cf.parse(classfileBuffer); - RuntimeInstrumenter instrumenter = checker.getInstrumenter(filter); + + // 2. Pass the POLICY filter to the instrumenter factory + // The instrumenter will use this to distinguish Checked vs Unchecked + RuntimeInstrumenter instrumenter = checker.getInstrumenter(policyFilter); + return cf.transformClass(classModel, instrumenter.asClassTransform(classModel, loader)); } catch (Throwable t) { 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 index 1400600..33dd390 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java @@ -1,5 +1,9 @@ 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; @@ -20,11 +24,17 @@ public class AnnotationInstrumenter extends RuntimeInstrumenter { private final EnforcementPolicy policy; private final HierarchyResolver hierarchyResolver; - public AnnotationInstrumenter(EnforcementPolicy policy, HierarchyResolver hierarchyResolver) { + public AnnotationInstrumenter( + EnforcementPolicy policy, + HierarchyResolver hierarchyResolver, + Filter safetyFilter) { + super(safetyFilter); // Pass filter to base class this.policy = policy; this.hierarchyResolver = hierarchyResolver; } + // --- Hooks --- + @Override protected void generateParameterCheck( CodeBuilder b, int slotIndex, TypeKind type, MethodModel method, int paramIndex) { @@ -38,13 +48,22 @@ protected void generateParameterCheck( @Override protected void generateFieldWriteCheck( CodeBuilder b, FieldInstruction field, ClassModel classModel) { - if (!field.owner().asInternalName().equals(classModel.thisClass().asInternalName())) return; - - FieldModel targetField = findField(classModel, field); - if (targetField == null) return; - + TargetAnnotation target = null; TypeKind type = TypeKind.fromDescriptor(field.typeSymbol().descriptorString()); - TargetAnnotation target = policy.getFieldWriteCheck(targetField, type); + + // A. Internal Write (this.field = val) + if (field.owner().asInternalName().equals(classModel.thisClass().asInternalName())) { + FieldModel targetField = findField(classModel, field); + if (targetField != null) { + target = policy.getFieldWriteCheck(targetField, type); + } + } + // B. External Write (other.field = val) - Crucial for Global Policy + else { + target = + policy.getBoundaryFieldWriteCheck( + field.owner().asInternalName(), field.name().stringValue(), type); + } if (target != null) { if (field.opcode() == Opcode.PUTSTATIC) { @@ -92,6 +111,32 @@ protected void generateReturnCheck(CodeBuilder b, ReturnInstruction ret, MethodM } } + @Override + protected void generateUncheckedReturnCheck( + CodeBuilder b, + ReturnInstruction ret, + MethodModel method, + ClassModel classModel, + ClassLoader loader) { + if (ret.opcode() != Opcode.ARETURN) return; + + // Find if we are overriding a method from a CHECKED parent + String checkedParent = findCheckedOverriddenMethod(classModel, method, loader); + + if (checkedParent != null) { + TargetAnnotation target = + policy.getBoundaryMethodOverrideReturnCheck(checkedParent, method.methodTypeSymbol()); + + 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) { TargetAnnotation target = @@ -112,6 +157,8 @@ protected void generateBridgeMethods(ClassBuilder builder, ClassModel model, Cla } } + // --- Helpers --- + private void emitBridge(ClassBuilder builder, Method parentMethod) { String methodName = parentMethod.getName(); MethodTypeDesc desc = @@ -131,11 +178,9 @@ private void emitBridge(ClassBuilder builder, Method parentMethod) { int slotIndex = 1; Class[] paramTypes = parentMethod.getParameterTypes(); - // 1. Checks 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); @@ -145,7 +190,6 @@ private void emitBridge(ClassBuilder builder, Method parentMethod) { slotIndex += type.slotSize(); } - // 2. Super Call codeBuilder.aload(0); slotIndex = 1; for (Class pType : paramTypes) { @@ -156,13 +200,52 @@ private void emitBridge(ClassBuilder builder, Method parentMethod) { ClassDesc parentDesc = ClassDesc.of(parentMethod.getDeclaringClass().getName()); codeBuilder.invokespecial(parentDesc, methodName, desc); - - // 3. Return returnResult(codeBuilder, parentMethod.getReturnType()); }); }); } + private String findCheckedOverriddenMethod( + 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('.', '/'); + + // Use the PROTECTED scopeFilter from the base class + if (scopeFilter.test(new ClassInfo(internalName, null, null))) { + 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)) { + return internalName; + } + } + } + } + parent = parent.getSuperclass(); + } + } catch (Exception e) { + System.out.println("here"); + } + return null; + } + + 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(); + } + private FieldModel findField(ClassModel classModel, FieldInstruction field) { for (FieldModel fm : classModel.fields()) { if (fm.fieldName().stringValue().equals(field.name().stringValue()) diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/core/EnforcementPolicy.java b/framework/src/main/java/io/github/eisop/runtimeframework/core/EnforcementPolicy.java deleted file mode 100644 index 828f3b2..0000000 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/EnforcementPolicy.java +++ /dev/null @@ -1,46 +0,0 @@ -package io.github.eisop.runtimeframework.core; - -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. - * - *

This separates the "Mechanism" (Bytecode Generation) from the "Policy" (Safety Rules). It - * allows swapping between Strict, Lenient, or Debug policies without changing the instrumenter. - */ -public interface EnforcementPolicy { - - // --- 1. Internal Logic (Method Bodies) --- - - /** 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); - - // --- 2. Boundary Logic (Calls & External Access) --- - - /** We are calling a method on 'owner'. Should we check the result? */ - TargetAnnotation getBoundaryCallCheck(String owner, MethodTypeDesc desc); - - /** We are reading a field from an EXTERNAL class. Should we check the value? */ - TargetAnnotation getBoundaryFieldReadCheck(String owner, String fieldName, TypeKind type); - - // --- 3. Inheritance Logic (Bridges) --- - - /** 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); -} diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/core/HierarchyResolver.java b/framework/src/main/java/io/github/eisop/runtimeframework/core/HierarchyResolver.java deleted file mode 100644 index e53d583..0000000 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/HierarchyResolver.java +++ /dev/null @@ -1,22 +0,0 @@ -package io.github.eisop.runtimeframework.core; - -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/core/ReflectionHierarchyResolver.java b/framework/src/main/java/io/github/eisop/runtimeframework/core/ReflectionHierarchyResolver.java deleted file mode 100644 index db111a7..0000000 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/ReflectionHierarchyResolver.java +++ /dev/null @@ -1,76 +0,0 @@ -package io.github.eisop.runtimeframework.core; - -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; - - // FIX: Use manual descriptor generation instead of ASM - 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; - } - - // Helper to generate descriptor (e.g. "(Ljava/lang/String;)V") using JDK APIs - 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/core/RuntimeChecker.java b/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeChecker.java index 3a9bc36..ba4f815 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeChecker.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeChecker.java @@ -2,6 +2,10 @@ 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 @@ -15,8 +19,28 @@ public abstract class RuntimeChecker { /** * Creates or returns the instrumenter that injects this checker's logic. * - * @param filter The safety filter currently active in the Agent. The instrumenter can use this to - * determine boundary checks (Checked vs Unchecked). + * @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, Filter filter) { + boolean isGlobalMode = Boolean.getBoolean("runtime.global"); + if (isGlobalMode) { + return new GlobalEnforcementPolicy(targets, filter); + } else { + return new StandardEnforcementPolicy(targets, 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 index 2932243..4c03fc8 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeInstrumenter.java @@ -1,5 +1,7 @@ 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; @@ -11,16 +13,24 @@ import java.lang.classfile.TypeKind; import java.lang.classfile.attribute.CodeAttribute; import java.lang.classfile.instruction.FieldInstruction; -import java.lang.classfile.instruction.InvokeInstruction; // <--- NEW +import java.lang.classfile.instruction.InvokeInstruction; import java.lang.classfile.instruction.ReturnInstruction; import java.lang.constant.MethodTypeDesc; import java.lang.reflect.Modifier; public abstract class RuntimeInstrumenter { - public RuntimeInstrumenter() {} + protected final Filter scopeFilter; + + protected RuntimeInstrumenter(Filter scopeFilter) { + this.scopeFilter = scopeFilter; + } public ClassTransform asClassTransform(ClassModel classModel, ClassLoader loader) { + // 1. Determine Scope for this class + boolean isCheckedScope = + scopeFilter.test(new ClassInfo(classModel.thisClass().asInternalName(), loader, null)); + return new ClassTransform() { @Override public void accept(ClassBuilder classBuilder, ClassElement classElement) { @@ -31,24 +41,41 @@ public void accept(ClassBuilder classBuilder, ClassElement classElement) { if (methodElement instanceof CodeAttribute codeModel) { methodBuilder.withCode( codeBuilder -> { - instrumentMethodEntry(codeBuilder, methodModel); + + // GATE: Only check parameters if we are in Checked Code + if (isCheckedScope) { + instrumentMethodEntry(codeBuilder, methodModel); + } + for (CodeElement element : codeModel) { if (element instanceof FieldInstruction fInst) { if (isFieldWrite(fInst)) { + // Always check writes (Handles Global Write Protection) generateFieldWriteCheck(codeBuilder, fInst, classModel); codeBuilder.with(element); } else if (isFieldRead(fInst)) { codeBuilder.with(element); - generateFieldReadCheck(codeBuilder, fInst, classModel); + // GATE: Only check reads if we are in Checked Code + if (isCheckedScope) { + generateFieldReadCheck(codeBuilder, fInst, classModel); + } } } else if (element instanceof ReturnInstruction rInst) { - generateReturnCheck(codeBuilder, rInst, methodModel); + // SPLIT: Checked Return vs. Unchecked Override Return + if (isCheckedScope) { + generateReturnCheck(codeBuilder, rInst, methodModel); + } else { + // Pass ClassLoader to help resolve hierarchy + generateUncheckedReturnCheck( + codeBuilder, rInst, methodModel, classModel, loader); + } codeBuilder.with(element); - } else if (element instanceof InvokeInstruction invoke) { // <--- NEW HOOK - // 1. Write the original call + } else if (element instanceof InvokeInstruction invoke) { codeBuilder.with(element); - // 2. Check the result (if applicable) - generateMethodCallCheck(codeBuilder, invoke); + // GATE: Only check calls if we are in Checked Code + if (isCheckedScope) { + generateMethodCallCheck(codeBuilder, invoke); + } } else { codeBuilder.with(element); } @@ -65,16 +92,19 @@ public void accept(ClassBuilder classBuilder, ClassElement classElement) { @Override public void atEnd(ClassBuilder builder) { - generateBridgeMethods(builder, classModel, loader); + // GATE: Only generate bridges if we are in Checked Code + if (isCheckedScope) { + generateBridgeMethods(builder, classModel, loader); + } } }; } - private boolean isFieldWrite(FieldInstruction f) { + protected boolean isFieldWrite(FieldInstruction f) { return f.opcode() == Opcode.PUTFIELD || f.opcode() == Opcode.PUTSTATIC; } - private boolean isFieldRead(FieldInstruction f) { + protected boolean isFieldRead(FieldInstruction f) { return f.opcode() == Opcode.GETFIELD || f.opcode() == Opcode.GETSTATIC; } @@ -105,7 +135,14 @@ protected abstract void generateFieldReadCheck( protected abstract void generateReturnCheck( CodeBuilder b, ReturnInstruction ret, MethodModel method); - // NEW HOOK: Triggered immediately after INVOKEVIRTUAL/STATIC/INTERFACE etc. + // NEW: Hook for Unchecked classes overriding Checked methods + 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( diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/core/StandardEnforcementPolicy.java b/framework/src/main/java/io/github/eisop/runtimeframework/core/StandardEnforcementPolicy.java deleted file mode 100644 index 215ccc3..0000000 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/StandardEnforcementPolicy.java +++ /dev/null @@ -1,183 +0,0 @@ -package io.github.eisop.runtimeframework.core; - -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.stream.Collectors; - -/** - * The standard policy for Annotation-Driven Runtime Verification. - */ -public class StandardEnforcementPolicy implements EnforcementPolicy { - - private final Map targets; - private final TargetAnnotation defaultTarget; - private final Filter safetyFilter; - - public StandardEnforcementPolicy(Collection targetAnnotations, Filter safetyFilter) { - this.targets = targetAnnotations.stream() - .collect(Collectors.toMap(t -> t.annotationType().descriptorString(), t -> t)); - this.defaultTarget = targetAnnotations.stream().findFirst().orElse(null); - this.safetyFilter = safetyFilter; - } - - private TargetAnnotation findTarget(List annotations) { - for (Annotation a : annotations) { - TargetAnnotation t = targets.get(a.classSymbol().descriptorString()); - if (t != null) return t; - } - return null; - } - - @Override - public TargetAnnotation getParameterCheck(MethodModel method, int paramIndex, TypeKind type) { - if (type != TypeKind.REFERENCE) return null; - TargetAnnotation explicit = findTarget(getMethodParamAnnotations(method, paramIndex)); - if (explicit != null) return explicit; - 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; - return findTarget(getFieldAnnotations(field)); - } - - @Override - public TargetAnnotation getReturnCheck(MethodModel method) { - TargetAnnotation explicit = findTarget(getMethodReturnAnnotations(method)); - if (explicit != null) return explicit; - - TypeKind returnKind = TypeKind.from(method.methodTypeSymbol().returnType()); - if (returnKind == TypeKind.REFERENCE) { - return defaultTarget; - } - return null; - } - - // --- 2. Boundary Logic --- - - @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) { - 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) { - 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; - } - - ClassDesc pTypeDesc = ClassDesc.ofDescriptor(paramType.descriptorString()); - if (TypeKind.from(pTypeDesc) == TypeKind.REFERENCE) { - return defaultTarget; - } - return null; - } - - - 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() instanceof TypeAnnotation.EmptyTarget) { - result.add(ta.annotation()); - } - } - }); - return result; - } - - private List getMethodReturnAnnotations(MethodModel method) { - List result = new ArrayList<>(); - method.findAttribute(Attributes.runtimeVisibleTypeAnnotations()) - .ifPresent(attr -> { - for (TypeAnnotation ta : attr.annotations()) { - // FIX: Use enum comparison, not instanceof - if (ta.targetInfo().targetType() == TypeAnnotation.TargetType.METHOD_RETURN) { - result.add(ta.annotation()); - } - } - }); - return result; - } -} 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 index 45cdaeb..e4310de 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/filter/ClassListFilter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/filter/ClassListFilter.java @@ -18,7 +18,6 @@ public ClassListFilter(Collection classNames) { .filter(Objects::nonNull) .map(ClassListFilter::toInternalName) .collect(Collectors.toUnmodifiableSet()); - } @Override 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 index 13662f5..6b916b9 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutInstrumenter.java @@ -7,7 +7,7 @@ import java.lang.classfile.MethodModel; import java.lang.classfile.TypeKind; import java.lang.classfile.instruction.FieldInstruction; -import java.lang.classfile.instruction.InvokeInstruction; // New +import java.lang.classfile.instruction.InvokeInstruction; import java.lang.classfile.instruction.ReturnInstruction; import java.lang.constant.ClassDesc; import java.lang.constant.MethodTypeDesc; @@ -20,7 +20,8 @@ public class SysOutInstrumenter extends RuntimeInstrumenter { MethodTypeDesc.ofDescriptor("(Ljava/lang/String;)V"); public SysOutInstrumenter() { - super(); + // Pass a filter that accepts everything for debug purposes + super(info -> true); } private void print(CodeBuilder b, String msg) { @@ -52,7 +53,19 @@ protected void generateReturnCheck(CodeBuilder b, ReturnInstruction ret, MethodM print(b, " [Return Check] Returning from method via opcode: " + ret.opcode().name()); } - // NEW HOOK IMPLEMENTATION + @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( @@ -65,6 +78,8 @@ protected void generateMethodCallCheck(CodeBuilder b, InvokeInstruction invoke) @Override protected void generateBridgeMethods(ClassBuilder builder, ClassModel model, ClassLoader loader) { - // No-op for debug + // 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()); } } From 2b580e0fe946aac0ed98647311d5444d737614fb Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Wed, 17 Dec 2025 14:45:50 -0500 Subject: [PATCH 30/50] refactor(framework): policy and resolution refactor --- .../policy/EnforcementPolicy.java | 63 ++++++ .../policy/GlobalEnforcementPolicy.java | 65 ++++++ .../policy/StandardEnforcementPolicy.java | 193 ++++++++++++++++++ .../resolution/HierarchyResolver.java | 22 ++ .../ReflectionHierarchyResolver.java | 76 +++++++ 5 files changed, 419 insertions(+) create mode 100644 framework/src/main/java/io/github/eisop/runtimeframework/policy/EnforcementPolicy.java create mode 100644 framework/src/main/java/io/github/eisop/runtimeframework/policy/GlobalEnforcementPolicy.java create mode 100644 framework/src/main/java/io/github/eisop/runtimeframework/policy/StandardEnforcementPolicy.java create mode 100644 framework/src/main/java/io/github/eisop/runtimeframework/resolution/HierarchyResolver.java create mode 100644 framework/src/main/java/io/github/eisop/runtimeframework/resolution/ReflectionHierarchyResolver.java 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..022d004 --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/policy/EnforcementPolicy.java @@ -0,0 +1,63 @@ +package io.github.eisop.runtimeframework.policy; + +import io.github.eisop.runtimeframework.core.TargetAnnotation; +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. + * + *

This separates the "Mechanism" (Bytecode Generation) from the "Policy" (Safety Rules). It + * allows swapping between Strict, Lenient, or Debug policies without changing the instrumenter. + */ +public interface EnforcementPolicy { + + // --- 1. Internal Logic (Method Bodies) --- + + /** 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: Returns {@code null} (No check). Most policies do not instrument unchecked + * code and therefore cannot enforce this. + */ + default TargetAnnotation getBoundaryFieldWriteCheck( + String owner, String fieldName, TypeKind type) { + return null; + } + + default TargetAnnotation getBoundaryMethodOverrideReturnCheck(String owner, MethodTypeDesc desc) { + return null; + } + + // --- 2. Boundary Logic (Calls & External Access) --- + + /** We are calling a method on 'owner'. Should we check the result? */ + TargetAnnotation getBoundaryCallCheck(String owner, MethodTypeDesc desc); + + /** We are reading a field from an EXTERNAL class. Should we check the value? */ + TargetAnnotation getBoundaryFieldReadCheck(String owner, String fieldName, TypeKind type); + + // --- 3. Inheritance Logic (Bridges) --- + + /** 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); +} 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..dfddfc9 --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/policy/GlobalEnforcementPolicy.java @@ -0,0 +1,65 @@ +package io.github.eisop.runtimeframework.policy; + +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.TypeKind; +import java.lang.constant.MethodTypeDesc; +import java.util.Collection; + +/** + * An aggressive policy that enforces checks even inside Unchecked code. + * + *

This policy is used when the Agent is configured to instrument ALL classes (not just the + * Checked ones). It detects when Unchecked code interacts with Checked code and injects "Firewall" + * checks to protect the Trusted boundary. + */ +public class GlobalEnforcementPolicy extends StandardEnforcementPolicy { + + public GlobalEnforcementPolicy( + Collection targetAnnotations, Filter safetyFilter) { + super(targetAnnotations, safetyFilter); + } + + @Override + public TargetAnnotation getBoundaryFieldWriteCheck( + String owner, String fieldName, TypeKind type) { + // GLOBAL LOGIC: + // We are currently in Unchecked Code (Legacy). + // We are writing to 'owner'. + + // 1. Is the Target Class (owner) Checked? + // Note: safetyFilter returns true if the class is "Safe/Checked". + boolean isTargetChecked = safetyFilter.test(new ClassInfo(owner, null, null)); + + // 2. Is it a Reference? + // 3. Do we have a strict default? + if (isTargetChecked && type == TypeKind.REFERENCE) { + // Yes. The Legacy code is attempting to write to a Checked Field. + // We must enforce NonNull to prevent poisoning the Checked class. + return super.defaultTarget; + } + + return null; + } + + @Override + public TargetAnnotation getBoundaryMethodOverrideReturnCheck(String owner, MethodTypeDesc desc) { + // GLOBAL LOGIC: + // We are in Unchecked Code, and we have overridden a method defined in 'owner'. + // (The Instrumenter is responsible for determining which class defined the contract). + + // 1. Is the Defining Class (Parent) Checked? + boolean isParentChecked = safetyFilter.test(new ClassInfo(owner, null, null)); + + // 2. Does it return a Reference? + TypeKind returnType = TypeKind.from(desc.returnType()); + + if (isParentChecked && returnType == TypeKind.REFERENCE) { + // Yes. We are returning a value that the Checked Parent promised would be NonNull. + // We must check our return value to ensure we honor the Liskov Substitution Principle. + return super.defaultTarget; + } + return null; + } +} 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..e45d6cf --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/policy/StandardEnforcementPolicy.java @@ -0,0 +1,193 @@ +package io.github.eisop.runtimeframework.policy; + +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.stream.Collectors; + +/** The standard policy for Annotation-Driven Runtime Verification. */ +public class StandardEnforcementPolicy implements EnforcementPolicy { + + protected final Map targets; + protected final TargetAnnotation defaultTarget; + protected final Filter safetyFilter; + + public StandardEnforcementPolicy( + Collection targetAnnotations, Filter safetyFilter) { + this.targets = + targetAnnotations.stream() + .collect(Collectors.toMap(t -> t.annotationType().descriptorString(), t -> t)); + this.defaultTarget = targetAnnotations.stream().findFirst().orElse(null); + this.safetyFilter = safetyFilter; + } + + private TargetAnnotation findTarget(List annotations) { + for (Annotation a : annotations) { + TargetAnnotation t = targets.get(a.classSymbol().descriptorString()); + if (t != null) return t; + } + return null; + } + + @Override + public TargetAnnotation getParameterCheck(MethodModel method, int paramIndex, TypeKind type) { + if (type != TypeKind.REFERENCE) return null; + TargetAnnotation explicit = findTarget(getMethodParamAnnotations(method, paramIndex)); + if (explicit != null) return explicit; + 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; + return findTarget(getFieldAnnotations(field)); + } + + @Override + public TargetAnnotation getReturnCheck(MethodModel method) { + TargetAnnotation explicit = findTarget(getMethodReturnAnnotations(method)); + if (explicit != null) return explicit; + + TypeKind returnKind = TypeKind.from(method.methodTypeSymbol().returnType()); + if (returnKind == TypeKind.REFERENCE) { + return defaultTarget; + } + return null; + } + + // --- 2. Boundary Logic --- + + @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) { + 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) { + 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; + } + + ClassDesc pTypeDesc = ClassDesc.ofDescriptor(paramType.descriptorString()); + if (TypeKind.from(pTypeDesc) == TypeKind.REFERENCE) { + return defaultTarget; + } + return null; + } + + 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() instanceof TypeAnnotation.EmptyTarget) { + result.add(ta.annotation()); + } + } + }); + return result; + } + + private List getMethodReturnAnnotations(MethodModel method) { + List result = new ArrayList<>(); + method + .findAttribute(Attributes.runtimeVisibleTypeAnnotations()) + .ifPresent( + attr -> { + for (TypeAnnotation ta : attr.annotations()) { + // FIX: Use enum comparison, not instanceof + if (ta.targetInfo().targetType() == TypeAnnotation.TargetType.METHOD_RETURN) { + 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..b0e05c1 --- /dev/null +++ b/framework/src/main/java/io/github/eisop/runtimeframework/resolution/ReflectionHierarchyResolver.java @@ -0,0 +1,76 @@ +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; + + // FIX: Use manual descriptor generation instead of ASM + 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; + } + + // Helper to generate descriptor (e.g. "(Ljava/lang/String;)V") using JDK APIs + 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(); + } +} From bf1418305a7fc9c672ed00ab01c6a54e87077348 Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Wed, 17 Dec 2025 15:06:39 -0500 Subject: [PATCH 31/50] feat(framework): add an AnnotatedFor annotation with runtime retention --- .../runtimeframework/util/AnnotatedFor.java | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 framework/src/main/java/io/github/eisop/runtimeframework/util/AnnotatedFor.java 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(); +} From 52276434c2599b6104a6a3d1ae60c70e07451264 Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Wed, 17 Dec 2025 15:45:42 -0500 Subject: [PATCH 32/50] feat(framework): add AnnotatedFor scanning --- .../nullness/NullnessRuntimeChecker.java | 2 +- .../runtimeframework/agent/RuntimeAgent.java | 36 +++--- .../agent/RuntimeTransformer.java | 108 ++++++++++++++++-- 3 files changed, 121 insertions(+), 25 deletions(-) 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 index c939dbf..5edcc35 100644 --- 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 @@ -14,7 +14,7 @@ public class NullnessRuntimeChecker extends RuntimeChecker { @Override public String getName() { - return "Nullness Runtime Checker"; + return "nullness"; } @Override 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 54a04fd..d0c1ba0 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 @@ -11,34 +11,38 @@ public final class RuntimeAgent { public static void premain(String args, Instrumentation inst) { - // 1. Safety Filter (Always active to protect JDK/Framework) + // 1. Safety Filter Filter safeFilter = new FrameworkSafetyFilter(); + Filter policyFilter = safeFilter; + Filter scanFilter = safeFilter; - // 2. Defaults - Filter policyFilter = safeFilter; // "Checked" scope - Filter scanFilter = safeFilter; // "Instrumentation" scope - - // 3. User Configuration: Checked List + // 2. Configuration Flags 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(","))); - // Policy: Must be Safe AND in List + // Policy: Must be Safe AND in the Checked List policyFilter = info -> safeFilter.test(info) && listFilter.test(info); - // Default Scan: Matches Policy - scanFilter = policyFilter; + + if (trustAnnotatedFor) { + System.out.println( + "[RuntimeAgent] Auto-Discovery Enabled. Scanning all safe classes for annotations."); + scanFilter = safeFilter; + } else { + scanFilter = policyFilter; + } } - // 4. User Configuration: Global Mode - // If enabled, we scan EVERYTHING safe, even if it's not in the Checked List. - // This allows us to instrument LegacyLib to catch writes to Checked code. - if (Boolean.getBoolean("runtime.global")) { + if (isGlobalMode) { System.out.println("[RuntimeAgent] Global Mode ENABLED. Scanning all safe classes."); scanFilter = safeFilter; } - // 5. Load Checker + // 3. Load Checker String checkerClassName = System.getProperty( "runtime.checker", "io.github.eisop.runtimeframework.util.SysOutRuntimeChecker"); @@ -55,6 +59,8 @@ public static void premain(String args, Instrumentation inst) { return; } - inst.addTransformer(new RuntimeTransformer(scanFilter, policyFilter, checker), false); + // 4. Register with new flag + inst.addTransformer( + new RuntimeTransformer(scanFilter, policyFilter, checker, trustAnnotatedFor), false); } } diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java b/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java index b901760..a52e74a 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java @@ -4,23 +4,37 @@ 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; // Added import 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; // Which classes do we parse/instrument? - private final Filter - policyFilter; // Which classes are considered "Checked" by the policy? + private final Filter scanFilter; + private final Filter policyFilter; private final RuntimeChecker checker; + private final boolean trustAnnotatedFor; + + private final Map packageCache = new ConcurrentHashMap<>(); + + private static final String ANNOTATED_FOR_DESC = AnnotatedFor.class.descriptorString(); public RuntimeTransformer( - Filter scanFilter, Filter policyFilter, RuntimeChecker checker) { + Filter scanFilter, + Filter policyFilter, + RuntimeChecker checker, + boolean trustAnnotatedFor) { this.scanFilter = scanFilter; this.policyFilter = policyFilter; this.checker = checker; + this.trustAnnotatedFor = trustAnnotatedFor; } @Override @@ -32,7 +46,6 @@ public byte[] transform( ProtectionDomain protectionDomain, byte[] classfileBuffer) { - // Skip JDK internals to avoid boot loop issues if (className != null && (className.startsWith("java/") || className.startsWith("sun/") @@ -43,7 +56,6 @@ public byte[] transform( ClassInfo info = new ClassInfo(className, loader, module); - // 1. Check Scanning Scope if (!scanFilter.test(info)) { return null; } @@ -54,10 +66,30 @@ public byte[] transform( ClassFile cf = ClassFile.of(); ClassModel classModel = cf.parse(classfileBuffer); - // 2. Pass the POLICY filter to the instrumenter factory - // The instrumenter will use this to distinguish Checked vs Unchecked - RuntimeInstrumenter instrumenter = checker.getInstrumenter(policyFilter); + 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; + } + } + + 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) { @@ -66,4 +98,62 @@ public byte[] transform( 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("TODO"); + } + + 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); + } } From 1d7a5c3f49b139ba6221ef5914008207c67b35fa Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Thu, 18 Dec 2025 12:44:18 -0500 Subject: [PATCH 33/50] feat(framework): introduce array store and load checks --- .../core/AnnotationInstrumenter.java | 31 +++++++++++++++ .../core/RuntimeInstrumenter.java | 23 +++++++++-- .../policy/EnforcementPolicy.java | 6 +++ .../policy/StandardEnforcementPolicy.java | 39 ++++++++++++++----- .../util/SysOutInstrumenter.java | 12 ++++++ 5 files changed, 97 insertions(+), 14 deletions(-) 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 index 33dd390..763b7ed 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java @@ -11,6 +11,8 @@ 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; @@ -35,6 +37,35 @@ public AnnotationInstrumenter( // --- Hooks --- + @Override + protected void generateArrayStoreCheck(CodeBuilder b, ArrayStoreInstruction instruction) { + // We only support Reference Arrays (AASTORE) for nullness checking + if (instruction.opcode() == Opcode.AASTORE) { + TargetAnnotation target = policy.getArrayStoreCheck(TypeKind.REFERENCE); + if (target != null) { + // Stack: [..., arrayRef, index, value] + // We need to check 'value' without consuming it. + b.dup(); + // Stack: [..., arrayRef, index, value, value] + target.check(b, TypeKind.REFERENCE, "Array Element Write"); + // Stack: [..., arrayRef, index, value] -> Ready for AASTORE + } + } + } + + @Override + protected void generateArrayLoadCheck(CodeBuilder b, ArrayLoadInstruction instruction) { + // We only support Reference Arrays (AALOAD) + if (instruction.opcode() == Opcode.AALOAD) { + TargetAnnotation target = policy.getArrayLoadCheck(TypeKind.REFERENCE); + if (target != null) { + // Stack: [..., value] (Instruction has already executed) + b.dup(); + target.check(b, TypeKind.REFERENCE, "Array Element Read"); + } + } + } + @Override protected void generateParameterCheck( CodeBuilder b, int slotIndex, TypeKind type, MethodModel method, int paramIndex) { 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 index 4c03fc8..6ed97fc 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeInstrumenter.java @@ -12,6 +12,8 @@ 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.ReturnInstruction; @@ -61,21 +63,31 @@ public void accept(ClassBuilder classBuilder, ClassElement classElement) { } } } else if (element instanceof ReturnInstruction rInst) { - // SPLIT: Checked Return vs. Unchecked Override Return if (isCheckedScope) { generateReturnCheck(codeBuilder, rInst, methodModel); } else { - // Pass ClassLoader to help resolve hierarchy generateUncheckedReturnCheck( codeBuilder, rInst, methodModel, classModel, loader); } codeBuilder.with(element); } else if (element instanceof InvokeInstruction invoke) { codeBuilder.with(element); - // GATE: Only check calls if we are in Checked Code if (isCheckedScope) { generateMethodCallCheck(codeBuilder, invoke); } + } else if (element instanceof ArrayStoreInstruction astore) { + // NEW: Array Stores + // UNGATED: We check all array writes. + // If Unchecked code writes to a Reference array, we check it. + generateArrayStoreCheck(codeBuilder, astore); + codeBuilder.with(element); + } else if (element instanceof ArrayLoadInstruction aload) { + // NEW: Array Loads + codeBuilder.with(element); + // GATE: Only check reads if we are in Checked Code (Defense in Depth) + if (isCheckedScope) { + generateArrayLoadCheck(codeBuilder, aload); + } } else { codeBuilder.with(element); } @@ -135,7 +147,6 @@ protected abstract void generateFieldReadCheck( protected abstract void generateReturnCheck( CodeBuilder b, ReturnInstruction ret, MethodModel method); - // NEW: Hook for Unchecked classes overriding Checked methods protected abstract void generateUncheckedReturnCheck( CodeBuilder b, ReturnInstruction ret, @@ -147,4 +158,8 @@ protected abstract void generateUncheckedReturnCheck( 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); } 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 index 022d004..c24a828 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/policy/EnforcementPolicy.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/policy/EnforcementPolicy.java @@ -60,4 +60,10 @@ default TargetAnnotation getBoundaryMethodOverrideReturnCheck(String owner, Meth /** 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); } 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 index e45d6cf..a048c7c 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/policy/StandardEnforcementPolicy.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/policy/StandardEnforcementPolicy.java @@ -61,17 +61,13 @@ public TargetAnnotation getFieldReadCheck(FieldModel field, TypeKind type) { return findTarget(getFieldAnnotations(field)); } - @Override - public TargetAnnotation getReturnCheck(MethodModel method) { - TargetAnnotation explicit = findTarget(getMethodReturnAnnotations(method)); - if (explicit != null) return explicit; - - TypeKind returnKind = TypeKind.from(method.methodTypeSymbol().returnType()); - if (returnKind == TypeKind.REFERENCE) { - return defaultTarget; + @Override + public TargetAnnotation getReturnCheck(MethodModel method) { + // POLICY CHANGE: Trust internal code. + // We assume the compiler ensured that a method returning @NonNull actually returns a non-null value. + // We only check boundaries (Calls/Overrides) or Inputs (Params/Reads). + return null; } - return null; - } // --- 2. Boundary Logic --- @@ -190,4 +186,27 @@ private List getMethodReturnAnnotations(MethodModel method) { }); return result; } + + @Override + public TargetAnnotation getArrayLoadCheck(TypeKind componentType) { + // Enforce strict defaults on array reads (Defense in Depth). + // Since arrays can be aliased and modified by Unchecked code (Heap Pollution), + // we check values upon retrieval to ensure they match our NonNull expectation. + if (componentType == TypeKind.REFERENCE) { + return defaultTarget; + } + return null; + } + + @Override + public TargetAnnotation getArrayStoreCheck(TypeKind componentType) { + // Enforce strict defaults for arrays in Checked code. + // We assume all Reference arrays in Checked code are NonNull by default. + // This prevents Checked code from poisoning its own arrays (or shared arrays) with null. + if (componentType == TypeKind.REFERENCE) { + return defaultTarget; + } + return null; + } + } 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 index 6b916b9..dd70bf9 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutInstrumenter.java @@ -6,6 +6,8 @@ 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; @@ -82,4 +84,14 @@ protected void generateBridgeMethods(ClassBuilder builder, ClassModel model, Cla // 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"); + } } From 40203e88b92d828c12af754a39120137e61470db Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Thu, 18 Dec 2025 15:47:13 -0500 Subject: [PATCH 34/50] feat(test): introduce test suite --- build.gradle | 14 ++ checker/build.gradle | 5 + .../nullness/NullnessRuntimeChecker.java | 9 +- .../nullness/NullnessDirectoryTest.java | 15 ++ .../nullness-basic/MixedMethods.java | 36 ++++ .../nullness-basic/SimpleAssignment.java | 11 ++ .../runtimeframework/agent/RuntimeAgent.java | 24 ++- .../core/OptOutAnnotation.java | 13 ++ .../runtimeframework/core/RuntimeChecker.java | 9 +- .../core/RuntimeInstrumenter.java | 46 +++-- .../policy/GlobalEnforcementPolicy.java | 35 +--- .../policy/StandardEnforcementPolicy.java | 114 +++++++----- .../runtime/RuntimeVerifier.java | 40 +++-- settings.gradle | 1 + test-utils/build.gradle | 9 + .../eisop/testutils/AgentTestHarness.java | 170 ++++++++++++++++++ .../github/eisop/testutils/ExpectedError.java | 8 + .../eisop/testutils/RuntimeTestRunner.java | 147 +++++++++++++++ .../eisop/testutils/TestViolationHandler.java | 42 +++++ testBasicScenarios | 0 20 files changed, 642 insertions(+), 106 deletions(-) create mode 100644 checker/src/test/java/io/github/eisop/checker/nullness/NullnessDirectoryTest.java create mode 100644 checker/src/test/resources/test-cases/nullness-basic/MixedMethods.java create mode 100644 checker/src/test/resources/test-cases/nullness-basic/SimpleAssignment.java create mode 100644 framework/src/main/java/io/github/eisop/runtimeframework/core/OptOutAnnotation.java create mode 100644 test-utils/build.gradle create mode 100644 test-utils/src/main/java/io/github/eisop/testutils/AgentTestHarness.java create mode 100644 test-utils/src/main/java/io/github/eisop/testutils/ExpectedError.java create mode 100644 test-utils/src/main/java/io/github/eisop/testutils/RuntimeTestRunner.java create mode 100644 test-utils/src/main/java/io/github/eisop/testutils/TestViolationHandler.java create mode 100644 testBasicScenarios diff --git a/build.gradle b/build.gradle index 45f73bc..508fa83 100644 --- a/build.gradle +++ b/build.gradle @@ -38,6 +38,20 @@ allprojects { 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" + } + } } apply plugin: 'eclipse' diff --git a/checker/build.gradle b/checker/build.gradle index c4f3ca3..19708ab 100644 --- a/checker/build.gradle +++ b/checker/build.gradle @@ -18,4 +18,9 @@ ext { 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/NullnessRuntimeChecker.java b/checker/src/main/java/io/github/eisop/runtimeframework/checker/nullness/NullnessRuntimeChecker.java index 5edcc35..38b3e75 100644 --- 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 @@ -1,6 +1,7 @@ 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; @@ -9,6 +10,7 @@ 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 { @@ -19,8 +21,11 @@ public String getName() { @Override public RuntimeInstrumenter getInstrumenter(Filter filter) { - // 1. Create Policy - EnforcementPolicy policy = createPolicy(List.of(new NonNullTarget()), filter); + EnforcementPolicy policy = + createPolicy( + List.of(new NonNullTarget()), + List.of(new OptOutAnnotation(Nullable.class)), // Wraps the class + filter); // 2. Create Resolver HierarchyResolver resolver = 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..db9dcc6 --- /dev/null +++ b/checker/src/test/java/io/github/eisop/checker/nullness/NullnessDirectoryTest.java @@ -0,0 +1,15 @@ +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 testBasicScenarios() throws Exception { + // This looks for files in src/test/resources/test-cases/nullness-basic + runDirectoryTest( + "nullness-basic", + "io.github.eisop.runtimeframework.checker.nullness.NullnessRuntimeChecker"); + } +} diff --git a/checker/src/test/resources/test-cases/nullness-basic/MixedMethods.java b/checker/src/test/resources/test-cases/nullness-basic/MixedMethods.java new file mode 100644 index 0000000..f6a59a7 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-basic/MixedMethods.java @@ -0,0 +1,36 @@ +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; + +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-basic/SimpleAssignment.java b/checker/src/test/resources/test-cases/nullness-basic/SimpleAssignment.java new file mode 100644 index 0000000..2ebf47f --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-basic/SimpleAssignment.java @@ -0,0 +1,11 @@ +import org.checkerframework.checker.nullness.qual.NonNull; + +public class SimpleAssignment { + public static void main(String[] args) { + test(null); + } + + public static void test(@NonNull String s) { + // :: error: (Parameter 0 must be NonNull) + } +} 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 d0c1ba0..511396b 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 @@ -5,6 +5,8 @@ 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; @@ -28,11 +30,14 @@ public static void premain(String args, Instrumentation inst) { // Policy: Must be Safe AND in the Checked List policyFilter = info -> safeFilter.test(info) && listFilter.test(info); + // Scan Logic: + // If 'trustAnnotatedFor' is true, we MUST scan all safe classes to look for the annotation. if (trustAnnotatedFor) { System.out.println( "[RuntimeAgent] Auto-Discovery Enabled. Scanning all safe classes for annotations."); scanFilter = safeFilter; } else { + // Otherwise, optimization: Only scan what is explicitly checked scanFilter = policyFilter; } } @@ -42,7 +47,22 @@ public static void premain(String args, Instrumentation inst) { scanFilter = safeFilter; } - // 3. Load Checker + // 3. Configure Violation Handler (NEW) + 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(); + } + } + + // 4. Load Checker String checkerClassName = System.getProperty( "runtime.checker", "io.github.eisop.runtimeframework.util.SysOutRuntimeChecker"); @@ -59,7 +79,7 @@ public static void premain(String args, Instrumentation inst) { return; } - // 4. Register with new flag + // 5. Register inst.addTransformer( new RuntimeTransformer(scanFilter, policyFilter, checker, trustAnnotatedFor), false); } 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 index ba4f815..00d22f1 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeChecker.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeChecker.java @@ -35,12 +35,15 @@ public abstract class RuntimeChecker { * @return A configured EnforcementPolicy (Standard or Global). */ protected EnforcementPolicy createPolicy( - Collection targets, Filter filter) { + Collection targets, + Collection optOuts, + Filter filter) { + boolean isGlobalMode = Boolean.getBoolean("runtime.global"); if (isGlobalMode) { - return new GlobalEnforcementPolicy(targets, filter); + return new GlobalEnforcementPolicy(targets, optOuts, filter); } else { - return new StandardEnforcementPolicy(targets, filter); + 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 index 6ed97fc..29300e7 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeInstrumenter.java @@ -8,6 +8,7 @@ import java.lang.classfile.ClassTransform; import java.lang.classfile.CodeBuilder; import java.lang.classfile.CodeElement; +import java.lang.classfile.Instruction; // Added Import for fail-safe check import java.lang.classfile.MethodModel; import java.lang.classfile.Opcode; import java.lang.classfile.TypeKind; @@ -16,6 +17,7 @@ import java.lang.classfile.instruction.ArrayStoreInstruction; import java.lang.classfile.instruction.FieldInstruction; import java.lang.classfile.instruction.InvokeInstruction; +import java.lang.classfile.instruction.LineNumber; // Added Import import java.lang.classfile.instruction.ReturnInstruction; import java.lang.constant.MethodTypeDesc; import java.lang.reflect.Modifier; @@ -29,7 +31,6 @@ protected RuntimeInstrumenter(Filter scopeFilter) { } public ClassTransform asClassTransform(ClassModel classModel, ClassLoader loader) { - // 1. Determine Scope for this class boolean isCheckedScope = scopeFilter.test(new ClassInfo(classModel.thisClass().asInternalName(), loader, null)); @@ -44,20 +45,37 @@ public void accept(ClassBuilder classBuilder, ClassElement classElement) { methodBuilder.withCode( codeBuilder -> { - // GATE: Only check parameters if we are in Checked Code - if (isCheckedScope) { - instrumentMethodEntry(codeBuilder, methodModel); - } + // State flag to ensure we inject entry checks exactly once + // If not checked scope, we mark as done immediately. + boolean entryChecksDone = !isCheckedScope; for (CodeElement element : codeModel) { + // DEBUG INFO FIX: + // Inject entry checks AFTER the first LineNumber table entry. + // This ensures the injected code inherits the line number of the method + // start, + // preventing "Line -1" in stack traces. + if (!entryChecksDone && element instanceof LineNumber) { + codeBuilder.with(element); // Write the LineNumber first + instrumentMethodEntry(codeBuilder, methodModel); + entryChecksDone = true; + continue; // Continue loop to avoid writing element twice or triggering + // other logic + } + + // Fail-safe: If we hit a real Instruction and haven't injected yet + // (e.g. class compiled without -g has no LineNumbers), inject now. + if (!entryChecksDone && element instanceof Instruction) { + instrumentMethodEntry(codeBuilder, methodModel); + entryChecksDone = true; + } + if (element instanceof FieldInstruction fInst) { if (isFieldWrite(fInst)) { - // Always check writes (Handles Global Write Protection) generateFieldWriteCheck(codeBuilder, fInst, classModel); codeBuilder.with(element); } else if (isFieldRead(fInst)) { codeBuilder.with(element); - // GATE: Only check reads if we are in Checked Code if (isCheckedScope) { generateFieldReadCheck(codeBuilder, fInst, classModel); } @@ -76,15 +94,10 @@ public void accept(ClassBuilder classBuilder, ClassElement classElement) { generateMethodCallCheck(codeBuilder, invoke); } } else if (element instanceof ArrayStoreInstruction astore) { - // NEW: Array Stores - // UNGATED: We check all array writes. - // If Unchecked code writes to a Reference array, we check it. generateArrayStoreCheck(codeBuilder, astore); codeBuilder.with(element); } else if (element instanceof ArrayLoadInstruction aload) { - // NEW: Array Loads codeBuilder.with(element); - // GATE: Only check reads if we are in Checked Code (Defense in Depth) if (isCheckedScope) { generateArrayLoadCheck(codeBuilder, aload); } @@ -92,6 +105,14 @@ public void accept(ClassBuilder classBuilder, ClassElement classElement) { codeBuilder.with(element); } } + + // Edge Case: Empty methods or methods with only labels might miss the loop + // check. + // Ensure checks are injected if they haven't been (unlikely in valid + // methods). + if (!entryChecksDone && isCheckedScope) { + instrumentMethodEntry(codeBuilder, methodModel); + } }); } else { methodBuilder.with(methodElement); @@ -104,7 +125,6 @@ public void accept(ClassBuilder classBuilder, ClassElement classElement) { @Override public void atEnd(ClassBuilder builder) { - // GATE: Only generate bridges if we are in Checked Code if (isCheckedScope) { generateBridgeMethods(builder, classModel, loader); } 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 index dfddfc9..14ae138 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/policy/GlobalEnforcementPolicy.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/policy/GlobalEnforcementPolicy.java @@ -1,5 +1,6 @@ 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; @@ -7,57 +8,31 @@ import java.lang.constant.MethodTypeDesc; import java.util.Collection; -/** - * An aggressive policy that enforces checks even inside Unchecked code. - * - *

This policy is used when the Agent is configured to instrument ALL classes (not just the - * Checked ones). It detects when Unchecked code interacts with Checked code and injects "Firewall" - * checks to protect the Trusted boundary. - */ public class GlobalEnforcementPolicy extends StandardEnforcementPolicy { public GlobalEnforcementPolicy( - Collection targetAnnotations, Filter safetyFilter) { - super(targetAnnotations, safetyFilter); + Collection targetAnnotations, + Collection optOutAnnotations, + Filter safetyFilter) { + super(targetAnnotations, optOutAnnotations, safetyFilter); } @Override public TargetAnnotation getBoundaryFieldWriteCheck( String owner, String fieldName, TypeKind type) { - // GLOBAL LOGIC: - // We are currently in Unchecked Code (Legacy). - // We are writing to 'owner'. - - // 1. Is the Target Class (owner) Checked? - // Note: safetyFilter returns true if the class is "Safe/Checked". boolean isTargetChecked = safetyFilter.test(new ClassInfo(owner, null, null)); - - // 2. Is it a Reference? - // 3. Do we have a strict default? if (isTargetChecked && type == TypeKind.REFERENCE) { - // Yes. The Legacy code is attempting to write to a Checked Field. - // We must enforce NonNull to prevent poisoning the Checked class. return super.defaultTarget; } - return null; } @Override public TargetAnnotation getBoundaryMethodOverrideReturnCheck(String owner, MethodTypeDesc desc) { - // GLOBAL LOGIC: - // We are in Unchecked Code, and we have overridden a method defined in 'owner'. - // (The Instrumenter is responsible for determining which class defined the contract). - - // 1. Is the Defining Class (Parent) Checked? boolean isParentChecked = safetyFilter.test(new ClassInfo(owner, null, null)); - - // 2. Does it return a Reference? TypeKind returnType = TypeKind.from(desc.returnType()); if (isParentChecked && returnType == TypeKind.REFERENCE) { - // Yes. We are returning a value that the Checked Parent promised would be NonNull. - // We must check our return value to ensure we honor the Liskov Substitution Principle. return super.defaultTarget; } return null; 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 index a048c7c..33a2a3e 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/policy/StandardEnforcementPolicy.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/policy/StandardEnforcementPolicy.java @@ -1,5 +1,6 @@ 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; @@ -16,43 +17,70 @@ import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; -/** The standard policy for Annotation-Driven Runtime Verification. */ 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, Filter safetyFilter) { + 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) { - TargetAnnotation t = targets.get(a.classSymbol().descriptorString()); + 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; + } + + // --- 1. Internal Logic --- + @Override public TargetAnnotation getParameterCheck(MethodModel method, int paramIndex, TypeKind type) { if (type != TypeKind.REFERENCE) return null; - TargetAnnotation explicit = findTarget(getMethodParamAnnotations(method, paramIndex)); + 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; + return null; // Trust internal code } @Override @@ -61,13 +89,28 @@ public TargetAnnotation getFieldReadCheck(FieldModel field, TypeKind type) { return findTarget(getFieldAnnotations(field)); } - @Override - public TargetAnnotation getReturnCheck(MethodModel method) { - // POLICY CHANGE: Trust internal code. - // We assume the compiler ensured that a method returning @NonNull actually returns a non-null value. - // We only check boundaries (Calls/Overrides) or Inputs (Params/Reads). - return null; + @Override + public TargetAnnotation getReturnCheck(MethodModel method) { + return null; // Trust internal code + } + + // --- Array Logic --- + + @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; + } // --- 2. Boundary Logic --- @@ -95,6 +138,7 @@ public TargetAnnotation getBoundaryFieldReadCheck(String owner, String fieldName @Override public boolean shouldGenerateBridge(Method parentMethod) { + if (parentMethod.getDeclaringClass() == Object.class) return false; Class[] paramTypes = parentMethod.getParameterTypes(); java.lang.annotation.Annotation[][] paramAnnos = parentMethod.getParameterAnnotations(); @@ -103,9 +147,18 @@ public boolean shouldGenerateBridge(Method parentMethod) { 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) { - return true; + 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; @@ -122,6 +175,11 @@ public TargetAnnotation getBridgeParameterCheck(Method parentMethod, int paramIn 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; @@ -129,6 +187,7 @@ public TargetAnnotation getBridgeParameterCheck(Method parentMethod, int paramIn return null; } + // --- Parsing Helpers --- private List getMethodParamAnnotations(MethodModel method, int paramIndex) { List result = new ArrayList<>(); method @@ -144,9 +203,7 @@ private List getMethodParamAnnotations(MethodModel method, int param attr -> { for (TypeAnnotation ta : attr.annotations()) { if (ta.targetInfo() instanceof TypeAnnotation.FormalParameterTarget pt - && pt.formalParameterIndex() == paramIndex) { - result.add(ta.annotation()); - } + && pt.formalParameterIndex() == paramIndex) result.add(ta.annotation()); } }); return result; @@ -157,13 +214,13 @@ private List getFieldAnnotations(FieldModel field) { field .findAttribute(Attributes.runtimeVisibleAnnotations()) .ifPresent(attr -> result.addAll(attr.annotations())); - field .findAttribute(Attributes.runtimeVisibleTypeAnnotations()) .ifPresent( attr -> { for (TypeAnnotation ta : attr.annotations()) { - if (ta.targetInfo() instanceof TypeAnnotation.EmptyTarget) { + // FIX: Use enum comparison for Field type annotations + if (ta.targetInfo().targetType() == TypeAnnotation.TargetType.FIELD) { result.add(ta.annotation()); } } @@ -186,27 +243,4 @@ private List getMethodReturnAnnotations(MethodModel method) { }); return result; } - - @Override - public TargetAnnotation getArrayLoadCheck(TypeKind componentType) { - // Enforce strict defaults on array reads (Defense in Depth). - // Since arrays can be aliased and modified by Unchecked code (Heap Pollution), - // we check values upon retrieval to ensure they match our NonNull expectation. - if (componentType == TypeKind.REFERENCE) { - return defaultTarget; - } - return null; - } - - @Override - public TargetAnnotation getArrayStoreCheck(TypeKind componentType) { - // Enforce strict defaults for arrays in Checked code. - // We assume all Reference arrays in Checked code are NonNull by default. - // This prevents Checked code from poisoning its own arrays (or shared arrays) with null. - if (componentType == TypeKind.REFERENCE) { - return defaultTarget; - } - return null; - } - } diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/runtime/RuntimeVerifier.java b/framework/src/main/java/io/github/eisop/runtimeframework/runtime/RuntimeVerifier.java index a917821..77912c0 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/runtime/RuntimeVerifier.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/runtime/RuntimeVerifier.java @@ -3,21 +3,37 @@ /** * The abstract base class for all runtime verifiers. * - *

Design Note: This class does not contain abstract methods for checks. - * Specific verification methods (e.g., {@code checkNotNull}) must be {@code static} in subclasses - * to allow efficient {@code invokestatic} calls from the instrumented bytecode. + *

This class serves as the central manager for the {@link ViolationHandler}. */ public abstract class RuntimeVerifier { - private static volatile ViolationHandler handler = new ThrowingViolationHandler(); + // 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 (e.g., to switch from throwing exceptions to logging). - * - * @param newHandler The new handler to use + * runtime checks. */ public static void setViolationHandler(ViolationHandler newHandler) { if (newHandler == null) { @@ -26,15 +42,7 @@ public static void setViolationHandler(ViolationHandler newHandler) { handler = newHandler; } - /** - * Reports a violation to the current handler. - * - *

This method is designed to be called by the static check methods in concrete subclasses (the - * "Static Trampolines"). - * - * @param checkerName The name of the checker reporting the issue - * @param message The violation details - */ + /** Reports a violation to the current handler. */ protected static void reportViolation(String checkerName, String message) { handler.handleViolation(checkerName, message); } diff --git a/settings.gradle b/settings.gradle index 6ed3c79..57e51d7 100644 --- a/settings.gradle +++ b/settings.gradle @@ -9,3 +9,4 @@ 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..a520c89 --- /dev/null +++ b/test-utils/src/main/java/io/github/eisop/testutils/AgentTestHarness.java @@ -0,0 +1,170 @@ +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) { + // Fallback for running in IDE without Gradle context + 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."); + } + } + 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 { + // Load from classpath resources + 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(String... filenames) throws Exception { + compileWithClasspath(null, filenames); + } + + protected void compileWithClasspath(String extraClasspath, String... filenames) throws Exception { + Path qualJar = findJar("checker-qual"); + String cp = qualJar.toAbsolutePath().toString(); + if (extraClasspath != null) { + cp += ":" + extraClasspath; + } + + List cmd = new ArrayList<>(); + cmd.add("javac"); + // FIX: Add -g to generate all debugging info (lines, vars, source) + 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 { + 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()); + 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 -> p.getFileName().toString().startsWith(prefix) && p.toString().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); + } + + public 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..719e07a --- /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(long lineNumber, String expectedMessage) { + @Override + public String toString() { + return "Line " + 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..fffc9f2 --- /dev/null +++ b/test-utils/src/main/java/io/github/eisop/testutils/RuntimeTestRunner.java @@ -0,0 +1,147 @@ +package io.github.eisop.testutils; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +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) throws Exception { + setup(); + try { + String resourcePath = "test-cases/" + dirName; + Path resourceDir = Path.of("src/test/resources/" + resourcePath); + + if (!Files.exists(resourceDir)) { + // Fallback for IDE vs Gradle working directory differences + 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()); + } + + for (Path sourcePath : javaFiles) { + runSingleTest(sourcePath, checkerClass); + } + } finally { + cleanup(); + } + } + + private void runSingleTest(Path sourcePath, String checkerClass) throws Exception { + System.out.println("Running test: " + sourcePath.getFileName()); + List expectedErrors = parseExpectedErrors(sourcePath); + + String filename = sourcePath.getFileName().toString(); + Files.copy(sourcePath, tempDir.resolve(filename)); + compile(filename); + + String mainClass = filename.replace(".java", ""); + + // Pass the Fully Qualified Name of the TestViolationHandler in test-utils + TestResult result = + runAgent( + mainClass, + "-Druntime.checker=" + checkerClass, + "-Druntime.classes=" + mainClass, + "-Druntime.handler=io.github.eisop.testutils.TestViolationHandler"); + + verifyErrors(expectedErrors, result.stdout(), result.stderr(), filename); + } + + private List parseExpectedErrors(Path sourceFile) throws IOException { + 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()) { + // Line numbers are 1-based + errors.add(new ExpectedError(i + 2, m.group(1).trim())); + } + } + return errors; + } + + @SuppressWarnings("StringSplitter") // We use simple split() to avoid Guava dependency + private void verifyErrors( + List expected, String stdout, String stderr, String filename) { + List actualErrors = new ArrayList<>(); + + // Parse STDOUT for [VIOLATION] lines + // Use stream instead of split("\\R") to avoid StringSplitter warning and handle newlines + // cleanly + stdout + .lines() + .forEach( + line -> { + if (line.startsWith("[VIOLATION]")) { + String[] parts = line.split(" "); + if (parts.length > 1) { + String fileLoc = parts[1]; // "File.java:Line" + if (fileLoc.contains(":")) { + String[] locParts = fileLoc.split(":"); + if (locParts[0].equals(filename)) { + long lineNum = Long.parseLong(locParts[1]); + int msgStart = line.indexOf(") ") + 2; + String msg = (msgStart > 1) ? line.substring(msgStart) : ""; + actualErrors.add(new ExpectedError(lineNum, msg.trim())); + } + } + } + } + }); + + // Check for matches allowing for a 1-line difference (comment on next line) + boolean match = expected.size() == actualErrors.size(); + if (match) { + for (int i = 0; i < expected.size(); i++) { + ExpectedError exp = expected.get(i); + ExpectedError act = actualErrors.get(i); + + // Fuzzy line match: Accept if actual line is same OR 1 line before the comment + boolean lineMatch = + (act.lineNumber() == exp.lineNumber()) || (act.lineNumber() == exp.lineNumber() - 1); + + if (!lineMatch || !exp.expectedMessage().equals(act.expectedMessage())) { + match = false; + break; + } + } + } + + if (!match) { + StringBuilder sb = new StringBuilder(); + sb.append("\n=== TEST FAILED: ").append(filename).append(" ===\n"); + sb.append("Expected Errors:\n"); + if (expected.isEmpty()) sb.append(" (None)\n"); + expected.forEach(e -> sb.append(" ").append(e).append("\n")); + + sb.append("Actual Violations:\n"); + if (actualErrors.isEmpty()) sb.append(" (None - Did the agent run?)\n"); + actualErrors.forEach(e -> sb.append(" ").append(e).append("\n")); + + sb.append("Full Stdout:\n").append(stdout).append("\n"); + sb.append("Full Stderr:\n").append(stderr).append("\n"); + sb.append("==========================================\n"); + + // Print to stdout so it shows up in Gradle log even without --info + System.out.println(sb.toString()); + + Assertions.fail("Verification failed. See stdout for diff."); + } + } +} 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..67090b0 --- /dev/null +++ b/test-utils/src/main/java/io/github/eisop/testutils/TestViolationHandler.java @@ -0,0 +1,42 @@ +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 { + + static { + // DEBUG: Confirm class is loaded by the RuntimeVerifier + System.err.println("DEBUG: TestViolationHandler class initialized."); + } + + @Override + public void handleViolation(String checkerName, String message) { + // DEBUG: Confirm method is called + System.err.println("DEBUG: TestViolationHandler.handleViolation invoked. Msg: " + 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 + // Skip the runtime framework internals (Verifier, Handler, etc.) + .filter(f -> !f.getClassName().startsWith("io.github.eisop.runtimeframework")) + // Skip the test utils (This handler itself) + .filter(f -> !f.getClassName().startsWith("io.github.eisop.testutils")) + .findFirst() + .map(StackWalker.StackFrame::toStackTraceElement) + .orElse(null)); + } +} diff --git a/testBasicScenarios b/testBasicScenarios new file mode 100644 index 0000000..e69de29 From 79b78f990d71af5dbbbab2cdb66233a81e546518 Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Fri, 19 Dec 2025 14:25:56 -0500 Subject: [PATCH 35/50] feat(test-util): introduce test suite --- .../nullness/NullnessDirectoryTest.java | 19 +++- .../nullness-basic/SimpleAssignment.java | 11 --- .../nullness-boundary/InstanceBoundary.java | 16 ++++ .../nullness-boundary/NullableBoundary.java | 14 +++ .../nullness-boundary/PrimitiveBoundary.java | 13 +++ .../nullness-boundary/StaticBoundary.java | 16 ++++ .../nullness-fields/InstanceFieldRead.java | 17 ++++ .../nullness-fields/StaticFieldRead.java | 11 +++ .../nullness-parameter/Constructors.java | 16 ++++ .../nullness-parameter/FieldArgument.java | 22 +++++ .../MixedMethods.java | 0 .../nullness-parameter/Primitives.java | 9 ++ .../core/AnnotationInstrumenter.java | 30 +++++-- .../core/RuntimeInstrumenter.java | 42 ++++----- .../policy/EnforcementPolicy.java | 2 + .../policy/StandardEnforcementPolicy.java | 45 +++++++++- .../util/SysOutInstrumenter.java | 7 ++ .../eisop/testutils/RuntimeTestRunner.java | 89 ++++++++++--------- 18 files changed, 294 insertions(+), 85 deletions(-) delete mode 100644 checker/src/test/resources/test-cases/nullness-basic/SimpleAssignment.java create mode 100644 checker/src/test/resources/test-cases/nullness-boundary/InstanceBoundary.java create mode 100644 checker/src/test/resources/test-cases/nullness-boundary/NullableBoundary.java create mode 100644 checker/src/test/resources/test-cases/nullness-boundary/PrimitiveBoundary.java create mode 100644 checker/src/test/resources/test-cases/nullness-boundary/StaticBoundary.java create mode 100644 checker/src/test/resources/test-cases/nullness-fields/InstanceFieldRead.java create mode 100644 checker/src/test/resources/test-cases/nullness-fields/StaticFieldRead.java create mode 100644 checker/src/test/resources/test-cases/nullness-parameter/Constructors.java create mode 100644 checker/src/test/resources/test-cases/nullness-parameter/FieldArgument.java rename checker/src/test/resources/test-cases/{nullness-basic => nullness-parameter}/MixedMethods.java (100%) create mode 100644 checker/src/test/resources/test-cases/nullness-parameter/Primitives.java diff --git a/checker/src/test/java/io/github/eisop/checker/nullness/NullnessDirectoryTest.java b/checker/src/test/java/io/github/eisop/checker/nullness/NullnessDirectoryTest.java index db9dcc6..75f4d69 100644 --- a/checker/src/test/java/io/github/eisop/checker/nullness/NullnessDirectoryTest.java +++ b/checker/src/test/java/io/github/eisop/checker/nullness/NullnessDirectoryTest.java @@ -6,10 +6,23 @@ public class NullnessDirectoryTest extends RuntimeTestRunner { @Test - public void testBasicScenarios() throws Exception { - // This looks for files in src/test/resources/test-cases/nullness-basic + public void testParameterScenarios() throws Exception { runDirectoryTest( - "nullness-basic", + "nullness-parameter", + "io.github.eisop.runtimeframework.checker.nullness.NullnessRuntimeChecker"); + } + + @Test + public void testBoundaryScenarios() throws Exception { + runDirectoryTest( + "nullness-boundary", + "io.github.eisop.runtimeframework.checker.nullness.NullnessRuntimeChecker"); + } + + @Test + public void testFieldScenarios() throws Exception { + runDirectoryTest( + "nullness-fields", "io.github.eisop.runtimeframework.checker.nullness.NullnessRuntimeChecker"); } } diff --git a/checker/src/test/resources/test-cases/nullness-basic/SimpleAssignment.java b/checker/src/test/resources/test-cases/nullness-basic/SimpleAssignment.java deleted file mode 100644 index 2ebf47f..0000000 --- a/checker/src/test/resources/test-cases/nullness-basic/SimpleAssignment.java +++ /dev/null @@ -1,11 +0,0 @@ -import org.checkerframework.checker.nullness.qual.NonNull; - -public class SimpleAssignment { - public static void main(String[] args) { - test(null); - } - - public static void test(@NonNull String s) { - // :: error: (Parameter 0 must be NonNull) - } -} diff --git a/checker/src/test/resources/test-cases/nullness-boundary/InstanceBoundary.java b/checker/src/test/resources/test-cases/nullness-boundary/InstanceBoundary.java new file mode 100644 index 0000000..9b9422e --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-boundary/InstanceBoundary.java @@ -0,0 +1,16 @@ +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) + + } +} diff --git a/checker/src/test/resources/test-cases/nullness-boundary/NullableBoundary.java b/checker/src/test/resources/test-cases/nullness-boundary/NullableBoundary.java new file mode 100644 index 0000000..f3b72ed --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-boundary/NullableBoundary.java @@ -0,0 +1,14 @@ +import org.checkerframework.checker.nullness.qual.Nullable; + +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-boundary/PrimitiveBoundary.java b/checker/src/test/resources/test-cases/nullness-boundary/PrimitiveBoundary.java new file mode 100644 index 0000000..7594c73 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-boundary/PrimitiveBoundary.java @@ -0,0 +1,13 @@ +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-boundary/StaticBoundary.java b/checker/src/test/resources/test-cases/nullness-boundary/StaticBoundary.java new file mode 100644 index 0000000..3fa767f --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-boundary/StaticBoundary.java @@ -0,0 +1,16 @@ +import org.checkerframework.checker.nullness.qual.NonNull; + +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-fields/InstanceFieldRead.java b/checker/src/test/resources/test-cases/nullness-fields/InstanceFieldRead.java new file mode 100644 index 0000000..f765328 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-fields/InstanceFieldRead.java @@ -0,0 +1,17 @@ +import org.checkerframework.checker.nullness.qual.Nullable; + +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-fields/StaticFieldRead.java b/checker/src/test/resources/test-cases/nullness-fields/StaticFieldRead.java new file mode 100644 index 0000000..892017b --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-fields/StaticFieldRead.java @@ -0,0 +1,11 @@ +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-parameter/Constructors.java b/checker/src/test/resources/test-cases/nullness-parameter/Constructors.java new file mode 100644 index 0000000..38a35b3 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-parameter/Constructors.java @@ -0,0 +1,16 @@ +import org.checkerframework.checker.nullness.qual.NonNull; + +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..5aa99a2 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-parameter/FieldArgument.java @@ -0,0 +1,22 @@ +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; + + +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-basic/MixedMethods.java b/checker/src/test/resources/test-cases/nullness-parameter/MixedMethods.java similarity index 100% rename from checker/src/test/resources/test-cases/nullness-basic/MixedMethods.java rename to checker/src/test/resources/test-cases/nullness-parameter/MixedMethods.java 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..9f4b90c --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-parameter/Primitives.java @@ -0,0 +1,9 @@ +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/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java b/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java index 763b7ed..ec02b5a 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java @@ -16,6 +16,7 @@ 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; @@ -170,13 +171,8 @@ protected void generateUncheckedReturnCheck( @Override protected void generateMethodCallCheck(CodeBuilder b, InvokeInstruction invoke) { - TargetAnnotation target = - policy.getBoundaryCallCheck(invoke.owner().asInternalName(), invoke.typeSymbol()); - if (target != null) { - b.dup(); - target.check( - b, TypeKind.REFERENCE, "Result from unchecked method " + invoke.name().stringValue()); - } + // empty for now, only need to generate checks when a method call is stored somehwhere + } @Override @@ -188,6 +184,26 @@ protected void generateBridgeMethods(ClassBuilder builder, ClassModel model, Cla } } + @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 + ")"); + } + } + // --- Helpers --- private void emitBridge(ClassBuilder builder, Method parentMethod) { 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 index 29300e7..c4219c7 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeInstrumenter.java @@ -8,7 +8,7 @@ import java.lang.classfile.ClassTransform; import java.lang.classfile.CodeBuilder; import java.lang.classfile.CodeElement; -import java.lang.classfile.Instruction; // Added Import for fail-safe check +import java.lang.classfile.Instruction; import java.lang.classfile.MethodModel; import java.lang.classfile.Opcode; import java.lang.classfile.TypeKind; @@ -17,8 +17,9 @@ import java.lang.classfile.instruction.ArrayStoreInstruction; import java.lang.classfile.instruction.FieldInstruction; import java.lang.classfile.instruction.InvokeInstruction; -import java.lang.classfile.instruction.LineNumber; // Added Import +import java.lang.classfile.instruction.LineNumber; import java.lang.classfile.instruction.ReturnInstruction; +import java.lang.classfile.instruction.StoreInstruction; // NEW import java.lang.constant.MethodTypeDesc; import java.lang.reflect.Modifier; @@ -44,27 +45,16 @@ public void accept(ClassBuilder classBuilder, ClassElement classElement) { if (methodElement instanceof CodeAttribute codeModel) { methodBuilder.withCode( codeBuilder -> { - - // State flag to ensure we inject entry checks exactly once - // If not checked scope, we mark as done immediately. boolean entryChecksDone = !isCheckedScope; for (CodeElement element : codeModel) { - // DEBUG INFO FIX: - // Inject entry checks AFTER the first LineNumber table entry. - // This ensures the injected code inherits the line number of the method - // start, - // preventing "Line -1" in stack traces. + // Inject entry checks after first LineNumber to ensure valid stack traces if (!entryChecksDone && element instanceof LineNumber) { - codeBuilder.with(element); // Write the LineNumber first + codeBuilder.with(element); instrumentMethodEntry(codeBuilder, methodModel); entryChecksDone = true; - continue; // Continue loop to avoid writing element twice or triggering - // other logic + continue; } - - // Fail-safe: If we hit a real Instruction and haven't injected yet - // (e.g. class compiled without -g has no LineNumbers), inject now. if (!entryChecksDone && element instanceof Instruction) { instrumentMethodEntry(codeBuilder, methodModel); entryChecksDone = true; @@ -77,7 +67,11 @@ public void accept(ClassBuilder classBuilder, ClassElement classElement) { } else if (isFieldRead(fInst)) { codeBuilder.with(element); if (isCheckedScope) { - generateFieldReadCheck(codeBuilder, fInst, classModel); + // 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 } } } else if (element instanceof ReturnInstruction rInst) { @@ -101,15 +95,18 @@ public void accept(ClassBuilder classBuilder, ClassElement classElement) { if (isCheckedScope) { generateArrayLoadCheck(codeBuilder, aload); } + } else if (element + instanceof StoreInstruction store) { // NEW: Store Check + // GATE: Only check local vars in Checked Code + if (isCheckedScope) { + generateStoreCheck(codeBuilder, store, methodModel); + } + codeBuilder.with(element); } else { codeBuilder.with(element); } } - // Edge Case: Empty methods or methods with only labels might miss the loop - // check. - // Ensure checks are injected if they haven't been (unlikely in valid - // methods). if (!entryChecksDone && isCheckedScope) { instrumentMethodEntry(codeBuilder, methodModel); } @@ -182,4 +179,7 @@ protected abstract void generateBridgeMethods( 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/policy/EnforcementPolicy.java b/framework/src/main/java/io/github/eisop/runtimeframework/policy/EnforcementPolicy.java index c24a828..78011db 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/policy/EnforcementPolicy.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/policy/EnforcementPolicy.java @@ -66,4 +66,6 @@ default TargetAnnotation getBoundaryMethodOverrideReturnCheck(String owner, Meth /** Should we check a value being read from an array? */ TargetAnnotation getArrayLoadCheck(TypeKind componentType); + + TargetAnnotation getLocalVariableWriteCheck(MethodModel method, int slot, TypeKind type); } 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 index 33a2a3e..b6f8dd8 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/policy/StandardEnforcementPolicy.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/policy/StandardEnforcementPolicy.java @@ -72,7 +72,6 @@ public TargetAnnotation getParameterCheck(MethodModel method, int paramIndex, Ty TargetAnnotation explicit = findTarget(annos); if (explicit != null) return explicit; - if (hasOptOutAnnotation(annos)) return null; return defaultTarget; @@ -86,7 +85,11 @@ public TargetAnnotation getFieldWriteCheck(FieldModel field, TypeKind type) { @Override public TargetAnnotation getFieldReadCheck(FieldModel field, TypeKind type) { if (type != TypeKind.REFERENCE) return null; - return findTarget(getFieldAnnotations(field)); + List annos = getFieldAnnotations(field); + TargetAnnotation explicit = findTarget(annos); + if (explicit != null) return explicit; + if (hasOptOutAnnotation(annos)) return null; + return defaultTarget; } @Override @@ -94,6 +97,20 @@ public TargetAnnotation getReturnCheck(MethodModel method) { return null; // Trust internal code } + @Override + public TargetAnnotation getLocalVariableWriteCheck(MethodModel method, int slot, TypeKind type) { + if (type != TypeKind.REFERENCE) return null; + + // Scan for annotations on this local variable slot + List annos = getLocalVariableAnnotations(method, slot); + + TargetAnnotation explicit = findTarget(annos); + if (explicit != null) return explicit; + if (hasOptOutAnnotation(annos)) return null; + + return defaultTarget; + } + // --- Array Logic --- @Override @@ -219,7 +236,6 @@ private List getFieldAnnotations(FieldModel field) { .ifPresent( attr -> { for (TypeAnnotation ta : attr.annotations()) { - // FIX: Use enum comparison for Field type annotations if (ta.targetInfo().targetType() == TypeAnnotation.TargetType.FIELD) { result.add(ta.annotation()); } @@ -243,4 +259,27 @@ private List getMethodReturnAnnotations(MethodModel method) { }); 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/util/SysOutInstrumenter.java b/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutInstrumenter.java index dd70bf9..8b256a8 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/util/SysOutInstrumenter.java @@ -11,6 +11,7 @@ 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; @@ -94,4 +95,10 @@ protected void generateArrayLoadCheck(CodeBuilder b, ArrayLoadInstruction instru 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/test-utils/src/main/java/io/github/eisop/testutils/RuntimeTestRunner.java b/test-utils/src/main/java/io/github/eisop/testutils/RuntimeTestRunner.java index fffc9f2..a824e26 100644 --- a/test-utils/src/main/java/io/github/eisop/testutils/RuntimeTestRunner.java +++ b/test-utils/src/main/java/io/github/eisop/testutils/RuntimeTestRunner.java @@ -12,7 +12,9 @@ public class RuntimeTestRunner extends AgentTestHarness { - private static final Pattern ERROR_PATTERN = Pattern.compile("//\\s*::\\s*error:\\s*\\((.+?)\\)"); + // Regex to find comments like: // :: error: (message) + // Using greedy match .* to capture nested parens if necessary + private static final Pattern ERROR_PATTERN = Pattern.compile("//\\s*::\\s*error:\\s*\\((.*)\\)"); public void runDirectoryTest(String dirName, String checkerClass) throws Exception { setup(); @@ -21,7 +23,7 @@ public void runDirectoryTest(String dirName, String checkerClass) throws Excepti Path resourceDir = Path.of("src/test/resources/" + resourcePath); if (!Files.exists(resourceDir)) { - // Fallback for IDE vs Gradle working directory differences + // Fallback for IDEs where working dir might be root resourceDir = Path.of("checker/src/test/resources/" + resourcePath); } @@ -52,7 +54,6 @@ private void runSingleTest(Path sourcePath, String checkerClass) throws Exceptio String mainClass = filename.replace(".java", ""); - // Pass the Fully Qualified Name of the TestViolationHandler in test-utils TestResult result = runAgent( mainClass, @@ -60,7 +61,7 @@ private void runSingleTest(Path sourcePath, String checkerClass) throws Exceptio "-Druntime.classes=" + mainClass, "-Druntime.handler=io.github.eisop.testutils.TestViolationHandler"); - verifyErrors(expectedErrors, result.stdout(), result.stderr(), filename); + verifyErrors(expectedErrors, result.stdout(), filename); } private List parseExpectedErrors(Path sourceFile) throws IOException { @@ -70,28 +71,26 @@ private List parseExpectedErrors(Path sourceFile) throws IOExcept Matcher m = ERROR_PATTERN.matcher(lines.get(i)); if (m.find()) { // Line numbers are 1-based - errors.add(new ExpectedError(i + 2, m.group(1).trim())); + errors.add(new ExpectedError(i + 1, m.group(1).trim())); } } return errors; } - @SuppressWarnings("StringSplitter") // We use simple split() to avoid Guava dependency - private void verifyErrors( - List expected, String stdout, String stderr, String filename) { + @SuppressWarnings("StringSplitter") + private void verifyErrors(List expected, String stdout, String filename) { List actualErrors = new ArrayList<>(); // Parse STDOUT for [VIOLATION] lines - // Use stream instead of split("\\R") to avoid StringSplitter warning and handle newlines - // cleanly stdout .lines() .forEach( line -> { if (line.startsWith("[VIOLATION]")) { + // Format: [VIOLATION] File.java:Line (Checker) Message String[] parts = line.split(" "); if (parts.length > 1) { - String fileLoc = parts[1]; // "File.java:Line" + String fileLoc = parts[1]; if (fileLoc.contains(":")) { String[] locParts = fileLoc.split(":"); if (locParts[0].equals(filename)) { @@ -105,43 +104,53 @@ private void verifyErrors( } }); - // Check for matches allowing for a 1-line difference (comment on next line) - boolean match = expected.size() == actualErrors.size(); - if (match) { - for (int i = 0; i < expected.size(); i++) { - ExpectedError exp = expected.get(i); - ExpectedError act = actualErrors.get(i); - - // Fuzzy line match: Accept if actual line is same OR 1 line before the comment - boolean lineMatch = - (act.lineNumber() == exp.lineNumber()) || (act.lineNumber() == exp.lineNumber() - 1); - - if (!lineMatch || !exp.expectedMessage().equals(act.expectedMessage())) { - match = false; - break; - } - } - } + List unmatchedExpected = new ArrayList<>(expected); + List unmatchedActual = new ArrayList<>(actualErrors); + + // Greedy matching + unmatchedActual.removeIf( + act -> { + ExpectedError bestMatch = null; + + for (ExpectedError exp : unmatchedExpected) { + if (exp.expectedMessage().equals(act.expectedMessage())) { + // Fuzzy Line Check: + // Runtime injection can squash parameter checks to the method start line. + // Comments might be spread out over the parameter list. + // Allow a tolerance of +/- 5 lines. + long diff = Math.abs(act.lineNumber() - exp.lineNumber()); + if (diff <= 5) { + bestMatch = exp; + break; // Found it + } + } + } - if (!match) { + if (bestMatch != null) { + unmatchedExpected.remove(bestMatch); + return true; // Match found + } + return false; + }); + + if (!unmatchedExpected.isEmpty() || !unmatchedActual.isEmpty()) { StringBuilder sb = new StringBuilder(); sb.append("\n=== TEST FAILED: ").append(filename).append(" ===\n"); - sb.append("Expected Errors:\n"); - if (expected.isEmpty()) sb.append(" (None)\n"); - expected.forEach(e -> sb.append(" ").append(e).append("\n")); - sb.append("Actual Violations:\n"); - if (actualErrors.isEmpty()) sb.append(" (None - Did the agent run?)\n"); - actualErrors.forEach(e -> sb.append(" ").append(e).append("\n")); + if (!unmatchedExpected.isEmpty()) { + sb.append("Missing Expected Errors:\n"); + unmatchedExpected.forEach(e -> sb.append(" ").append(e).append("\n")); + } - sb.append("Full Stdout:\n").append(stdout).append("\n"); - sb.append("Full Stderr:\n").append(stderr).append("\n"); - sb.append("==========================================\n"); + if (!unmatchedActual.isEmpty()) { + sb.append("Unexpected Runtime Violations:\n"); + unmatchedActual.forEach(e -> sb.append(" ").append(e).append("\n")); + } - // Print to stdout so it shows up in Gradle log even without --info + sb.append("\nFull Stdout:\n").append(stdout).append("\n"); System.out.println(sb.toString()); - Assertions.fail("Verification failed. See stdout for diff."); + Assertions.fail("Verification failed. Mismatched errors."); } } } From 3d8278a06ec303e747b8fa977e60feea36fe85f2 Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Fri, 19 Dec 2025 15:25:32 -0500 Subject: [PATCH 36/50] test: add field test cases --- .../nullness/NullnessDirectoryTest.java | 21 +++++-- .../InstanceFieldRead.java | 0 .../StaticFieldRead.java | 0 .../nullness-field-write/InstanceWrite.java | 22 +++++++ .../nullness-field-write/StaticWrite.java | 24 ++++++++ .../policy/GlobalEnforcementPolicy.java | 51 ++++++++++++++++ .../eisop/testutils/AgentTestHarness.java | 57 ++++++++++++++---- .../eisop/testutils/RuntimeTestRunner.java | 60 ++++++++++--------- 8 files changed, 192 insertions(+), 43 deletions(-) rename checker/src/test/resources/test-cases/{nullness-fields => nullness-field-read}/InstanceFieldRead.java (100%) rename checker/src/test/resources/test-cases/{nullness-fields => nullness-field-read}/StaticFieldRead.java (100%) create mode 100644 checker/src/test/resources/test-cases/nullness-field-write/InstanceWrite.java create mode 100644 checker/src/test/resources/test-cases/nullness-field-write/StaticWrite.java diff --git a/checker/src/test/java/io/github/eisop/checker/nullness/NullnessDirectoryTest.java b/checker/src/test/java/io/github/eisop/checker/nullness/NullnessDirectoryTest.java index 75f4d69..0a6fde0 100644 --- a/checker/src/test/java/io/github/eisop/checker/nullness/NullnessDirectoryTest.java +++ b/checker/src/test/java/io/github/eisop/checker/nullness/NullnessDirectoryTest.java @@ -9,20 +9,31 @@ public class NullnessDirectoryTest extends RuntimeTestRunner { public void testParameterScenarios() throws Exception { runDirectoryTest( "nullness-parameter", - "io.github.eisop.runtimeframework.checker.nullness.NullnessRuntimeChecker"); + "io.github.eisop.runtimeframework.checker.nullness.NullnessRuntimeChecker", + false); } @Test public void testBoundaryScenarios() throws Exception { runDirectoryTest( "nullness-boundary", - "io.github.eisop.runtimeframework.checker.nullness.NullnessRuntimeChecker"); + "io.github.eisop.runtimeframework.checker.nullness.NullnessRuntimeChecker", + false); } @Test - public void testFieldScenarios() throws Exception { + public void testFieldReadScenarios() throws Exception { runDirectoryTest( - "nullness-fields", - "io.github.eisop.runtimeframework.checker.nullness.NullnessRuntimeChecker"); + "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); } } diff --git a/checker/src/test/resources/test-cases/nullness-fields/InstanceFieldRead.java b/checker/src/test/resources/test-cases/nullness-field-read/InstanceFieldRead.java similarity index 100% rename from checker/src/test/resources/test-cases/nullness-fields/InstanceFieldRead.java rename to checker/src/test/resources/test-cases/nullness-field-read/InstanceFieldRead.java diff --git a/checker/src/test/resources/test-cases/nullness-fields/StaticFieldRead.java b/checker/src/test/resources/test-cases/nullness-field-read/StaticFieldRead.java similarity index 100% rename from checker/src/test/resources/test-cases/nullness-fields/StaticFieldRead.java rename to checker/src/test/resources/test-cases/nullness-field-read/StaticFieldRead.java 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..4d87537 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-field-write/InstanceWrite.java @@ -0,0 +1,22 @@ +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; + +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..b5cfaa8 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-field-write/StaticWrite.java @@ -0,0 +1,24 @@ +import org.checkerframework.checker.nullness.qual.Nullable; +import org.checkerframework.checker.nullness.qual.NonNull; + +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/framework/src/main/java/io/github/eisop/runtimeframework/policy/GlobalEnforcementPolicy.java b/framework/src/main/java/io/github/eisop/runtimeframework/policy/GlobalEnforcementPolicy.java index 14ae138..b6c5653 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/policy/GlobalEnforcementPolicy.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/policy/GlobalEnforcementPolicy.java @@ -20,19 +20,70 @@ public GlobalEnforcementPolicy( @Override public TargetAnnotation getBoundaryFieldWriteCheck( String owner, String fieldName, TypeKind type) { + // GLOBAL LOGIC: + // We are currently in Unchecked Code (Legacy). + // We are writing to 'owner'. + + // 1. Is the Target Class (owner) Checked? boolean isTargetChecked = safetyFilter.test(new ClassInfo(owner, null, null)); + + // 2. Is it a Reference? if (isTargetChecked && type == TypeKind.REFERENCE) { + + // 3. Check for Opt-Outs (e.g. @Nullable) on the target field + // Since we don't have the ClassModel for 'owner', we try to resolve it via Reflection. + if (isFieldOptOut(owner, fieldName)) { + return null; // Field allows nulls, so don't check. + } + + // 4. Default to Strict return super.defaultTarget; } + return null; } + private boolean isFieldOptOut(String owner, String fieldName) { + try { + // Attempt to load the target class to inspect field annotations. + // We use the ContextClassLoader as a best-effort resolution strategy. + Class clazz = + Class.forName( + owner.replace('/', '.'), false, Thread.currentThread().getContextClassLoader()); + java.lang.reflect.Field field = clazz.getDeclaredField(fieldName); + + // Check Declaration Annotations + for (java.lang.annotation.Annotation anno : field.getAnnotations()) { + String desc = "L" + anno.annotationType().getName().replace('.', '/') + ";"; + if (optOutDescriptors.contains(desc)) { + return true; + } + } + + // Check Type Annotations (e.g. @Nullable String) + for (java.lang.annotation.Annotation anno : field.getAnnotatedType().getAnnotations()) { + String desc = "L" + anno.annotationType().getName().replace('.', '/') + ";"; + if (optOutDescriptors.contains(desc)) { + return true; + } + } + } catch (Throwable t) { + // If resolution fails (class not found, field not found, security, etc.), + // we fall back to "False" (Not Opt-Out), which enforces the Strict Check. + // This is the safe default. + } + return false; + } + @Override public TargetAnnotation getBoundaryMethodOverrideReturnCheck(String owner, MethodTypeDesc desc) { boolean isParentChecked = safetyFilter.test(new ClassInfo(owner, null, null)); TypeKind returnType = TypeKind.from(desc.returnType()); if (isParentChecked && returnType == TypeKind.REFERENCE) { + // Note: Ideally we should perform similar reflection here to check if the + // Parent method return type is @Nullable. + // For now, we keep the strict default. return super.defaultTarget; } return null; diff --git a/test-utils/src/main/java/io/github/eisop/testutils/AgentTestHarness.java b/test-utils/src/main/java/io/github/eisop/testutils/AgentTestHarness.java index a520c89..53e77be 100644 --- a/test-utils/src/main/java/io/github/eisop/testutils/AgentTestHarness.java +++ b/test-utils/src/main/java/io/github/eisop/testutils/AgentTestHarness.java @@ -2,7 +2,6 @@ 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; @@ -18,26 +17,29 @@ public abstract class AgentTestHarness { protected Path distDir; protected void setup() throws IOException { + // Create a temp directory for this test run this.tempDir = Files.createTempDirectory("eisop-agent-test"); + + // Locate the dist directory passed from Gradle String distPath = System.getProperty("agent.dist.dir"); if (distPath == null) { - // Fallback for running in IDE without Gradle context + // Fallback for running in IDE without Gradle context, assuming standard build layout 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."); + "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 { + // Recursive delete try (Stream walk = Files.walk(tempDir)) { - walk.sorted((a, b) -> b.compareTo(a)) + walk.sorted((a, b) -> b.compareTo(a)) // Delete leaves first .forEach( p -> { try { @@ -48,11 +50,19 @@ protected void cleanup() throws IOException { } } + // --- Helper to copy test resources --- + + /** + * Copies a test source file from src/test/resources/test-cases/ to the temp directory. + * + * @param resourcePath relative path in test-cases (e.g. "dispatch/Runner.java") + */ protected void copyTestFile(String resourcePath) throws IOException { // Load from classpath resources String fullPath = "test-cases/" + resourcePath; try (InputStream is = getClass().getClassLoader().getResourceAsStream(fullPath)) { if (is == null) { + // Try looking in file system directly (useful if resources aren't copied yet by IDE) Path fsPath = Path.of("src/test/resources/" + fullPath); if (Files.exists(fsPath)) { copyFileFromDisk(fsPath, resourcePath); @@ -60,6 +70,7 @@ protected void copyTestFile(String resourcePath) throws IOException { } throw new IOException("Test resource not found: " + fullPath); } + Path dest = tempDir.resolve(resourcePath); Files.createDirectories(dest.getParent()); Files.copy(is, dest, StandardCopyOption.REPLACE_EXISTING); @@ -72,17 +83,25 @@ private void copyFileFromDisk(Path source, String relativeDest) throws IOExcepti Files.copy(source, dest, StandardCopyOption.REPLACE_EXISTING); } + // Keep this for simple ad-hoc tests if needed protected void writeSource(String filename, String content) throws IOException { Path file = tempDir.resolve(filename); Files.createDirectories(file.getParent()); Files.writeString(file, content, StandardOpenOption.CREATE); } + // --- Compilation --- + + 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 { + // Find checker-qual jar Path qualJar = findJar("checker-qual"); String cp = qualJar.toAbsolutePath().toString(); if (extraClasspath != null) { @@ -91,8 +110,6 @@ protected void compileWithClasspath(String extraClasspath, String... filenames) List cmd = new ArrayList<>(); cmd.add("javac"); - // FIX: Add -g to generate all debugging info (lines, vars, source) - cmd.add("-g"); cmd.add("-cp"); cmd.add(cp); cmd.add("-d"); @@ -105,12 +122,21 @@ protected void compileWithClasspath(String extraClasspath, String... filenames) runProcess(cmd, "Compilation"); } + // --- Execution --- + 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"); + // We likely need test-utils jar on the classpath for the TestViolationHandler Path testUtilsJar = findJar("test-utils"); + // Build Classpath (Agent Jars + Test Classes + Test Utils) String cp = "." + ":" @@ -126,7 +152,14 @@ protected TestResult runAgent(String mainClass, String... agentArgs) throws Exce cmd.add("java"); cmd.add("--enable-preview"); cmd.add("-javaagent:" + frameworkJar.toAbsolutePath()); + + if (isGlobal) { + cmd.add("-Druntime.global=true"); + } + + // Add agent arguments (e.g. -Druntime.classes=...) cmd.addAll(List.of(agentArgs)); + cmd.add("-cp"); cmd.add(cp); cmd.add(mainClass); @@ -150,21 +183,25 @@ private Path findJar(String prefix) throws IOException { 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); + // Read output + String stdout = new String(p.getInputStream().readAllBytes()); + String stderr = new String(p.getErrorStream().readAllBytes()); 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); } - public record TestResult(int exitCode, String stdout, String stderr) {} + protected record TestResult(int exitCode, String stdout, String stderr) {} } diff --git a/test-utils/src/main/java/io/github/eisop/testutils/RuntimeTestRunner.java b/test-utils/src/main/java/io/github/eisop/testutils/RuntimeTestRunner.java index a824e26..2f98bd2 100644 --- a/test-utils/src/main/java/io/github/eisop/testutils/RuntimeTestRunner.java +++ b/test-utils/src/main/java/io/github/eisop/testutils/RuntimeTestRunner.java @@ -3,6 +3,7 @@ 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; @@ -12,53 +13,70 @@ public class RuntimeTestRunner extends AgentTestHarness { - // Regex to find comments like: // :: error: (message) - // Using greedy match .* to capture nested parens if necessary private static final Pattern ERROR_PATTERN = Pattern.compile("//\\s*::\\s*error:\\s*\\((.*)\\)"); - public void runDirectoryTest(String dirName, String checkerClass) throws Exception { + 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)) { - // Fallback for IDEs where working dir might be root resourceDir = Path.of("checker/src/test/resources/" + resourcePath); } - if (!Files.exists(resourceDir)) { throw new IOException("Test directory not found: " + resourceDir.toAbsolutePath()); } + // 1. Gather all Java files List javaFiles; try (var stream = Files.walk(resourceDir)) { javaFiles = stream.filter(p -> p.toString().endsWith(".java")).collect(Collectors.toList()); } + if (javaFiles.isEmpty()) return; + + // 2. Copy all files to temp dir + 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); + } + + // 3. Compile ALL files together (handles dependencies) + compile(fileNames); + + // 4. Run each file that has a main method for (Path sourcePath : javaFiles) { - runSingleTest(sourcePath, checkerClass); + String content = Files.readString(sourcePath); + // Simple heuristic to avoid running helper classes + if (content.contains("public static void main")) { + runSingleTest(sourcePath, checkerClass, isGlobal); + } } + } finally { cleanup(); } } - private void runSingleTest(Path sourcePath, String checkerClass) throws Exception { + private void runSingleTest(Path sourcePath, String checkerClass, boolean isGlobal) + throws Exception { System.out.println("Running test: " + sourcePath.getFileName()); List expectedErrors = parseExpectedErrors(sourcePath); String filename = sourcePath.getFileName().toString(); - Files.copy(sourcePath, tempDir.resolve(filename)); - compile(filename); - String mainClass = filename.replace(".java", ""); + // Pass global flag and handler TestResult result = runAgent( mainClass, + isGlobal, "-Druntime.checker=" + checkerClass, - "-Druntime.classes=" + mainClass, + "-Druntime.classes=" + mainClass, // Always treat the runner as Checked "-Druntime.handler=io.github.eisop.testutils.TestViolationHandler"); verifyErrors(expectedErrors, result.stdout(), filename); @@ -70,7 +88,6 @@ private List parseExpectedErrors(Path sourceFile) throws IOExcept for (int i = 0; i < lines.size(); i++) { Matcher m = ERROR_PATTERN.matcher(lines.get(i)); if (m.find()) { - // Line numbers are 1-based errors.add(new ExpectedError(i + 1, m.group(1).trim())); } } @@ -81,13 +98,11 @@ private List parseExpectedErrors(Path sourceFile) throws IOExcept private void verifyErrors(List expected, String stdout, String filename) { List actualErrors = new ArrayList<>(); - // Parse STDOUT for [VIOLATION] lines stdout .lines() .forEach( line -> { if (line.startsWith("[VIOLATION]")) { - // Format: [VIOLATION] File.java:Line (Checker) Message String[] parts = line.split(" "); if (parts.length > 1) { String fileLoc = parts[1]; @@ -107,28 +122,21 @@ private void verifyErrors(List expected, String stdout, String fi List unmatchedExpected = new ArrayList<>(expected); List unmatchedActual = new ArrayList<>(actualErrors); - // Greedy matching unmatchedActual.removeIf( act -> { ExpectedError bestMatch = null; - for (ExpectedError exp : unmatchedExpected) { if (exp.expectedMessage().equals(act.expectedMessage())) { - // Fuzzy Line Check: - // Runtime injection can squash parameter checks to the method start line. - // Comments might be spread out over the parameter list. - // Allow a tolerance of +/- 5 lines. long diff = Math.abs(act.lineNumber() - exp.lineNumber()); - if (diff <= 5) { + if (diff <= 5) { // Fuzzy matching bestMatch = exp; - break; // Found it + break; } } } - if (bestMatch != null) { unmatchedExpected.remove(bestMatch); - return true; // Match found + return true; } return false; }); @@ -136,20 +144,16 @@ private void verifyErrors(List expected, String stdout, String fi if (!unmatchedExpected.isEmpty() || !unmatchedActual.isEmpty()) { StringBuilder sb = new StringBuilder(); sb.append("\n=== TEST FAILED: ").append(filename).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 Stdout:\n").append(stdout).append("\n"); System.out.println(sb.toString()); - Assertions.fail("Verification failed. Mismatched errors."); } } From 7437e21002c7b1d70035346e8be9cca6ea4a0dcd Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Mon, 22 Dec 2025 14:03:02 -0500 Subject: [PATCH 37/50] test: add bridge tests --- .../nullness/NullnessDirectoryTest.java | 16 ++++++ .../nullness-boundary/InstanceBoundary.java | 3 + .../nullness-boundary/NullableBoundary.java | 2 + .../nullness-boundary/PrimitiveBoundary.java | 3 + .../nullness-boundary/StaticBoundary.java | 2 + .../InheritanceBridgeTest.java | 38 +++++++++++++ .../nullness-bridge/UncheckedParent.java | 22 ++++++++ .../InstanceFieldRead.java | 2 + .../nullness-field-read/StaticFieldRead.java | 3 + .../nullness-field-write/InstanceWrite.java | 2 + .../nullness-field-write/StaticWrite.java | 4 +- .../nullness-global-bridge/LegacyTrojan.java | 8 +++ .../nullness-global-bridge/SafeContract.java | 7 +++ .../nullness-global-bridge/TrojanRunner.java | 7 +++ .../nullness-parameter/Constructors.java | 2 + .../nullness-parameter/FieldArgument.java | 3 +- .../nullness-parameter/MixedMethods.java | 2 + .../nullness-parameter/Primitives.java | 3 + .../runtimeframework/agent/RuntimeAgent.java | 30 +++++----- .../core/AnnotationInstrumenter.java | 26 +++++++-- .../policy/EnforcementPolicy.java | 2 +- .../policy/GlobalEnforcementPolicy.java | 55 +++++++++++++------ .../eisop/testutils/AgentTestHarness.java | 43 ++++----------- .../eisop/testutils/RuntimeTestRunner.java | 17 ++++-- 24 files changed, 224 insertions(+), 78 deletions(-) create mode 100644 checker/src/test/resources/test-cases/nullness-bridge/InheritanceBridgeTest.java create mode 100644 checker/src/test/resources/test-cases/nullness-bridge/UncheckedParent.java create mode 100644 checker/src/test/resources/test-cases/nullness-global-bridge/LegacyTrojan.java create mode 100644 checker/src/test/resources/test-cases/nullness-global-bridge/SafeContract.java create mode 100644 checker/src/test/resources/test-cases/nullness-global-bridge/TrojanRunner.java diff --git a/checker/src/test/java/io/github/eisop/checker/nullness/NullnessDirectoryTest.java b/checker/src/test/java/io/github/eisop/checker/nullness/NullnessDirectoryTest.java index 0a6fde0..64cf64d 100644 --- a/checker/src/test/java/io/github/eisop/checker/nullness/NullnessDirectoryTest.java +++ b/checker/src/test/java/io/github/eisop/checker/nullness/NullnessDirectoryTest.java @@ -36,4 +36,20 @@ public void testFieldWriteScenarios() throws Exception { "io.github.eisop.runtimeframework.checker.nullness.NullnessRuntimeChecker", true); } + + @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-boundary/InstanceBoundary.java b/checker/src/test/resources/test-cases/nullness-boundary/InstanceBoundary.java index 9b9422e..abd7475 100644 --- a/checker/src/test/resources/test-cases/nullness-boundary/InstanceBoundary.java +++ b/checker/src/test/resources/test-cases/nullness-boundary/InstanceBoundary.java @@ -1,3 +1,6 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") public class InstanceBoundary { static class UncheckedLib { diff --git a/checker/src/test/resources/test-cases/nullness-boundary/NullableBoundary.java b/checker/src/test/resources/test-cases/nullness-boundary/NullableBoundary.java index f3b72ed..38b6cc2 100644 --- a/checker/src/test/resources/test-cases/nullness-boundary/NullableBoundary.java +++ b/checker/src/test/resources/test-cases/nullness-boundary/NullableBoundary.java @@ -1,5 +1,7 @@ import org.checkerframework.checker.nullness.qual.Nullable; +import io.github.eisop.runtimeframework.qual.AnnotatedFor; +@AnnotatedFor("nullness") public class NullableBoundary { static class UncheckedLib { diff --git a/checker/src/test/resources/test-cases/nullness-boundary/PrimitiveBoundary.java b/checker/src/test/resources/test-cases/nullness-boundary/PrimitiveBoundary.java index 7594c73..c0131c8 100644 --- a/checker/src/test/resources/test-cases/nullness-boundary/PrimitiveBoundary.java +++ b/checker/src/test/resources/test-cases/nullness-boundary/PrimitiveBoundary.java @@ -1,3 +1,6 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") public class PrimitiveBoundary { static class UncheckedLib { diff --git a/checker/src/test/resources/test-cases/nullness-boundary/StaticBoundary.java b/checker/src/test/resources/test-cases/nullness-boundary/StaticBoundary.java index 3fa767f..3aa1212 100644 --- a/checker/src/test/resources/test-cases/nullness-boundary/StaticBoundary.java +++ b/checker/src/test/resources/test-cases/nullness-boundary/StaticBoundary.java @@ -1,5 +1,7 @@ import org.checkerframework.checker.nullness.qual.NonNull; +import io.github.eisop.runtimeframework.qual.AnnotatedFor; +@AnnotatedFor("nullness") public class StaticBoundary { static class UncheckedLib { 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 index f765328..66ca7fd 100644 --- a/checker/src/test/resources/test-cases/nullness-field-read/InstanceFieldRead.java +++ b/checker/src/test/resources/test-cases/nullness-field-read/InstanceFieldRead.java @@ -1,5 +1,7 @@ import org.checkerframework.checker.nullness.qual.Nullable; +import io.github.eisop.runtimeframework.qual.AnnotatedFor; +@AnnotatedFor("nullness") public class InstanceFieldRead { static class UncheckedLib { 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 index 892017b..99673fb 100644 --- a/checker/src/test/resources/test-cases/nullness-field-read/StaticFieldRead.java +++ b/checker/src/test/resources/test-cases/nullness-field-read/StaticFieldRead.java @@ -1,3 +1,6 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") public class StaticFieldRead { static class UncheckedLib { 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 index 4d87537..12c2c12 100644 --- a/checker/src/test/resources/test-cases/nullness-field-write/InstanceWrite.java +++ b/checker/src/test/resources/test-cases/nullness-field-write/InstanceWrite.java @@ -1,6 +1,8 @@ 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"; 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 index b5cfaa8..4db4011 100644 --- a/checker/src/test/resources/test-cases/nullness-field-write/StaticWrite.java +++ b/checker/src/test/resources/test-cases/nullness-field-write/StaticWrite.java @@ -1,7 +1,9 @@ import org.checkerframework.checker.nullness.qual.Nullable; import org.checkerframework.checker.nullness.qual.NonNull; +import io.github.eisop.runtimeframework.qual.AnnotatedFor; -public class StaticWrite { +@AnnotatedFor("nullness") +public class StaticWrite{ public static @Nullable String nullableData = "don't care"; public static @NonNull String nonNullData = "safe"; 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..8bd1131 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-global-bridge/LegacyTrojan.java @@ -0,0 +1,8 @@ +public class LegacyTrojan extends SafeContract { + @Override + public String getValue() { + return null; + // :: error: (Return value of overridden method getValue must be NonNull) + } + +} 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..4046af4 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-global-bridge/SafeContract.java @@ -0,0 +1,7 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; +import org.checkerframework.checker.nullness.qual.NonNull; + +@AnnotatedFor("nullness") +public class SafeContract { + public @NonNull String getValue() { return "Safe"; } +} 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..fd1da61 --- /dev/null +++ b/checker/src/test/resources/test-cases/nullness-global-bridge/TrojanRunner.java @@ -0,0 +1,7 @@ +public class TrojanRunner { + public static void main(String[] args) { + System.out.println("--- Starting Trojan Runner ---"); + SafeContract c = new LegacyTrojan(); + c.getValue(); + } +} diff --git a/checker/src/test/resources/test-cases/nullness-parameter/Constructors.java b/checker/src/test/resources/test-cases/nullness-parameter/Constructors.java index 38a35b3..2ab2737 100644 --- a/checker/src/test/resources/test-cases/nullness-parameter/Constructors.java +++ b/checker/src/test/resources/test-cases/nullness-parameter/Constructors.java @@ -1,5 +1,7 @@ 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); diff --git a/checker/src/test/resources/test-cases/nullness-parameter/FieldArgument.java b/checker/src/test/resources/test-cases/nullness-parameter/FieldArgument.java index 5aa99a2..cdd6bbe 100644 --- a/checker/src/test/resources/test-cases/nullness-parameter/FieldArgument.java +++ b/checker/src/test/resources/test-cases/nullness-parameter/FieldArgument.java @@ -1,7 +1,8 @@ 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 { diff --git a/checker/src/test/resources/test-cases/nullness-parameter/MixedMethods.java b/checker/src/test/resources/test-cases/nullness-parameter/MixedMethods.java index f6a59a7..25e9fc4 100644 --- a/checker/src/test/resources/test-cases/nullness-parameter/MixedMethods.java +++ b/checker/src/test/resources/test-cases/nullness-parameter/MixedMethods.java @@ -1,6 +1,8 @@ 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) { diff --git a/checker/src/test/resources/test-cases/nullness-parameter/Primitives.java b/checker/src/test/resources/test-cases/nullness-parameter/Primitives.java index 9f4b90c..2b5b8fa 100644 --- a/checker/src/test/resources/test-cases/nullness-parameter/Primitives.java +++ b/checker/src/test/resources/test-cases/nullness-parameter/Primitives.java @@ -1,3 +1,6 @@ +import io.github.eisop.runtimeframework.qual.AnnotatedFor; + +@AnnotatedFor("nullness") public class Primitives { public static void main(String[] args) { testPrimitives(42, null, true); 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 511396b..010cd60 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 @@ -23,31 +23,31 @@ public static void premain(String args, Instrumentation inst) { boolean isGlobalMode = Boolean.getBoolean("runtime.global"); boolean trustAnnotatedFor = Boolean.getBoolean("runtime.trustAnnotatedFor"); + // Logic for Policy Filter (Who is Checked?) if (checkedClasses != null && !checkedClasses.isBlank()) { System.out.println("[RuntimeAgent] Checked Scope restricted to: " + checkedClasses); Filter listFilter = new ClassListFilter(Arrays.asList(checkedClasses.split(","))); - - // Policy: Must be Safe AND in the Checked List policyFilter = info -> safeFilter.test(info) && listFilter.test(info); - - // Scan Logic: - // If 'trustAnnotatedFor' is true, we MUST scan all safe classes to look for the annotation. - if (trustAnnotatedFor) { - System.out.println( - "[RuntimeAgent] Auto-Discovery Enabled. Scanning all safe classes for annotations."); - scanFilter = safeFilter; - } else { - // Otherwise, optimization: Only scan what is explicitly checked - scanFilter = policyFilter; - } + } else if (trustAnnotatedFor) { + // New logic: If relying on annotations and no list is provided, default to Empty Set. + // This allows @AnnotatedFor to be the sole mechanism for opting in classes as Checked. + policyFilter = info -> false; } - if (isGlobalMode) { + // Logic for Scan Filter (Who do we parse?) + if (trustAnnotatedFor) { + System.out.println( + "[RuntimeAgent] Auto-Discovery Enabled. Scanning all safe classes for annotations."); + scanFilter = safeFilter; + } else if (isGlobalMode) { System.out.println("[RuntimeAgent] Global Mode ENABLED. Scanning all safe classes."); scanFilter = safeFilter; + } else if (checkedClasses != null) { + // Optimization: Only scan what is explicitly checked + scanFilter = policyFilter; } - // 3. Configure Violation Handler (NEW) + // 3. Configure Violation Handler String handlerClassName = System.getProperty("runtime.handler"); if (handlerClassName != null && !handlerClassName.isBlank()) { try { 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 index ec02b5a..37a3ffd 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java @@ -26,14 +26,16 @@ public class AnnotationInstrumenter extends RuntimeInstrumenter { private final EnforcementPolicy policy; private final HierarchyResolver hierarchyResolver; + private final Filter safetyFilter; public AnnotationInstrumenter( EnforcementPolicy policy, HierarchyResolver hierarchyResolver, Filter safetyFilter) { - super(safetyFilter); // Pass filter to base class + super(safetyFilter); this.policy = policy; this.hierarchyResolver = hierarchyResolver; + this.safetyFilter = safetyFilter; } // --- Hooks --- @@ -152,7 +154,6 @@ protected void generateUncheckedReturnCheck( ClassLoader loader) { if (ret.opcode() != Opcode.ARETURN) return; - // Find if we are overriding a method from a CHECKED parent String checkedParent = findCheckedOverriddenMethod(classModel, method, loader); if (checkedParent != null) { @@ -262,14 +263,28 @@ private String findCheckedOverriddenMethod( Class parent = Class.forName(superName, false, loader); while (parent != null && parent != Object.class) { String internalName = parent.getName().replace('.', '/'); + boolean isChecked = safetyFilter.test(new ClassInfo(internalName, null, null)); + if (!isChecked) { + for (java.lang.annotation.Annotation anno : parent.getAnnotations()) { + if (anno.annotationType() + .getName() + .equals("io.github.eisop.runtimeframework.qual.AnnotatedFor")) { + isChecked = true; + System.out.println("DEBUG: Found @AnnotatedFor on " + internalName); + break; + } + } + } else { + System.out.println("DEBUG: Parent " + internalName + " is explicitly checked via filter"); + } - // Use the PROTECTED scopeFilter from the base class - if (scopeFilter.test(new ClassInfo(internalName, null, null))) { + if (isChecked) { 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)) { + System.out.println("DEBUG: Found matched method in " + internalName); return internalName; } } @@ -278,7 +293,8 @@ private String findCheckedOverriddenMethod( parent = parent.getSuperclass(); } } catch (Exception e) { - System.out.println("here"); + System.err.println("DEBUG: Error in findCheckedOverriddenMethod: " + e); + e.printStackTrace(); } return null; } 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 index 78011db..3fb0f10 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/policy/EnforcementPolicy.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/policy/EnforcementPolicy.java @@ -50,7 +50,7 @@ default TargetAnnotation getBoundaryMethodOverrideReturnCheck(String owner, Meth /** We are calling a method on 'owner'. Should we check the result? */ TargetAnnotation getBoundaryCallCheck(String owner, MethodTypeDesc desc); - /** We are reading a field from an EXTERNAL class. Should we check the value? */ + /** We are reading field from an EXTERNAL class. Should we check the value? */ TargetAnnotation getBoundaryFieldReadCheck(String owner, String fieldName, TypeKind type); // --- 3. Inheritance Logic (Bridges) --- 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 index b6c5653..aeb6de9 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/policy/GlobalEnforcementPolicy.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/policy/GlobalEnforcementPolicy.java @@ -20,24 +20,25 @@ public GlobalEnforcementPolicy( @Override public TargetAnnotation getBoundaryFieldWriteCheck( String owner, String fieldName, TypeKind type) { - // GLOBAL LOGIC: - // We are currently in Unchecked Code (Legacy). - // We are writing to 'owner'. + // GLOBAL LOGIC: Legacy code writing to 'owner'. // 1. Is the Target Class (owner) Checked? - boolean isTargetChecked = safetyFilter.test(new ClassInfo(owner, null, null)); + boolean checked = isClassChecked(owner); + // System.out.println("DEBUG: getBoundaryFieldWriteCheck owner=" + owner + " checked=" + + // checked); - // 2. Is it a Reference? - if (isTargetChecked && type == TypeKind.REFERENCE) { + if (checked) { + // 2. Is it a Reference? + if (type == TypeKind.REFERENCE) { - // 3. Check for Opt-Outs (e.g. @Nullable) on the target field - // Since we don't have the ClassModel for 'owner', we try to resolve it via Reflection. - if (isFieldOptOut(owner, fieldName)) { - return null; // Field allows nulls, so don't check. - } + // 3. Check for Opt-Outs (e.g. @Nullable) on the target field + if (isFieldOptOut(owner, fieldName)) { + return null; // Field allows nulls, so don't check. + } - // 4. Default to Strict - return super.defaultTarget; + // 4. Default to Strict + return super.defaultTarget; + } } return null; @@ -77,15 +78,33 @@ private boolean isFieldOptOut(String owner, String fieldName) { @Override public TargetAnnotation getBoundaryMethodOverrideReturnCheck(String owner, MethodTypeDesc desc) { - boolean isParentChecked = safetyFilter.test(new ClassInfo(owner, null, null)); + TypeKind returnType = TypeKind.from(desc.returnType()); - if (isParentChecked && returnType == TypeKind.REFERENCE) { - // Note: Ideally we should perform similar reflection here to check if the - // Parent method return type is @Nullable. - // For now, we keep the strict default. + if (returnType == TypeKind.REFERENCE) { return super.defaultTarget; } 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("error finding class"); + } + return false; + } } diff --git a/test-utils/src/main/java/io/github/eisop/testutils/AgentTestHarness.java b/test-utils/src/main/java/io/github/eisop/testutils/AgentTestHarness.java index 53e77be..f55e8b4 100644 --- a/test-utils/src/main/java/io/github/eisop/testutils/AgentTestHarness.java +++ b/test-utils/src/main/java/io/github/eisop/testutils/AgentTestHarness.java @@ -2,6 +2,7 @@ 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; @@ -17,13 +18,9 @@ public abstract class AgentTestHarness { protected Path distDir; protected void setup() throws IOException { - // Create a temp directory for this test run this.tempDir = Files.createTempDirectory("eisop-agent-test"); - - // Locate the dist directory passed from Gradle String distPath = System.getProperty("agent.dist.dir"); if (distPath == null) { - // Fallback for running in IDE without Gradle context, assuming standard build layout Path potentialDist = Path.of(System.getProperty("user.dir")).resolve("../build/dist").normalize(); if (Files.exists(potentialDist)) { @@ -36,10 +33,10 @@ protected void setup() throws IOException { this.distDir = Path.of(distPath); } + @SuppressWarnings("EmptyCatch") protected void cleanup() throws IOException { - // Recursive delete try (Stream walk = Files.walk(tempDir)) { - walk.sorted((a, b) -> b.compareTo(a)) // Delete leaves first + walk.sorted((a, b) -> b.compareTo(a)) .forEach( p -> { try { @@ -50,19 +47,10 @@ protected void cleanup() throws IOException { } } - // --- Helper to copy test resources --- - - /** - * Copies a test source file from src/test/resources/test-cases/ to the temp directory. - * - * @param resourcePath relative path in test-cases (e.g. "dispatch/Runner.java") - */ protected void copyTestFile(String resourcePath) throws IOException { - // Load from classpath resources String fullPath = "test-cases/" + resourcePath; try (InputStream is = getClass().getClassLoader().getResourceAsStream(fullPath)) { if (is == null) { - // Try looking in file system directly (useful if resources aren't copied yet by IDE) Path fsPath = Path.of("src/test/resources/" + fullPath); if (Files.exists(fsPath)) { copyFileFromDisk(fsPath, resourcePath); @@ -70,7 +58,6 @@ protected void copyTestFile(String resourcePath) throws IOException { } throw new IOException("Test resource not found: " + fullPath); } - Path dest = tempDir.resolve(resourcePath); Files.createDirectories(dest.getParent()); Files.copy(is, dest, StandardCopyOption.REPLACE_EXISTING); @@ -83,15 +70,12 @@ private void copyFileFromDisk(Path source, String relativeDest) throws IOExcepti Files.copy(source, dest, StandardCopyOption.REPLACE_EXISTING); } - // Keep this for simple ad-hoc tests if needed protected void writeSource(String filename, String content) throws IOException { Path file = tempDir.resolve(filename); Files.createDirectories(file.getParent()); Files.writeString(file, content, StandardOpenOption.CREATE); } - // --- Compilation --- - protected void compile(List filenames) throws Exception { compile(filenames.toArray(String[]::new)); } @@ -101,15 +85,20 @@ protected void compile(String... filenames) throws Exception { } protected void compileWithClasspath(String extraClasspath, String... filenames) throws Exception { - // Find checker-qual jar Path qualJar = findJar("checker-qual"); - String cp = qualJar.toAbsolutePath().toString(); + // FIX: Include framework.jar so that @AnnotatedFor and other runtime annotations resolve + 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"); @@ -122,8 +111,6 @@ protected void compileWithClasspath(String extraClasspath, String... filenames) runProcess(cmd, "Compilation"); } - // --- Execution --- - protected TestResult runAgent(String mainClass, String... agentArgs) throws Exception { return runAgent(mainClass, false, agentArgs); } @@ -133,10 +120,8 @@ protected TestResult runAgent(String mainClass, boolean isGlobal, String... agen Path frameworkJar = findJar("framework"); Path checkerJar = findJar("checker"); Path qualJar = findJar("checker-qual"); - // We likely need test-utils jar on the classpath for the TestViolationHandler Path testUtilsJar = findJar("test-utils"); - // Build Classpath (Agent Jars + Test Classes + Test Utils) String cp = "." + ":" @@ -157,9 +142,7 @@ protected TestResult runAgent(String mainClass, boolean isGlobal, String... agen cmd.add("-Druntime.global=true"); } - // Add agent arguments (e.g. -Druntime.classes=...) cmd.addAll(List.of(agentArgs)); - cmd.add("-cp"); cmd.add(cp); cmd.add(mainClass); @@ -183,12 +166,10 @@ private Path findJar(String prefix) throws IOException { private TestResult runProcess(List cmd, String taskName) throws Exception { ProcessBuilder pb = new ProcessBuilder(cmd); pb.directory(tempDir.toFile()); - Process p = pb.start(); - // Read output - String stdout = new String(p.getInputStream().readAllBytes()); - String stderr = new String(p.getErrorStream().readAllBytes()); + 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) { diff --git a/test-utils/src/main/java/io/github/eisop/testutils/RuntimeTestRunner.java b/test-utils/src/main/java/io/github/eisop/testutils/RuntimeTestRunner.java index 2f98bd2..0d11d95 100644 --- a/test-utils/src/main/java/io/github/eisop/testutils/RuntimeTestRunner.java +++ b/test-utils/src/main/java/io/github/eisop/testutils/RuntimeTestRunner.java @@ -23,8 +23,10 @@ public void runDirectoryTest(String dirName, String checkerClass, boolean isGlob Path resourceDir = Path.of("src/test/resources/" + resourcePath); if (!Files.exists(resourceDir)) { + // Fallback for IDE vs Gradle working directory differences resourceDir = Path.of("checker/src/test/resources/" + resourcePath); } + if (!Files.exists(resourceDir)) { throw new IOException("Test directory not found: " + resourceDir.toAbsolutePath()); } @@ -45,13 +47,12 @@ public void runDirectoryTest(String dirName, String checkerClass, boolean isGlob fileNames.add(fname); } - // 3. Compile ALL files together (handles dependencies) + // 3. Compile ALL files compile(fileNames); // 4. Run each file that has a main method for (Path sourcePath : javaFiles) { String content = Files.readString(sourcePath); - // Simple heuristic to avoid running helper classes if (content.contains("public static void main")) { runSingleTest(sourcePath, checkerClass, isGlobal); } @@ -70,13 +71,16 @@ private void runSingleTest(Path sourcePath, String checkerClass, boolean isGloba String filename = sourcePath.getFileName().toString(); String mainClass = filename.replace(".java", ""); - // Pass global flag and handler + // We define the main class as the "Checked Class" for this test run. + // Any other classes needed to be Checked must be marked with @AnnotatedFor + // and we enable trustAnnotatedFor to pick them up. TestResult result = runAgent( mainClass, isGlobal, "-Druntime.checker=" + checkerClass, - "-Druntime.classes=" + mainClass, // Always treat the runner as Checked + "-Druntime.classes=" + "test", + "-Druntime.trustAnnotatedFor=true", // Enable auto-discovery for dependencies "-Druntime.handler=io.github.eisop.testutils.TestViolationHandler"); verifyErrors(expectedErrors, result.stdout(), filename); @@ -88,6 +92,7 @@ private List parseExpectedErrors(Path sourceFile) throws IOExcept for (int i = 0; i < lines.size(); i++) { Matcher m = ERROR_PATTERN.matcher(lines.get(i)); if (m.find()) { + // Line numbers are 1-based errors.add(new ExpectedError(i + 1, m.group(1).trim())); } } @@ -128,7 +133,7 @@ private void verifyErrors(List expected, String stdout, String fi for (ExpectedError exp : unmatchedExpected) { if (exp.expectedMessage().equals(act.expectedMessage())) { long diff = Math.abs(act.lineNumber() - exp.lineNumber()); - if (diff <= 5) { // Fuzzy matching + if (diff <= 5) { bestMatch = exp; break; } @@ -152,7 +157,7 @@ private void verifyErrors(List expected, String stdout, String fi sb.append("Unexpected Runtime Violations:\n"); unmatchedActual.forEach(e -> sb.append(" ").append(e).append("\n")); } - sb.append("\nFull Stdout:\n").append(stdout).append("\n"); + sb.append("\nFull Output:\n").append(stdout).append("\n"); System.out.println(sb.toString()); Assertions.fail("Verification failed. Mismatched errors."); } From a97e23ca5932bd324cc1ff954e17bcbf55758026 Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Mon, 22 Dec 2025 14:13:49 -0500 Subject: [PATCH 38/50] refactor: move checked reflection from instrumenter to policy --- .../core/AnnotationInstrumenter.java | 76 ++--------- .../policy/EnforcementPolicy.java | 6 + .../policy/GlobalEnforcementPolicy.java | 119 ++++++++++-------- 3 files changed, 80 insertions(+), 121 deletions(-) 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 index 37a3ffd..d5f1448 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java @@ -154,19 +154,15 @@ protected void generateUncheckedReturnCheck( ClassLoader loader) { if (ret.opcode() != Opcode.ARETURN) return; - String checkedParent = findCheckedOverriddenMethod(classModel, method, loader); + // Delegated to Policy now + TargetAnnotation target = policy.getUncheckedOverrideReturnCheck(classModel, method, loader); - if (checkedParent != null) { - TargetAnnotation target = - policy.getBoundaryMethodOverrideReturnCheck(checkedParent, method.methodTypeSymbol()); - - if (target != null) { - b.dup(); - target.check( - b, - TypeKind.REFERENCE, - "Return value of overridden method " + method.methodName().stringValue()); - } + if (target != null) { + b.dup(); + target.check( + b, + TypeKind.REFERENCE, + "Return value of overridden method " + method.methodName().stringValue()); } } @@ -253,62 +249,6 @@ private void emitBridge(ClassBuilder builder, Method parentMethod) { }); } - private String findCheckedOverriddenMethod( - 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('.', '/'); - boolean isChecked = safetyFilter.test(new ClassInfo(internalName, null, null)); - if (!isChecked) { - for (java.lang.annotation.Annotation anno : parent.getAnnotations()) { - if (anno.annotationType() - .getName() - .equals("io.github.eisop.runtimeframework.qual.AnnotatedFor")) { - isChecked = true; - System.out.println("DEBUG: Found @AnnotatedFor on " + internalName); - break; - } - } - } else { - System.out.println("DEBUG: Parent " + internalName + " is explicitly checked via filter"); - } - - if (isChecked) { - 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)) { - System.out.println("DEBUG: Found matched method in " + internalName); - return internalName; - } - } - } - } - parent = parent.getSuperclass(); - } - } catch (Exception e) { - System.err.println("DEBUG: Error in findCheckedOverriddenMethod: " + e); - e.printStackTrace(); - } - return null; - } - - 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(); - } - private FieldModel findField(ClassModel classModel, FieldInstruction field) { for (FieldModel fm : classModel.fields()) { if (fm.fieldName().stringValue().equals(field.name().stringValue()) 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 index 3fb0f10..e1477e7 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/policy/EnforcementPolicy.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/policy/EnforcementPolicy.java @@ -1,6 +1,7 @@ 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; @@ -68,4 +69,9 @@ default TargetAnnotation getBoundaryMethodOverrideReturnCheck(String owner, Meth TargetAnnotation getArrayLoadCheck(TypeKind componentType); TargetAnnotation getLocalVariableWriteCheck(MethodModel method, int slot, TypeKind type); + + 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 index aeb6de9..a4afbd0 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/policy/GlobalEnforcementPolicy.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/policy/GlobalEnforcementPolicy.java @@ -4,8 +4,11 @@ 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.MethodTypeDesc; +import java.lang.constant.ClassDesc; +import java.lang.reflect.Method; import java.util.Collection; public class GlobalEnforcementPolicy extends StandardEnforcementPolicy { @@ -20,69 +23,49 @@ public GlobalEnforcementPolicy( @Override public TargetAnnotation getBoundaryFieldWriteCheck( String owner, String fieldName, TypeKind type) { - // GLOBAL LOGIC: Legacy code writing to 'owner'. - - // 1. Is the Target Class (owner) Checked? - boolean checked = isClassChecked(owner); - // System.out.println("DEBUG: getBoundaryFieldWriteCheck owner=" + owner + " checked=" + - // checked); - - if (checked) { - // 2. Is it a Reference? + if (isClassChecked(owner)) { if (type == TypeKind.REFERENCE) { - - // 3. Check for Opt-Outs (e.g. @Nullable) on the target field if (isFieldOptOut(owner, fieldName)) { - return null; // Field allows nulls, so don't check. + return null; } - - // 4. Default to Strict return super.defaultTarget; } } - return null; } - private boolean isFieldOptOut(String owner, String fieldName) { - try { - // Attempt to load the target class to inspect field annotations. - // We use the ContextClassLoader as a best-effort resolution strategy. - Class clazz = - Class.forName( - owner.replace('/', '.'), false, Thread.currentThread().getContextClassLoader()); - java.lang.reflect.Field field = clazz.getDeclaredField(fieldName); - - // Check Declaration Annotations - for (java.lang.annotation.Annotation anno : field.getAnnotations()) { - String desc = "L" + anno.annotationType().getName().replace('.', '/') + ";"; - if (optOutDescriptors.contains(desc)) { - return true; - } - } + @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; - // Check Type Annotations (e.g. @Nullable String) - for (java.lang.annotation.Annotation anno : field.getAnnotatedType().getAnnotations()) { - String desc = "L" + anno.annotationType().getName().replace('.', '/') + ";"; - if (optOutDescriptors.contains(desc)) { - return true; + 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)) { + // Found Checked Parent defining this method + TypeKind returnType = + TypeKind.from(ClassDesc.ofDescriptor(m.getReturnType().descriptorString())); + if (returnType == TypeKind.REFERENCE) { + return super.defaultTarget; + } + } + } + } } + parent = parent.getSuperclass(); } - } catch (Throwable t) { - // If resolution fails (class not found, field not found, security, etc.), - // we fall back to "False" (Not Opt-Out), which enforces the Strict Check. - // This is the safe default. - } - return false; - } - - @Override - public TargetAnnotation getBoundaryMethodOverrideReturnCheck(String owner, MethodTypeDesc desc) { - - TypeKind returnType = TypeKind.from(desc.returnType()); - - if (returnType == TypeKind.REFERENCE) { - return super.defaultTarget; + } catch (Throwable e) { + // Ignore } return null; } @@ -103,8 +86,38 @@ private boolean isClassChecked(String internalName) { } } } catch (Throwable e) { - System.out.println("error finding class"); + // Ignore + } + 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) { } 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(); + } } From 835d46ff7f29a078ca445754516393dbdd904e61 Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Mon, 22 Dec 2025 14:34:31 -0500 Subject: [PATCH 39/50] chore: comment cleanup --- .../core/AnnotationInstrumenter.java | 26 +++++++------------ 1 file changed, 9 insertions(+), 17 deletions(-) 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 index d5f1448..405c7be 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java @@ -38,31 +38,22 @@ public AnnotationInstrumenter( this.safetyFilter = safetyFilter; } - // --- Hooks --- - @Override protected void generateArrayStoreCheck(CodeBuilder b, ArrayStoreInstruction instruction) { - // We only support Reference Arrays (AASTORE) for nullness checking if (instruction.opcode() == Opcode.AASTORE) { TargetAnnotation target = policy.getArrayStoreCheck(TypeKind.REFERENCE); if (target != null) { - // Stack: [..., arrayRef, index, value] - // We need to check 'value' without consuming it. b.dup(); - // Stack: [..., arrayRef, index, value, value] target.check(b, TypeKind.REFERENCE, "Array Element Write"); - // Stack: [..., arrayRef, index, value] -> Ready for AASTORE } } } @Override protected void generateArrayLoadCheck(CodeBuilder b, ArrayLoadInstruction instruction) { - // We only support Reference Arrays (AALOAD) if (instruction.opcode() == Opcode.AALOAD) { TargetAnnotation target = policy.getArrayLoadCheck(TypeKind.REFERENCE); if (target != null) { - // Stack: [..., value] (Instruction has already executed) b.dup(); target.check(b, TypeKind.REFERENCE, "Array Element Read"); } @@ -85,15 +76,12 @@ protected void generateFieldWriteCheck( TargetAnnotation target = null; TypeKind type = TypeKind.fromDescriptor(field.typeSymbol().descriptorString()); - // A. Internal Write (this.field = val) if (field.owner().asInternalName().equals(classModel.thisClass().asInternalName())) { FieldModel targetField = findField(classModel, field); if (targetField != null) { target = policy.getFieldWriteCheck(targetField, type); } - } - // B. External Write (other.field = val) - Crucial for Global Policy - else { + } else { target = policy.getBoundaryFieldWriteCheck( field.owner().asInternalName(), field.name().stringValue(), type); @@ -153,17 +141,23 @@ protected void generateUncheckedReturnCheck( ClassModel classModel, ClassLoader loader) { if (ret.opcode() != Opcode.ARETURN) return; + System.out.println( + "[DEBUG] Checking Unchecked Return for " + + classModel.thisClass().asInternalName() + + "." + + method.methodName().stringValue()); - // Delegated to Policy now TargetAnnotation target = policy.getUncheckedOverrideReturnCheck(classModel, method, loader); - if (target != null) { + System.out.println("[DEBUG] Policy returned target for override check."); + b.dup(); target.check( b, TypeKind.REFERENCE, "Return value of overridden method " + method.methodName().stringValue()); } + System.out.println("[DEBUG] Policy returned NULL for override check."); } @Override @@ -201,8 +195,6 @@ protected void generateStoreCheck( } } - // --- Helpers --- - private void emitBridge(ClassBuilder builder, Method parentMethod) { String methodName = parentMethod.getName(); MethodTypeDesc desc = From 2b1dbfe989aefd9cba6bc79515d500d7e0ef708d Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Mon, 22 Dec 2025 14:34:51 -0500 Subject: [PATCH 40/50] test: add nullable override --- .../test-cases/nullness-global-bridge/LegacyTrojan.java | 4 ++++ .../test-cases/nullness-global-bridge/SafeContract.java | 5 +++++ .../test-cases/nullness-global-bridge/TrojanRunner.java | 3 ++- 3 files changed, 11 insertions(+), 1 deletion(-) 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 index 8bd1131..fb91d84 100644 --- a/checker/src/test/resources/test-cases/nullness-global-bridge/LegacyTrojan.java +++ b/checker/src/test/resources/test-cases/nullness-global-bridge/LegacyTrojan.java @@ -4,5 +4,9 @@ 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 index 4046af4..5da5be5 100644 --- a/checker/src/test/resources/test-cases/nullness-global-bridge/SafeContract.java +++ b/checker/src/test/resources/test-cases/nullness-global-bridge/SafeContract.java @@ -1,7 +1,12 @@ 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 @NonNull 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 index fd1da61..4231398 100644 --- a/checker/src/test/resources/test-cases/nullness-global-bridge/TrojanRunner.java +++ b/checker/src/test/resources/test-cases/nullness-global-bridge/TrojanRunner.java @@ -2,6 +2,7 @@ public class TrojanRunner { public static void main(String[] args) { System.out.println("--- Starting Trojan Runner ---"); SafeContract c = new LegacyTrojan(); - c.getValue(); + c.getValue(); + c.getUnsafeValue(); } } From 972380493de6e7022e3bf9f1926bd7ef6310bf0f Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Mon, 22 Dec 2025 15:16:49 -0500 Subject: [PATCH 41/50] feat(framework): support nullable in an unchecked override --- .../nullness-global-bridge/SafeContract.java | 4 +- .../policy/GlobalEnforcementPolicy.java | 27 +++++++- .../github/eisop/testutils/ExpectedError.java | 4 +- .../eisop/testutils/RuntimeTestRunner.java | 65 ++++++++++++------- 4 files changed, 68 insertions(+), 32 deletions(-) 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 index 5da5be5..57ad000 100644 --- a/checker/src/test/resources/test-cases/nullness-global-bridge/SafeContract.java +++ b/checker/src/test/resources/test-cases/nullness-global-bridge/SafeContract.java @@ -6,7 +6,5 @@ @AnnotatedFor("nullness") public class SafeContract { public @NonNull String getValue() { return "Safe"; } - - public @NonNull String getUnsafeValue() { return "Unsafe"; } - + public @Nullable String getUnsafeValue() { return "Unsafe"; } } 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 index a4afbd0..231819b 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/policy/GlobalEnforcementPolicy.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/policy/GlobalEnforcementPolicy.java @@ -52,7 +52,30 @@ public TargetAnnotation getUncheckedOverrideReturnCheck( String methodDesc = method.methodTypeSymbol().descriptorString(); String parentDesc = getMethodDescriptor(m); if (methodDesc.equals(parentDesc)) { - // Found Checked Parent defining this method + // Found Checked Parent defining this method. + + // FIX: Check for Opt-Outs on the Parent Method. + // We must check BOTH Declaration Annotations (e.g. @Deprecated) + // AND Type Annotations on the return type (e.g. @Nullable String). + + // 1. Declaration Annotations + for (java.lang.annotation.Annotation anno : m.getAnnotations()) { + String desc = "L" + anno.annotationType().getName().replace('.', '/') + ";"; + if (optOutDescriptors.contains(desc)) { + return null; + } + } + + // 2. Type Annotations (Correct place for @Nullable on return) + for (java.lang.annotation.Annotation anno : + m.getAnnotatedReturnType().getAnnotations()) { + String desc = "L" + anno.annotationType().getName().replace('.', '/') + ";"; + if (optOutDescriptors.contains(desc)) { + return null; + } + } + + // Check strict default TypeKind returnType = TypeKind.from(ClassDesc.ofDescriptor(m.getReturnType().descriptorString())); if (returnType == TypeKind.REFERENCE) { @@ -65,7 +88,7 @@ public TargetAnnotation getUncheckedOverrideReturnCheck( parent = parent.getSuperclass(); } } catch (Throwable e) { - // Ignore + System.out.println("fail"); } return null; } 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 index 719e07a..f1c161b 100644 --- a/test-utils/src/main/java/io/github/eisop/testutils/ExpectedError.java +++ b/test-utils/src/main/java/io/github/eisop/testutils/ExpectedError.java @@ -1,8 +1,8 @@ package io.github.eisop.testutils; -public record ExpectedError(long lineNumber, String expectedMessage) { +public record ExpectedError(String filename, long lineNumber, String expectedMessage) { @Override public String toString() { - return "Line " + lineNumber + ": " + expectedMessage; + 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 index 0d11d95..073aa3e 100644 --- a/test-utils/src/main/java/io/github/eisop/testutils/RuntimeTestRunner.java +++ b/test-utils/src/main/java/io/github/eisop/testutils/RuntimeTestRunner.java @@ -23,7 +23,6 @@ public void runDirectoryTest(String dirName, String checkerClass, boolean isGlob Path resourceDir = Path.of("src/test/resources/" + resourcePath); if (!Files.exists(resourceDir)) { - // Fallback for IDE vs Gradle working directory differences resourceDir = Path.of("checker/src/test/resources/" + resourcePath); } @@ -31,7 +30,6 @@ public void runDirectoryTest(String dirName, String checkerClass, boolean isGlob throw new IOException("Test directory not found: " + resourceDir.toAbsolutePath()); } - // 1. Gather all Java files List javaFiles; try (var stream = Files.walk(resourceDir)) { javaFiles = stream.filter(p -> p.toString().endsWith(".java")).collect(Collectors.toList()); @@ -39,7 +37,6 @@ public void runDirectoryTest(String dirName, String checkerClass, boolean isGlob if (javaFiles.isEmpty()) return; - // 2. Copy all files to temp dir List fileNames = new ArrayList<>(); for (Path p : javaFiles) { String fname = p.getFileName().toString(); @@ -47,60 +44,69 @@ public void runDirectoryTest(String dirName, String checkerClass, boolean isGlob fileNames.add(fname); } - // 3. Compile ALL files compile(fileNames); - // 4. Run each file that has a main method + List mainFiles = new ArrayList<>(); + List helperFiles = new ArrayList<>(); + for (Path sourcePath : javaFiles) { String content = Files.readString(sourcePath); if (content.contains("public static void main")) { - runSingleTest(sourcePath, checkerClass, isGlobal); + mainFiles.add(sourcePath); + } else { + helperFiles.add(sourcePath); } } + for (Path mainSource : mainFiles) { + runSingleTest(mainSource, helperFiles, checkerClass, isGlobal); + } + } finally { cleanup(); } } - private void runSingleTest(Path sourcePath, String checkerClass, boolean isGlobal) + private void runSingleTest( + Path mainSource, List helperFiles, String checkerClass, boolean isGlobal) throws Exception { - System.out.println("Running test: " + sourcePath.getFileName()); - List expectedErrors = parseExpectedErrors(sourcePath); + 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 = sourcePath.getFileName().toString(); + String filename = mainSource.getFileName().toString(); String mainClass = filename.replace(".java", ""); - // We define the main class as the "Checked Class" for this test run. - // Any other classes needed to be Checked must be marked with @AnnotatedFor - // and we enable trustAnnotatedFor to pick them up. TestResult result = runAgent( mainClass, isGlobal, "-Druntime.checker=" + checkerClass, - "-Druntime.classes=" + "test", - "-Druntime.trustAnnotatedFor=true", // Enable auto-discovery for dependencies + "-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()) { - // Line numbers are 1-based - errors.add(new ExpectedError(i + 1, m.group(1).trim())); + errors.add(new ExpectedError(fileName, i + 1, m.group(1).trim())); } } return errors; } @SuppressWarnings("StringSplitter") - private void verifyErrors(List expected, String stdout, String filename) { + private void verifyErrors(List expected, String stdout, String testName) { List actualErrors = new ArrayList<>(); stdout @@ -108,17 +114,21 @@ private void verifyErrors(List expected, String stdout, String fi .forEach( line -> { if (line.startsWith("[VIOLATION]")) { + // Format: [VIOLATION] File.java:Line (Checker) Message String[] parts = line.split(" "); if (parts.length > 1) { String fileLoc = parts[1]; if (fileLoc.contains(":")) { String[] locParts = fileLoc.split(":"); - if (locParts[0].equals(filename)) { - long lineNum = Long.parseLong(locParts[1]); - int msgStart = line.indexOf(") ") + 2; - String msg = (msgStart > 1) ? line.substring(msgStart) : ""; - actualErrors.add(new ExpectedError(lineNum, msg.trim())); - } + String errFile = locParts[0]; + long lineNum = Long.parseLong(locParts[1]); + + int msgStart = line.indexOf(") ") + 2; + String msg = (msgStart > 1) ? line.substring(msgStart) : ""; + + // FIX: Added 'true ||' logic implicitly by removing the filename check. + // Now we accept errors from ANY file involved in the test. + actualErrors.add(new ExpectedError(errFile, lineNum, msg.trim())); } } } @@ -131,7 +141,12 @@ private void verifyErrors(List expected, String stdout, String fi act -> { ExpectedError bestMatch = null; for (ExpectedError exp : unmatchedExpected) { + // 1. Must match Filename + if (!exp.filename().equals(act.filename())) continue; + + // 2. Must match Message if (exp.expectedMessage().equals(act.expectedMessage())) { + // 3. Fuzzy Line Check (+/- 5 lines) long diff = Math.abs(act.lineNumber() - exp.lineNumber()); if (diff <= 5) { bestMatch = exp; @@ -148,7 +163,7 @@ private void verifyErrors(List expected, String stdout, String fi if (!unmatchedExpected.isEmpty() || !unmatchedActual.isEmpty()) { StringBuilder sb = new StringBuilder(); - sb.append("\n=== TEST FAILED: ").append(filename).append(" ===\n"); + 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")); From c0a5ed726f2a015f901e5f47583a024ed04ecd32 Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Mon, 22 Dec 2025 15:21:02 -0500 Subject: [PATCH 42/50] chore: error prone cleanup --- .../core/AnnotationInstrumenter.java | 2 -- .../policy/GlobalEnforcementPolicy.java | 3 ++- .../policy/StandardEnforcementPolicy.java | 16 ---------------- 3 files changed, 2 insertions(+), 19 deletions(-) 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 index 405c7be..6492fe0 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java @@ -26,7 +26,6 @@ public class AnnotationInstrumenter extends RuntimeInstrumenter { private final EnforcementPolicy policy; private final HierarchyResolver hierarchyResolver; - private final Filter safetyFilter; public AnnotationInstrumenter( EnforcementPolicy policy, @@ -35,7 +34,6 @@ public AnnotationInstrumenter( super(safetyFilter); this.policy = policy; this.hierarchyResolver = hierarchyResolver; - this.safetyFilter = safetyFilter; } @Override 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 index 231819b..c8f8a5c 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/policy/GlobalEnforcementPolicy.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/policy/GlobalEnforcementPolicy.java @@ -88,7 +88,7 @@ public TargetAnnotation getUncheckedOverrideReturnCheck( parent = parent.getSuperclass(); } } catch (Throwable e) { - System.out.println("fail"); + System.out.println("reflection fail in method override"); } return null; } @@ -130,6 +130,7 @@ private boolean isFieldOptOut(String owner, String fieldName) { "L" + anno.annotationType().getName().replace('.', '/') + ";")) return true; } } catch (Throwable t) { + System.out.println("reflection fail in is field opt out"); } return false; } 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 index b6f8dd8..aa6f736 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/policy/StandardEnforcementPolicy.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/policy/StandardEnforcementPolicy.java @@ -244,22 +244,6 @@ private List getFieldAnnotations(FieldModel field) { return result; } - private List getMethodReturnAnnotations(MethodModel method) { - List result = new ArrayList<>(); - method - .findAttribute(Attributes.runtimeVisibleTypeAnnotations()) - .ifPresent( - attr -> { - for (TypeAnnotation ta : attr.annotations()) { - // FIX: Use enum comparison, not instanceof - if (ta.targetInfo().targetType() == TypeAnnotation.TargetType.METHOD_RETURN) { - result.add(ta.annotation()); - } - } - }); - return result; - } - private List getLocalVariableAnnotations(MethodModel method, int slot) { List result = new ArrayList<>(); method From 44ea6c32318696e05f9f50fdeab2d46a906867b2 Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Mon, 22 Dec 2025 15:22:35 -0500 Subject: [PATCH 43/50] chore: debug cleanup --- .../eisop/runtimeframework/core/AnnotationInstrumenter.java | 3 --- 1 file changed, 3 deletions(-) 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 index 6492fe0..4ac180d 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java @@ -147,15 +147,12 @@ protected void generateUncheckedReturnCheck( TargetAnnotation target = policy.getUncheckedOverrideReturnCheck(classModel, method, loader); if (target != null) { - System.out.println("[DEBUG] Policy returned target for override check."); - b.dup(); target.check( b, TypeKind.REFERENCE, "Return value of overridden method " + method.methodName().stringValue()); } - System.out.println("[DEBUG] Policy returned NULL for override check."); } @Override From 054e8783e98b62c0c69f0398dd76f71613b978da Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Mon, 22 Dec 2025 15:23:49 -0500 Subject: [PATCH 44/50] doc: update runtime checker javadoc --- .../io/github/eisop/runtimeframework/core/RuntimeChecker.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeChecker.java b/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeChecker.java index 00d22f1..a024090 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeChecker.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeChecker.java @@ -13,7 +13,7 @@ */ public abstract class RuntimeChecker { - /** Returns the human-readable name of this checker (e.g., "Nullness Runtime Checker"). */ + /** Returns the name of this checker. This string should match the name used in AnnotatedFor */ public abstract String getName(); /** From 8b6037d0071f7f2b4d939b39c86b8bf7aa95da62 Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Mon, 22 Dec 2025 15:55:47 -0500 Subject: [PATCH 45/50] fix(framework): handle global vs. annotated for mode properly --- .../checker/nullness/NonNullTarget.java | 11 ------ .../nullness/NullnessRuntimeChecker.java | 6 +--- .../nullness/NullnessDirectoryTest.java | 8 +++++ .../InstanceWrite.java | 24 +++++++++++++ .../StaticWrite.java | 26 ++++++++++++++ .../runtimeframework/agent/RuntimeAgent.java | 35 ++++++++++--------- .../agent/RuntimeTransformer.java | 14 +++++--- .../core/RuntimeInstrumenter.java | 11 +++--- .../policy/EnforcementPolicy.java | 22 ++---------- .../policy/GlobalEnforcementPolicy.java | 11 +----- .../policy/StandardEnforcementPolicy.java | 11 ++---- 11 files changed, 98 insertions(+), 81 deletions(-) create mode 100644 checker/src/test/resources/test-cases/nullness-field-write-no-global/InstanceWrite.java create mode 100644 checker/src/test/resources/test-cases/nullness-field-write-no-global/StaticWrite.java 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 index 876d60d..479e4c8 100644 --- 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 @@ -22,21 +22,10 @@ public Class annotationType() { @Override public void check(CodeBuilder b, TypeKind type, String diagnosticName) { - // FIX: Use TypeKind.Reference (correct enum name in JDK 25) if (type == TypeKind.REFERENCE) { - // Stack: [..., Value] - - // 1. Push Message b.ldc(diagnosticName + " must be NonNull"); - - // Stack: [..., Value, String] - - // 2. Call Verifier b.invokestatic(VERIFIER, METHOD, DESC); - - // Stack: [...] (Consumed) } else { - // If it's a primitive, pop it to clean stack (no check needed) 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 index 38b3e75..c363cf8 100644 --- 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 @@ -23,16 +23,12 @@ public String getName() { public RuntimeInstrumenter getInstrumenter(Filter filter) { EnforcementPolicy policy = createPolicy( - List.of(new NonNullTarget()), - List.of(new OptOutAnnotation(Nullable.class)), // Wraps the class - filter); + List.of(new NonNullTarget()), List.of(new OptOutAnnotation(Nullable.class)), filter); - // 2. Create Resolver HierarchyResolver resolver = new ReflectionHierarchyResolver( className -> filter.test(new ClassInfo(className.replace('.', '/'), null, null))); - // 3. Create Instrumenter return new AnnotationInstrumenter(policy, resolver, filter); } } diff --git a/checker/src/test/java/io/github/eisop/checker/nullness/NullnessDirectoryTest.java b/checker/src/test/java/io/github/eisop/checker/nullness/NullnessDirectoryTest.java index 64cf64d..d7f18da 100644 --- a/checker/src/test/java/io/github/eisop/checker/nullness/NullnessDirectoryTest.java +++ b/checker/src/test/java/io/github/eisop/checker/nullness/NullnessDirectoryTest.java @@ -37,6 +37,14 @@ public void testFieldWriteScenarios() throws Exception { 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( 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/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeAgent.java b/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeAgent.java index 010cd60..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 @@ -13,38 +13,42 @@ public final class RuntimeAgent { public static void premain(String args, Instrumentation inst) { - // 1. Safety Filter Filter safeFilter = new FrameworkSafetyFilter(); Filter policyFilter = safeFilter; - Filter scanFilter = safeFilter; - // 2. Configuration Flags String checkedClasses = System.getProperty("runtime.classes"); boolean isGlobalMode = Boolean.getBoolean("runtime.global"); boolean trustAnnotatedFor = Boolean.getBoolean("runtime.trustAnnotatedFor"); - // Logic for Policy Filter (Who is Checked?) 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) { - // New logic: If relying on annotations and no list is provided, default to Empty Set. - // This allows @AnnotatedFor to be the sole mechanism for opting in classes as Checked. policyFilter = info -> false; } - // Logic for Scan Filter (Who do we parse?) + 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; - } else if (isGlobalMode) { - System.out.println("[RuntimeAgent] Global Mode ENABLED. Scanning all safe classes."); - scanFilter = safeFilter; - } else if (checkedClasses != null) { - // Optimization: Only scan what is explicitly checked - scanFilter = policyFilter; } // 3. Configure Violation Handler @@ -62,7 +66,6 @@ public static void premain(String args, Instrumentation inst) { } } - // 4. Load Checker String checkerClassName = System.getProperty( "runtime.checker", "io.github.eisop.runtimeframework.util.SysOutRuntimeChecker"); @@ -79,8 +82,8 @@ public static void premain(String args, Instrumentation inst) { return; } - // 5. Register inst.addTransformer( - new RuntimeTransformer(scanFilter, policyFilter, checker, trustAnnotatedFor), false); + 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 index a52e74a..dbb6f60 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/agent/RuntimeTransformer.java @@ -7,7 +7,7 @@ import io.github.eisop.runtimeframework.qual.AnnotatedFor; import java.lang.classfile.Annotation; import java.lang.classfile.AnnotationValue; -import java.lang.classfile.Attributes; // Added import +import java.lang.classfile.Attributes; import java.lang.classfile.ClassFile; import java.lang.classfile.ClassModel; import java.lang.instrument.ClassFileTransformer; @@ -21,20 +21,22 @@ public class RuntimeTransformer implements ClassFileTransformer { 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 trustAnnotatedFor, + boolean isGlobalMode) { this.scanFilter = scanFilter; this.policyFilter = policyFilter; this.checker = checker; this.trustAnnotatedFor = trustAnnotatedFor; + this.isGlobalMode = isGlobalMode; } @Override @@ -80,6 +82,10 @@ public byte[] transform( } } + if (!isChecked && !isGlobalMode) { + return null; + } + boolean finalIsChecked = isChecked; Filter dynamicFilter = ctx -> { @@ -124,7 +130,7 @@ private boolean hasPackageLevelAnnotation(String className, ClassLoader loader, } } } catch (Exception e) { - System.out.println("TODO"); + System.out.println("Cannot get package info"); } packageCache.put(packageName, found); 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 index c4219c7..785570d 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/RuntimeInstrumenter.java @@ -19,7 +19,7 @@ import java.lang.classfile.instruction.InvokeInstruction; import java.lang.classfile.instruction.LineNumber; import java.lang.classfile.instruction.ReturnInstruction; -import java.lang.classfile.instruction.StoreInstruction; // NEW +import java.lang.classfile.instruction.StoreInstruction; import java.lang.constant.MethodTypeDesc; import java.lang.reflect.Modifier; @@ -48,7 +48,6 @@ public void accept(ClassBuilder classBuilder, ClassElement classElement) { boolean entryChecksDone = !isCheckedScope; for (CodeElement element : codeModel) { - // Inject entry checks after first LineNumber to ensure valid stack traces if (!entryChecksDone && element instanceof LineNumber) { codeBuilder.with(element); instrumentMethodEntry(codeBuilder, methodModel); @@ -72,6 +71,8 @@ public void accept(ClassBuilder classBuilder, ClassElement classElement) { // 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) { @@ -95,9 +96,7 @@ public void accept(ClassBuilder classBuilder, ClassElement classElement) { if (isCheckedScope) { generateArrayLoadCheck(codeBuilder, aload); } - } else if (element - instanceof StoreInstruction store) { // NEW: Store Check - // GATE: Only check local vars in Checked Code + } else if (element instanceof StoreInstruction store) { if (isCheckedScope) { generateStoreCheck(codeBuilder, store, methodModel); } @@ -150,8 +149,6 @@ protected void instrumentMethodEntry(CodeBuilder builder, MethodModel method) { } } - // --- Abstract Hooks --- - protected abstract void generateParameterCheck( CodeBuilder b, int slotIndex, TypeKind type, MethodModel method, int paramIndex); 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 index e1477e7..c1b557f 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/policy/EnforcementPolicy.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/policy/EnforcementPolicy.java @@ -8,16 +8,9 @@ import java.lang.constant.MethodTypeDesc; import java.lang.reflect.Method; -/** - * Defines the rules for WHEN to inject a runtime check. - * - *

This separates the "Mechanism" (Bytecode Generation) from the "Policy" (Safety Rules). It - * allows swapping between Strict, Lenient, or Debug policies without changing the instrumenter. - */ +/** Defines the rules for WHEN to inject a runtime check. */ public interface EnforcementPolicy { - // --- 1. Internal Logic (Method Bodies) --- - /** Should we check this specific parameter at method entry? */ TargetAnnotation getParameterCheck(MethodModel method, int paramIndex, TypeKind type); @@ -33,29 +26,18 @@ public interface EnforcementPolicy { /** * Should we check a write to a field in an EXTERNAL class? (Used when Unchecked code writes to * Checked code). - * - *

Default: Returns {@code null} (No check). Most policies do not instrument unchecked - * code and therefore cannot enforce this. */ default TargetAnnotation getBoundaryFieldWriteCheck( String owner, String fieldName, TypeKind type) { return null; } - default TargetAnnotation getBoundaryMethodOverrideReturnCheck(String owner, MethodTypeDesc desc) { - return null; - } - - // --- 2. Boundary Logic (Calls & External Access) --- - /** 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); - // --- 3. Inheritance Logic (Bridges) --- - /** Should we generate a bridge for this inherited method? */ boolean shouldGenerateBridge(Method parentMethod); @@ -68,8 +50,10 @@ default TargetAnnotation getBoundaryMethodOverrideReturnCheck(String owner, Meth /** 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 index c8f8a5c..00533ac 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/policy/GlobalEnforcementPolicy.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/policy/GlobalEnforcementPolicy.java @@ -52,13 +52,6 @@ public TargetAnnotation getUncheckedOverrideReturnCheck( String methodDesc = method.methodTypeSymbol().descriptorString(); String parentDesc = getMethodDescriptor(m); if (methodDesc.equals(parentDesc)) { - // Found Checked Parent defining this method. - - // FIX: Check for Opt-Outs on the Parent Method. - // We must check BOTH Declaration Annotations (e.g. @Deprecated) - // AND Type Annotations on the return type (e.g. @Nullable String). - - // 1. Declaration Annotations for (java.lang.annotation.Annotation anno : m.getAnnotations()) { String desc = "L" + anno.annotationType().getName().replace('.', '/') + ";"; if (optOutDescriptors.contains(desc)) { @@ -66,7 +59,6 @@ public TargetAnnotation getUncheckedOverrideReturnCheck( } } - // 2. Type Annotations (Correct place for @Nullable on return) for (java.lang.annotation.Annotation anno : m.getAnnotatedReturnType().getAnnotations()) { String desc = "L" + anno.annotationType().getName().replace('.', '/') + ";"; @@ -75,7 +67,6 @@ public TargetAnnotation getUncheckedOverrideReturnCheck( } } - // Check strict default TypeKind returnType = TypeKind.from(ClassDesc.ofDescriptor(m.getReturnType().descriptorString())); if (returnType == TypeKind.REFERENCE) { @@ -109,7 +100,7 @@ private boolean isClassChecked(String internalName) { } } } catch (Throwable e) { - // Ignore + System.out.println("Override reflection fail"); } return false; } 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 index aa6f736..f513ca6 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/policy/StandardEnforcementPolicy.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/policy/StandardEnforcementPolicy.java @@ -63,8 +63,6 @@ private boolean hasOptOutAnnotation(List annotations) { return false; } - // --- 1. Internal Logic --- - @Override public TargetAnnotation getParameterCheck(MethodModel method, int paramIndex, TypeKind type) { if (type != TypeKind.REFERENCE) return null; @@ -79,7 +77,7 @@ public TargetAnnotation getParameterCheck(MethodModel method, int paramIndex, Ty @Override public TargetAnnotation getFieldWriteCheck(FieldModel field, TypeKind type) { - return null; // Trust internal code + return null; } @Override @@ -94,14 +92,13 @@ public TargetAnnotation getFieldReadCheck(FieldModel field, TypeKind type) { @Override public TargetAnnotation getReturnCheck(MethodModel method) { - return null; // Trust internal code + return null; } @Override public TargetAnnotation getLocalVariableWriteCheck(MethodModel method, int slot, TypeKind type) { if (type != TypeKind.REFERENCE) return null; - // Scan for annotations on this local variable slot List annos = getLocalVariableAnnotations(method, slot); TargetAnnotation explicit = findTarget(annos); @@ -111,8 +108,6 @@ public TargetAnnotation getLocalVariableWriteCheck(MethodModel method, int slot, return defaultTarget; } - // --- Array Logic --- - @Override public TargetAnnotation getArrayStoreCheck(TypeKind componentType) { if (componentType == TypeKind.REFERENCE) { @@ -129,8 +124,6 @@ public TargetAnnotation getArrayLoadCheck(TypeKind componentType) { return null; } - // --- 2. Boundary Logic --- - @Override public TargetAnnotation getBoundaryCallCheck(String owner, MethodTypeDesc desc) { boolean isUnchecked = !safetyFilter.test(new ClassInfo(owner, null, null)); From 3d41e60388f105a5806493f64ce6fc07175a15b8 Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Mon, 22 Dec 2025 15:59:31 -0500 Subject: [PATCH 46/50] chore: comment cleanup --- .../resolution/ReflectionHierarchyResolver.java | 8 +++----- .../io/github/eisop/testutils/AgentTestHarness.java | 1 - .../io/github/eisop/testutils/RuntimeTestRunner.java | 9 --------- .../github/eisop/testutils/TestViolationHandler.java | 10 ---------- 4 files changed, 3 insertions(+), 25 deletions(-) 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 index b0e05c1..2b23e7f 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/resolution/ReflectionHierarchyResolver.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/resolution/ReflectionHierarchyResolver.java @@ -46,8 +46,6 @@ public Set resolveUncheckedMethods(ClassModel model, ClassLoader loader) if (Modifier.isFinal(mods) || Modifier.isStatic(mods) || Modifier.isPrivate(mods)) continue; if (m.isSynthetic() || m.isBridge()) continue; - - // FIX: Use manual descriptor generation instead of ASM String sig = m.getName() + getMethodDescriptor(m); if (implementedSignatures.contains(sig)) continue; @@ -57,13 +55,13 @@ public Set resolveUncheckedMethods(ClassModel model, ClassLoader loader) currentAncestor = currentAncestor.getSuperclass(); } } catch (ClassNotFoundException e) { - // System.err.println("[RuntimeFramework] Could not resolve hierarchy for: " + - // model.thisClass().asInternalName()); + System.err.println( + "[RuntimeFramework] Could not resolve hierarchy for: " + + model.thisClass().asInternalName()); } return bridgesNeeded; } - // Helper to generate descriptor (e.g. "(Ljava/lang/String;)V") using JDK APIs private String getMethodDescriptor(Method m) { StringBuilder sb = new StringBuilder("("); for (Class p : m.getParameterTypes()) { diff --git a/test-utils/src/main/java/io/github/eisop/testutils/AgentTestHarness.java b/test-utils/src/main/java/io/github/eisop/testutils/AgentTestHarness.java index f55e8b4..668f3ef 100644 --- a/test-utils/src/main/java/io/github/eisop/testutils/AgentTestHarness.java +++ b/test-utils/src/main/java/io/github/eisop/testutils/AgentTestHarness.java @@ -86,7 +86,6 @@ protected void compile(String... filenames) throws Exception { protected void compileWithClasspath(String extraClasspath, String... filenames) throws Exception { Path qualJar = findJar("checker-qual"); - // FIX: Include framework.jar so that @AnnotatedFor and other runtime annotations resolve Path frameworkJar = findJar("framework"); String cp = diff --git a/test-utils/src/main/java/io/github/eisop/testutils/RuntimeTestRunner.java b/test-utils/src/main/java/io/github/eisop/testutils/RuntimeTestRunner.java index 073aa3e..692b5de 100644 --- a/test-utils/src/main/java/io/github/eisop/testutils/RuntimeTestRunner.java +++ b/test-utils/src/main/java/io/github/eisop/testutils/RuntimeTestRunner.java @@ -114,7 +114,6 @@ private void verifyErrors(List expected, String stdout, String te .forEach( line -> { if (line.startsWith("[VIOLATION]")) { - // Format: [VIOLATION] File.java:Line (Checker) Message String[] parts = line.split(" "); if (parts.length > 1) { String fileLoc = parts[1]; @@ -122,12 +121,8 @@ private void verifyErrors(List expected, String stdout, String te 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) : ""; - - // FIX: Added 'true ||' logic implicitly by removing the filename check. - // Now we accept errors from ANY file involved in the test. actualErrors.add(new ExpectedError(errFile, lineNum, msg.trim())); } } @@ -141,12 +136,8 @@ private void verifyErrors(List expected, String stdout, String te act -> { ExpectedError bestMatch = null; for (ExpectedError exp : unmatchedExpected) { - // 1. Must match Filename if (!exp.filename().equals(act.filename())) continue; - - // 2. Must match Message if (exp.expectedMessage().equals(act.expectedMessage())) { - // 3. Fuzzy Line Check (+/- 5 lines) long diff = Math.abs(act.lineNumber() - exp.lineNumber()); if (diff <= 5) { bestMatch = exp; 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 index 67090b0..9382c6c 100644 --- a/test-utils/src/main/java/io/github/eisop/testutils/TestViolationHandler.java +++ b/test-utils/src/main/java/io/github/eisop/testutils/TestViolationHandler.java @@ -8,16 +8,8 @@ */ public class TestViolationHandler implements ViolationHandler { - static { - // DEBUG: Confirm class is loaded by the RuntimeVerifier - System.err.println("DEBUG: TestViolationHandler class initialized."); - } - @Override public void handleViolation(String checkerName, String message) { - // DEBUG: Confirm method is called - System.err.println("DEBUG: TestViolationHandler.handleViolation invoked. Msg: " + message); - StackTraceElement caller = findCaller(); String location = (caller != null) ? caller.getFileName() + ":" + caller.getLineNumber() : "Unknown:0"; @@ -31,9 +23,7 @@ private StackTraceElement findCaller() { .walk( stream -> stream - // Skip the runtime framework internals (Verifier, Handler, etc.) .filter(f -> !f.getClassName().startsWith("io.github.eisop.runtimeframework")) - // Skip the test utils (This handler itself) .filter(f -> !f.getClassName().startsWith("io.github.eisop.testutils")) .findFirst() .map(StackWalker.StackFrame::toStackTraceElement) From bf99fbd6476a69d2607464bf023360f25625fffc Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Tue, 23 Dec 2025 00:31:14 -0500 Subject: [PATCH 47/50] fix(test): hack to select correct jars in test harness --- .../java/io/github/eisop/testutils/AgentTestHarness.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test-utils/src/main/java/io/github/eisop/testutils/AgentTestHarness.java b/test-utils/src/main/java/io/github/eisop/testutils/AgentTestHarness.java index 668f3ef..a84c717 100644 --- a/test-utils/src/main/java/io/github/eisop/testutils/AgentTestHarness.java +++ b/test-utils/src/main/java/io/github/eisop/testutils/AgentTestHarness.java @@ -153,7 +153,13 @@ private Path findJar(String prefix) throws IOException { try (Stream files = Files.list(distDir)) { return files .filter( - p -> p.getFileName().toString().startsWith(prefix) && p.toString().endsWith(".jar")) + 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( () -> From 8492d357b5c038ebd218b86b277b7d11b7069093 Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Tue, 23 Dec 2025 00:59:44 -0500 Subject: [PATCH 48/50] test: add mrore invoke test cases --- .../nullness/NullnessDirectoryTest.java | 4 +-- .../InstanceBoundary.java | 3 ++ .../NullableBoundary.java | 0 .../PrimitiveBoundary.java | 0 .../StaticBoundary.java | 0 .../UncheckedToCheckedInstance.java | 32 +++++++++++++++++++ .../UncheckedToCheckedStatic.java | 30 +++++++++++++++++ 7 files changed, 67 insertions(+), 2 deletions(-) rename checker/src/test/resources/test-cases/{nullness-boundary => nullness-invoke}/InstanceBoundary.java (84%) rename checker/src/test/resources/test-cases/{nullness-boundary => nullness-invoke}/NullableBoundary.java (100%) rename checker/src/test/resources/test-cases/{nullness-boundary => nullness-invoke}/PrimitiveBoundary.java (100%) rename checker/src/test/resources/test-cases/{nullness-boundary => nullness-invoke}/StaticBoundary.java (100%) create mode 100644 checker/src/test/resources/test-cases/nullness-invoke/UncheckedToCheckedInstance.java create mode 100644 checker/src/test/resources/test-cases/nullness-invoke/UncheckedToCheckedStatic.java diff --git a/checker/src/test/java/io/github/eisop/checker/nullness/NullnessDirectoryTest.java b/checker/src/test/java/io/github/eisop/checker/nullness/NullnessDirectoryTest.java index d7f18da..11d1e58 100644 --- a/checker/src/test/java/io/github/eisop/checker/nullness/NullnessDirectoryTest.java +++ b/checker/src/test/java/io/github/eisop/checker/nullness/NullnessDirectoryTest.java @@ -14,9 +14,9 @@ public void testParameterScenarios() throws Exception { } @Test - public void testBoundaryScenarios() throws Exception { + public void testInvokeScenarios() throws Exception { runDirectoryTest( - "nullness-boundary", + "nullness-invoke", "io.github.eisop.runtimeframework.checker.nullness.NullnessRuntimeChecker", false); } diff --git a/checker/src/test/resources/test-cases/nullness-boundary/InstanceBoundary.java b/checker/src/test/resources/test-cases/nullness-invoke/InstanceBoundary.java similarity index 84% rename from checker/src/test/resources/test-cases/nullness-boundary/InstanceBoundary.java rename to checker/src/test/resources/test-cases/nullness-invoke/InstanceBoundary.java index abd7475..7443f37 100644 --- a/checker/src/test/resources/test-cases/nullness-boundary/InstanceBoundary.java +++ b/checker/src/test/resources/test-cases/nullness-invoke/InstanceBoundary.java @@ -15,5 +15,8 @@ public static void main(String[] args) { 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-boundary/NullableBoundary.java b/checker/src/test/resources/test-cases/nullness-invoke/NullableBoundary.java similarity index 100% rename from checker/src/test/resources/test-cases/nullness-boundary/NullableBoundary.java rename to checker/src/test/resources/test-cases/nullness-invoke/NullableBoundary.java diff --git a/checker/src/test/resources/test-cases/nullness-boundary/PrimitiveBoundary.java b/checker/src/test/resources/test-cases/nullness-invoke/PrimitiveBoundary.java similarity index 100% rename from checker/src/test/resources/test-cases/nullness-boundary/PrimitiveBoundary.java rename to checker/src/test/resources/test-cases/nullness-invoke/PrimitiveBoundary.java diff --git a/checker/src/test/resources/test-cases/nullness-boundary/StaticBoundary.java b/checker/src/test/resources/test-cases/nullness-invoke/StaticBoundary.java similarity index 100% rename from checker/src/test/resources/test-cases/nullness-boundary/StaticBoundary.java rename to checker/src/test/resources/test-cases/nullness-invoke/StaticBoundary.java 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(); + } +} From 7e16a6f1728483716fd298ac84b3fdf8f90c63d6 Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Tue, 23 Dec 2025 12:40:49 -0500 Subject: [PATCH 49/50] docs: update README and add examples --- .gitignore | 4 + README.md | 2 - README.org | 103 ++++++++++++++++++ examples/global-policy/GlobalDemo.java | 38 +++++++ examples/global-policy/LegacyTrojan.java | 13 +++ examples/global-policy/SafeContract.java | 15 +++ examples/global-policy/UncheckedWriter.java | 16 +++ examples/standard-policy/DataHolder.java | 18 +++ examples/standard-policy/StandardDemo.java | 57 ++++++++++ .../standard-policy/UncheckedLibrary.java | 15 +++ examples/standard-policy/UncheckedParent.java | 16 +++ .../core/AnnotationInstrumenter.java | 7 +- testBasicScenarios | 0 13 files changed, 296 insertions(+), 8 deletions(-) delete mode 100644 README.md create mode 100644 README.org create mode 100644 examples/global-policy/GlobalDemo.java create mode 100644 examples/global-policy/LegacyTrojan.java create mode 100644 examples/global-policy/SafeContract.java create mode 100644 examples/global-policy/UncheckedWriter.java create mode 100644 examples/standard-policy/DataHolder.java create mode 100644 examples/standard-policy/StandardDemo.java create mode 100644 examples/standard-policy/UncheckedLibrary.java create mode 100644 examples/standard-policy/UncheckedParent.java delete mode 100644 testBasicScenarios diff --git a/.gitignore b/.gitignore index aad400c..103c78d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,10 @@ # Ignore Gradle build output directory build +# Example Classfiles +examples/global-policy/global/ +examples/standard-policy/standard/ + # Eclipse .metadata .classpath 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..bec6a0a --- /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 + +The 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 Nullness Checker logic) +- ~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/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/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java b/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java index 4ac180d..8e37c67 100644 --- a/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java +++ b/framework/src/main/java/io/github/eisop/runtimeframework/core/AnnotationInstrumenter.java @@ -139,13 +139,8 @@ protected void generateUncheckedReturnCheck( ClassModel classModel, ClassLoader loader) { if (ret.opcode() != Opcode.ARETURN) return; - System.out.println( - "[DEBUG] Checking Unchecked Return for " - + classModel.thisClass().asInternalName() - + "." - + method.methodName().stringValue()); - TargetAnnotation target = policy.getUncheckedOverrideReturnCheck(classModel, method, loader); + if (target != null) { b.dup(); target.check( diff --git a/testBasicScenarios b/testBasicScenarios deleted file mode 100644 index e69de29..0000000 From c7998392f4f50ed1f48bbc11c10f9aefe0477cb9 Mon Sep 17 00:00:00 2001 From: Alex Cook Date: Tue, 23 Dec 2025 12:45:14 -0500 Subject: [PATCH 50/50] fix: README formatting --- README.org | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.org b/README.org index bec6a0a..6ca51f8 100644 --- a/README.org +++ b/README.org @@ -24,11 +24,11 @@ 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. +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 -The project uses Gradle 9.0. To build the agent and runtime libraries: +This project uses Gradle 9.0. To build the agent and runtime libraries: #+begin_src bash ./gradlew build @@ -38,7 +38,7 @@ Note: This will also run the test suite located under ~checker/src/test/java/~ ( This will generate the artifacts in ~build/dist/~: - ~framework.jar~ (The Java Agent) -- ~checker.jar~ (The Nullness Checker logic) +- ~checker.jar~ (The Runtime Nullness Checker) - ~test-utils.jar~ (Testing helpers) - ~checker-qual.jar~ (Annotations)